#!/usr/bin/env python # ***************************************************************************** # Copyright (C) 2018-2023 Thomas Touhey # 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, target, ) 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 target(refid=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() el = reference(refid=self.value) el.extend(children) yield el 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(refuri=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