2
0
Fork 0
textout/textoutpc/builtin.py

1055 lines
30 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python
# *****************************************************************************
# Copyright (C) 2018-2023 Thomas Touhey <thomas@touhey.fr>
# This file is part of the textoutpc project, which is MIT-licensed.
# *****************************************************************************
"""Built-in tags for textoutpc."""
from __future__ import annotations
from collections.abc import Sequence
import re
from string import ascii_lowercase, ascii_uppercase
from typing import ClassVar, Iterator, Literal
from urllib.parse import urlparse
from docutils.nodes import (
Node,
Element,
Text,
container,
emphasis,
image,
literal,
reference,
strong,
)
from thcolor.colors import Color
from thcolor.errors import ColorExpressionSyntaxError
from .exceptions import InvalidValue, MissingValue, UnexpectedValue
from .nodes import progress, spoiler
from .tags import RawTag, Tag
BIG_FONT_SIZE: float = 2.00
SMALL_FONT_SIZE: float = 0.75
COLOR_TAG_NAMES: set[str] = {
"red",
"green",
"blue",
"yellow",
"maroon",
"purple",
"gray",
"grey",
"brown",
}
FONT_NAMES: dict[str, str] = {
"arial": "Arial",
"comic": "Comic MS",
"tahoma": "Tahoma",
"courier": "Courier",
"haettenschweiler": "Haettenschweiler",
"mono": "monospace",
"monospace": "monospace",
}
class TextTag(Tag):
"""Main tag for setting text formatting.
Example uses::
[b]Bold text.[/b]
[i]Italic text.[/i]
[u]Underlined text.[/u]
[strike]Striked text.[/strike]
[striked]Text strikes again.[/striked]
[font=arial]Arial text.[/font]
[arial]Arial text again.[/arial]
[blue]This will be in blue[/blue]
[color=blue]This as well[/color]
[color=rgb(255, 255, 255, 0.4)]BLACKNESS[/color]
[color=hsl(0, 100%, 0.5)]This will be red.[/color]
Also supports a hack used on Planète Casio for a while, which
is a CSS injection, e.g.:
[color=brown; size: 16pt]Hello world![/color]
"""
__slots__ = (
"strong",
"italic",
"underline",
"overline",
"strike",
"font_name",
"font_size",
"font_size_unit",
"text_color",
"back_color",
)
strong: bool
"""Whether the text should be set as strong or not."""
italic: bool
"""Whether the text should be set as italic or not."""
underline: bool
"""Whether the text should be underlined or not."""
overline: bool
"""Whether the text should be overlined or not."""
strike: bool
"""Whether the text should be striked or not."""
font_name: str | None
"""Name of the font to set to the text."""
font_size: float | None
"""Size of the font to set to the text."""
font_size_unit: Literal["pt", "em"]
"""Unit of the font size."""
text_color: Color | None
"""Color to set to the text."""
back_color: Color | None
"""Color to set to the text background."""
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
self.strong = self.name == "[b]"
self.italic = self.name == "[i]"
self.underline = self.name == "[u]"
self.overline = self.name == "[o]"
self.strike = self.name in ("[s]", "[strike]", "[striked]")
self.font_name = None
self.font_size = None
self.font_size_unit = "pt"
self.text_color = None
self.back_color = None
# Historically, such tags used to be used for CSS injections.
# We want to support limited CSS injections here, by parsing a lighter
# syntax of CSS.
value = self.value
css_properties: list[str] = []
if value is not None:
css_properties = value.split(";")
value = css_properties.pop(0)
if self.name == "[font]":
if value is None:
raise MissingValue()
if value not in FONT_NAMES:
raise InvalidValue(
'Invalid font name "{font_name}".',
font_name=value,
)
self.font_name = FONT_NAMES[value]
elif self.name[1:-1] in FONT_NAMES:
if value is not None:
raise UnexpectedValue()
self.font_name = FONT_NAMES[self.name[1:-1]]
elif self.name == "[big]":
if value is not None:
raise UnexpectedValue()
self.font_size = BIG_FONT_SIZE
elif self.name == "[small]":
if value is not None:
raise UnexpectedValue()
self.font_size = SMALL_FONT_SIZE
elif self.name == "[size]":
if value is None:
raise MissingValue()
if value == "big":
self.font_size = BIG_FONT_SIZE
elif value == "small":
self.font_size = SMALL_FONT_SIZE
else:
try:
self.font_size = round(int(value) / 100.0, 2)
except ValueError:
raise InvalidValue(
"Invalid font size: {value}",
value=value,
)
if self.font_size <= 0 or self.font_size > 3.0:
raise InvalidValue(
"Invalid font size: {value}",
value=value,
)
elif self.name in ("[c]", "[color]"):
if value is None:
raise MissingValue()
try:
self.text_color = Color.fromtext(value)
except (ColorExpressionSyntaxError, ValueError) as exc:
raise InvalidValue(f"Invalid color: {exc}") from exc
elif self.name == "[f]":
if value is None:
raise MissingValue()
try:
self.back_color = Color.fromtext(value)
except (ColorExpressionSyntaxError, ValueError) as exc:
raise InvalidValue(f"Invalid color: {exc}") from exc
elif self.name[1:-1] in COLOR_TAG_NAMES:
if value is not None:
raise UnexpectedValue()
self.text_color = Color.fromtext(self.name[1:-1])
elif self.name == "[css]":
if value is None:
raise MissingValue()
css_properties.insert(0, value)
elif self.value is not None:
# Other tags do not expect any value.
raise UnexpectedValue()
# CSS properties.
for css_property in css_properties:
name, *value_list = css_property.split(":")
if not value_list:
continue
name = name.strip()
value = ":".join(value_list).strip()
if name in ("size", "font-size"):
unit: Literal["pt", "em"]
if value.endswith("pt"):
value = value[:-2].rstrip()
unit = "pt"
elif value.endswith("em"):
value = value[:-2].rstrip()
unit = "em"
try:
size = float(int(value))
except ValueError:
continue
if size <= 0:
continue
self.font_size = size
self.font_size_unit = unit
elif name == "color":
try:
self.text_color = Color.fromtext(value)
except ValueError:
continue
elif name == "background-color":
try:
self.back_color = Color.fromtext(value)
except ValueError:
continue
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
style_items = []
if self.text_color is not None:
for css_color in self.text_color.css():
style_items.append(("color", css_color))
if self.back_color is not None:
for css_color in self.back_color.css():
style_items.append(("background-color", css_color))
if self.font_name is not None:
style_items.append(("font-family", self.font_name))
if self.font_size is not None:
if int(self.font_size) == self.font_size:
font_size = f"{int(self.font_size):d}"
else:
font_size = f"{self.font_size:.02f}"
style_items.append(
(
"font-size",
f"{font_size}{self.font_size_unit}",
),
)
text_decorations = []
if self.underline:
text_decorations.append("underline")
if self.overline:
text_decorations.append("overline")
if self.strike:
text_decorations.append("line-through")
if text_decorations:
style_items.append(("text-decoration", " ".join(text_decorations)))
if self.strong:
children = [strong("", *children)]
if self.italic:
children = [emphasis("", *children)]
if style_items:
con = container("", *children)
con.attributes["style"] = "; ".join(
f"{key}: {value}" for key, value in style_items
)
children = [con]
yield from children
class AlignTag(Tag):
"""Main tag for aligning paragraphs.
Example uses::
[align=center]This text is centered horizontally.[/align]
[justify]This text is justified.[/justify]
"""
__slots__ = ("kind",)
ALIGN_KEYS = {
"center": "center",
"centre": "center",
"left": "left",
"right": "right",
"justify": "justify",
}
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
if self.name == "[align]":
if self.value is None:
raise MissingValue()
if self.value not in self.ALIGN_KEYS:
raise InvalidValue(
"Expected one of these values:"
+ ", ".join(self.ALIGN_KEYS.keys()),
)
kind = self.value
elif (
not self.name.startswith("[")
or not self.name.endswith("]")
or not self.name[1:-1] in self.ALIGN_KEYS
):
raise ValueError(
"Only supported the following names: "
+ ", ".join(
f"[{name}]" for name in ("align", *self.ALIGN_KEYS)
),
)
elif self.value is not None:
raise UnexpectedValue()
else:
kind = self.name[1:-1]
self.kind = self.ALIGN_KEYS[kind]
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
div = container("", *children)
div.attributes["class"] = "align-" + self.kind
yield div
class CodeTag(RawTag):
"""Basic code tag, for displaying code.
Example uses::
[code]int main()
{
printf("hello, world");
}[/code]
"""
__slots__ = ()
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
div = container("", *children)
div.attributes["class"] = "code"
yield div
class InlineCodeTag(RawTag):
"""Inline code tag.
This tag does not display a box, simply doesn't evaluate the content and
uses a monospace font.
Example uses::
`some inline code`
[inlinecode][b]The tags will be shown verbatim.[/b][/inlinecode]
[inlinecode][inlinecode][i]This also[/inlinecode] works![/inlinecode]
"""
__slots__ = ()
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
node = literal("", *children)
node.attributes["class"] = "inline-code"
yield node
class NoEvalTag(RawTag):
"""Tag for not evaluating content.
Same as above, except doesn't apply any parent container or additional
style.
Example uses::
[noeval][b]wow, and no need for monospace![/b][/noeval]
"""
__slots__ = ()
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
yield from children
class ImageTag(RawTag):
"""Tag for displaying an image.
Example uses::
[img]picture_url[/img]
[img=center]picture_url[/img]
[img=12x24]picture_url[/img]
[img=center|12x24]picture_url[/img]
[img=x24|right]picture_url[/img]
"""
__slots__ = ("width", "height", "alignment", "floating")
MODES: ClassVar[
dict[
str,
tuple[Literal["center", "left", "right"] | None, bool],
]
] = {
"center": ("center", False),
"centre": ("center", False),
"left": ("left", False),
"right": ("right", False),
"float": (None, True),
"floating": (None, True),
"float-left": ("left", True),
"float-center": ("center", True),
"float-centre": ("center", True),
"float-right": ("right", True),
}
"""The mapping between mode strings and alignment and floating."""
width: int | None
"""The width in pixels to display the image as, if provided."""
height: int | None
"""The height in pixels to display the image as, if provided."""
alignment: Literal["center", "left", "right"] | None
"""The alignment to display the image as, if provided."""
floating: bool
"""Whether the image should be floating or not."""
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
self.width = None
self.height = None
self.alignment = None
self.floating = False
if self.value is None:
return
for arg in self.value.split("|"):
arg = arg.strip().casefold()
if not arg:
continue
if arg[0] in "0123456789x":
try:
raw_w, *raw_hs = arg.split("x")
(raw_h,) = raw_hs if raw_hs else (raw_w,)
w = None
if raw_w:
w = int(raw_w)
h = None
if raw_h:
h = int(raw_h)
except ValueError:
continue
if w == 0 or h == 0:
continue
self.width = w
self.height = h
elif arg in self.MODES:
self.alignment, self.floating = self.MODES[arg]
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
url = self.get_text_from_raw_children(children)
yield self.build_element(url=url)
def build_element(self, *, url: str) -> Element:
"""Build the image element.
:param url: The URL of the image element.
:return: The node element.
"""
parsed_url = urlparse(url)
if parsed_url.scheme not in ("http", "https"):
raise ValueError(
f"Forbidden image source scheme: {parsed_url.scheme!r}",
)
style = []
classes = []
if self.width is not None:
style.append(f"width: {self.width}px")
if self.height is not None:
style.append(f"height: {self.height}px")
else:
style.append("height: auto")
elif self.height is not None:
style.append("width: auto")
style.append(f"height: {self.height}px")
if self.floating:
classes.append(f"img-float-{self.alignment or 'right'}")
elif self.alignment is not None:
classes.append(f"img-{self.alignment}")
img = image()
img.attributes["src"] = url
if style:
img.attributes["style"] = "; ".join(style)
if classes:
img.attributes["class"] = " ".join(classes)
return img
class AdminImageTag(ImageTag):
"""Tag for displaying an image from the administration.
This tag is special for Planète Casio, as it takes images from the
administration's (hence ``ad``) image folder. It adds the folder's prefix.
Example uses::
[adimg]some_picture.png[/img]
[adimg=center]some_picture.png[/img]
[adimg=12x24]some_picture.png[/img]
[adimg=center|12x24]some_picture.png[/img]
[adimg=x24|right]some_picture.png[/img]
"""
__slots__ = ()
def build_element(self, *, url: str) -> Element:
"""Build the image element.
:param url: The URL of the image element.
:return: The node element.
"""
return super().build_element(
url="https://www.planet-casio.com/images/ad/" + url,
)
class LabelTag(Tag):
"""Tag for defining an anchor at a point of the document.
Example uses::
[label=installation]Installation de tel logiciel... (no ending req.)
[label=compilation][/label] Compilation de tel logiciel...
"""
__slots__ = ()
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
if self.value is None:
raise MissingValue()
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
yield reference(name=self.value)
yield from children
class TargetTag(Tag):
"""Tag for linking to an anchor defined in the document.
Example uses::
[target=installation]Check out the installation manual[/target]!
"""
__slots__ = ()
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
if self.value is None:
raise MissingValue()
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
if self.value is None: # pragma: no cover
raise MissingValue()
yield reference(uri="#" + self.value)
yield from children
class LinkTag(Tag):
"""Tag for linking to an external resource.
Example uses::
[url=https://example.org/hi]Go to example.org[/url]!
[url=/Fr/index.php][/url]
[url]https://random.org/randomize.php[/url]
"""
__slots__ = ("url",)
url: str | None
"""The stored URL for the link tag."""
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
self.url = None
if self.value is None:
return None
self.url = self.process_url(self.value)
if self.url is None:
raise InvalidValue("Not a valid URL: {url}", url=self.value)
def is_raw(self) -> bool:
"""Return whether the content of this tag should be read as raw.
:return: Whether the tag should be read as raw or not.
"""
return self.value is None
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
url = self.value
if url is None:
orig_url = self.get_text_from_raw_children(children)
url = self.process_url(orig_url)
if url is None:
raise ValueError(f"Not a valid URL: {orig_url}")
ref = reference(uri=url)
ref.extend(children)
yield ref
def process_url(self, url: str) -> str | None:
"""Process the URL.
:param url: The URL to process.
:return: The adapted URL, or :py:data:`None` if the URL is invalid.
"""
for prefix in ("http://", "https://", "ftp://", "ftps://", "/"):
if url.startswith(prefix):
return url
return None
class ProfileTag(LinkTag):
"""Tag for linking to a profile for the current site.
This tag was originally made for Planète Casio's profiles.
It adds a prefix to the content, and sets the value.
Example uses::
[profil]Cakeisalie5[/]
"""
__slots__ = ()
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
if self.value is not None:
raise UnexpectedValue()
def process_url(self, url: str) -> str | None:
"""Process the URL.
:param url: The URL to process.
:return: The adapted URL, or :py:data:`None` if the URL is invalid.
"""
if any(
car not in "abcdefghijklmnopqrstuvwxyz0123456789_ -."
for car in url
):
return None
return (
"https://www.planet-casio.com/Fr/compte/voir_profil.php?membre="
+ url
)
class TopicTag(LinkTag):
"""Tag for linking topics for the current site.
Originally made for Planète Casio's forum topics.
Example uses::
[topic]234[/]
"""
__slots__ = ()
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
if self.value is not None:
raise UnexpectedValue()
def process_url(self, url: str) -> str | None:
"""Process the URL.
:param url: The URL to process.
:return: The adapted URL, or :py:data:`None` if the URL is invalid.
"""
try:
topic_id = int(url)
except ValueError:
return None
return (
"https://www.planet-casio.com/Fr/forums/lecture_sujet.php?id="
+ str(topic_id)
)
class TutorialTag(LinkTag):
"""Tag for linking tutorials for the current site.
Originally made for Planète Casio's tutorials.
Example uses::
[tutorial]71[/]
"""
__slots__ = ()
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
if self.value is not None:
raise UnexpectedValue()
def process_url(self, url: str) -> str | None:
"""Process the URL.
:param url: The URL to process.
:return: The adapted URL, or :py:data:`None` if the URL is invalid.
"""
try:
tutorial_id = int(url)
except ValueError:
return None
return (
"https://www.planet-casio.com/Fr/programmation/tutoriels.php?id="
+ str(tutorial_id)
)
class ProgramTag(LinkTag):
"""Tag for linking programs for the current site.
Originally made for Planète Casio's programs.
Example uses::
[program]3598[/]
"""
__slots__ = ()
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
if self.value is not None:
raise UnexpectedValue()
def process_url(self, url: str) -> str | None:
"""Process the URL.
:param url: The URL to process.
:return: The adapted URL, or :py:data:`None` if the URL is invalid.
"""
try:
tutorial_id = int(url)
except ValueError:
return None
return (
"https://www.planet-casio.com/Fr/programmes/"
+ "voir_un_programme_casio.php?showid="
+ str(tutorial_id)
)
class ProgressTag(Tag):
"""Tag for displaying a progress bar.
Example uses::
[progress=50]My great progress bar[/progress]
[progress=100][/progress]
"""
__slots__ = ("progress_value",)
progress_value: int
"""The progress value, between 0 and 100 included."""
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
if self.value is None:
raise MissingValue("Expected an integer between 0 and 100.")
try:
self.progress_value = int(self.value)
except ValueError:
raise InvalidValue(
"Value should have been an integer between 0 and 100.",
)
if self.progress_value < 0 or self.progress_value > 100:
raise InvalidValue(
"Value should have been an integer between 0 and 100.",
)
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
result = progress(value=self.progress_value)
result.extend(children)
yield result
class RotTag(RawTag):
"""Tag for un-rot13-ing raw text and returning such text.
Example uses::
[rot=13]obawbhe[/rot]
[rot13]Obawbhe[/rot13]
"""
__slots__ = "_rot"
general_tag_names = ("[rot]",)
"""The accepted tag names for this tag, with an expected value."""
embedded_tag_pattern = re.compile(r"\[rot0*?([0-9]|1[0-9]|2[0-5])\]", re.I)
"""The compiled pattern for tag names with embedded rot values."""
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
if self.name in self.general_tag_names:
if self.value is None:
raise MissingValue()
try:
self._rot = int(self.value)
except ValueError:
raise InvalidValue("Expected a rot value between 0 and 25")
if self._rot < 0 or self._rot >= 26:
raise InvalidValue("Expected a rot value between 0 and 25")
return
m = self.embedded_tag_pattern.match(self.name)
if m is None:
raise ValueError(f"Unsupported tag name {self.name!r} for rot")
if self.value is not None:
raise UnexpectedValue()
self._rot = int(m.group(1))
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
text = self.get_text_from_raw_children(children)
result = str.translate(
text,
str.maketrans(
ascii_uppercase + ascii_lowercase,
ascii_uppercase[self._rot :]
+ ascii_uppercase[: self._rot]
+ ascii_lowercase[self._rot :]
+ ascii_lowercase[: self._rot],
),
)
yield Text(result)
class SpoilerTag(Tag):
"""Tag for hiding content at first glance.
This tag produces an element that requires the reader to click on a
button to read its content. It can help to contain "secret" elements,
such as solutions, source code, or various other things.
Example uses::
[spoiler]This is hidden![/spoiler]
[spoiler=Y a quelque chose de caché!|Ah, bah en fait non :)]:E
And it's multiline, [big]and formatted[/big], as usual :D[/spoiler]
"""
__slots__ = ("_closed_title", "_opened_title")
def validate(self) -> None:
"""Validate the name and value for this tag.
:raises TagValidationError: The name and value combination is invalid.
"""
closed, opened = "", ""
if self.value is not None:
closed, _, opened = self.value.partition("|")
self._closed_title = closed or "Cliquez pour découvrir"
self._opened_title = opened or "Cliquez pour recouvrir"
def process(self, *, children: Sequence[Node]) -> Iterator[Node]:
"""Process the tag with children to build document nodes.
:param children: The children to process.
:return: The produced nodes.
"""
result = spoiler(
opened_title=self._opened_title,
closed_title=self._closed_title,
)
result.extend(children)
yield result