from panflute import Element, ListContainer, Inline, Block from panflute import Cite, Code, Emph, Image, LineBreak, Link, Math, Note, Quoted, RawInline, SmallCaps, SoftBreak, Space, Span, Str, Strikeout, Strong, Subscript, Superscript, Underline from panflute import BlockQuote, BulletList, Citation, CodeBlock, Definition, DefinitionItem, DefinitionList, Div, Figure, Header, HorizontalRule, LineBlock, LineItem, ListItem, MetaBlocks, MetaBool, MetaInlines, MetaList, MetaMap, MetaString, Null, OrderedList, Para, Plain, RawBlock, Table, TableBody, TableFoot, TableHead from panflute import TableRow, TableCell, Caption, Doc from panflute import MetaValue from typing import Union, Callable from types import ModuleType import os import re import warnings import importlib import json from .whitespace import NBSP from .elements import FQuoted from .context import Group, InlineGroup, BlockGroup from .util import nullify, import_md from .context import Context, CommandCallable from .whitespace import Whitespace, bavlna from .command import BlockCommand, InlineCommand, CodeCommand, Command, InlineError from .command_util import handle_command_define, parse_command from .nop_processor import NOPProcessor, ELCl, DoubleDocError class TransformProcessor(NOPProcessor): root_file_path: str root_highlight_style: str = "default" _command_modules: list[tuple[Union[dict[str, CommandCallable], ModuleType], str]] = [] class UnknownElementError(Exception): "An unknown Element has been passed to the TransformProcessor, probably because panflute introduced a new one." pass def __init__(self, root_file_path: str, *args, **kwargs): self.root_file_path = root_file_path super().__init__(*args, **kwargs) def add_command_module(self, module: Union[dict[str, CommandCallable], ModuleType], module_name: str=""): self._command_modules.append((module, module_name)) def get_pretransformers(self) -> list[Callable[[ELCl],ELCl]]: return super().get_pretransformers()+[self.handle_if_attribute, self.handle_ifnot_attribute] def handle_if_attribute(self, e: ELCl) -> ELCl: # `if` attribute. Only show this element if flag is set. if hasattr(e, "attributes") and "if" in e.attributes: if not self.context.is_flag_set(e.attributes["if"]): return nullify(e) return e def handle_ifnot_attribute(self, e: ELCl) -> ELCl: # `ifnot` attribute. Only show this element if flag is NOT set if hasattr(e, "attributes") and "ifnot" in e.attributes: if self.context.is_flag_set(e.attributes["ifnot"]): return nullify(e) return e def transform_ListContainer(self, e: ListContainer) -> ListContainer: try: return super().transform_ListContainer(e) except TypeError as err: names = [] for el in e: if hasattr(el, "attributes") and "c" in el.attributes: names.append(el.attributes["c"]) if len(names) > 0: raise InlineError(f"The command{'s' if len(names) > 1 else ''} {names[0] if len(names) == 1 else names} was called in an Inline way but returned Block content. Put it in a paragraph alone or execute it as a Div using: \n::: {{c={names[0] if len(names) == 1 else ''}}}\n:::") else: raise err def transform_Doc(self, e: Doc) -> Doc: if self.context is not None: raise DoubleDocError() self.context = Context(e, self.root_file_path) for module, module_name in self._command_modules: self.context.add_commands_from_module(module, module_name) e.content = self.transform(e.content) e.content = [BlockGroup(*e.content, context=self.context)] return e def transform_Quoted(self, e: Quoted) -> FQuoted: e.content = self.transform(e.content) quote_styles = { "cs": "cs", "en": "en", "sk": "cs", None: None } return FQuoted(*e.content, quote_type=e.quote_type, style=quote_styles[self.context.get_metadata("lang")]) def transform_Image(self, e: Image) -> Image: e.content = self.transform(e.content) # OG now has Context so this is not needed per se, but I'm keeping this here for the handling of attribute > context > default value # Pass down "no-srcset" metadatum as attribute down to images. if not "no-srcset" in e.attributes: e.attributes["no-srcset"] = self.context.get_metadata("no-srcset") if self.context.get_metadata("no-srcset") is not None else False return e def create_Group(self, *content, new_context: Context, inline: bool=False) -> Group: old_context = self.context self.context = new_context content = self.transform([*content]) self.context = old_context if inline: return InlineGroup(*content, context=new_context) else: return BlockGroup(*content, context=new_context) def transform_Para(self, e: Para) -> Union[Para, Div]: if len(e.content) == 1 and isinstance(e.content[0], Span): # If the span turns out to be a command, it might return a Div. We should then replace ourselves with the Div span = e.content[0] span = self.transform(span) if isinstance(span, Div): return span else: e.content[0] = span return super().transform_Para(e) else: return super().transform_Para(e) def transform_Div(self, e: Div) -> Union[Div, Group, Null, RawBlock]: e.content = self.transform(e.content) if "group" in e.classes: # `.group` class for Divs # Content of Div is enclosed in a separate context, all attributes are passed as metadata new_context = Context(Doc(), self.context.path, self.context, trusted=self.context.trusted) for attribute, value in e.attributes.items(): new_context.set_metadata(attribute, value) return self.create_Group(*e.content, new_context=new_context) if "c" in e.attributes: # Commands can be called multiple ways, this handles the following syntax: # :::{c=commandname} # ::: e = BlockCommand(*e.content, identifier=e.identifier, classes=e.classes, attributes=e.attributes) return self.transform(e) if "partial" in e.attributes: # `partial` attribute # Used to include a file which is executed in a different (child) Context. if not "type" in e.attributes: e.attributes["type"] = "md" if not self.context.trusted: # If we're in an untrusted context, we shouldn't allow inclusion of files outside the PWD. full_path = os.path.abspath(self.context.dir + "/" + e.attributes["partial"]) pwd = os.path.abspath(".") if os.path.commonpath([full_path, pwd]) != os.path.commonpath([pwd]): return nullify(e) text = open(self.context.dir + "/" + e.attributes["partial"], "r").read() path = self.context.dir + "/" + e.attributes["partial"] if e.attributes["type"] == "md": includedDoc = import_md(text) trusted = True if "untrusted" in e.attributes and (e.attributes["untrusted"] == True or e.attributes["untrusted"] == 'True'): trusted = False if not self.context.trusted: trusted = False return self.create_Group(*includedDoc.content, new_context=Context(includedDoc, path, self.context, trusted=trusted)) elif e.attributes["type"] in ["tex", "html"]: return RawBlock(text, e.attributes["type"]) if "header_content" in e.classes: header_content = self.context.get_metadata("header_content") header_content = [] if header_content is None else header_content header_content.append(MetaBlocks(*self.transform(e.content))) self.context.set_metadata("header_content", header_content) return Null() if "footer_content" in e.classes: footer_content = self.context.get_metadata("footer_content") footer_content = [] if footer_content is None else footer_content footer_content.append(MetaBlocks(*self.transform(e.content))) self.context.set_metadata("footer_content", footer_content) return Null() if "lang" in e.attributes: warnings.warn("To set language in a way formátítko will understand, this Div has to have the `.group` class and be a Group.", UserWarning) return e def transform_Span(self, e: Span) -> Span: e.content = self.transform(e.content) if "group" in e.classes: # `.group` class for Spans # Content of Span is enclosed in a separate context, all attributes are passed as metadata new_context = Context(Doc(), self.context.path, self.context, trusted=self.context.trusted) for attribute, value in e.attributes.items(): new_context.set_metadata(attribute, value) return self.create_Group(*e.content, new_context=new_context, inline=True) if "c" in e.attributes: # Commands can be called multiple ways, this handles the following syntax: # []{c=commandname} and e = InlineCommand(*e.content, identifier=e.identifier, classes=e.classes, attributes=e.attributes) return self.transform(e) if len(e.content) == 1 and isinstance(e.content[0], Str): ## Handle special command shorthand [!commandname]{} if re.match(r"^![\w.]+$", e.content[0].text): e = InlineCommand(identifier=e.identifier, classes=e.classes, attributes={**e.attributes, "c": e.content[0].text[1:]}) return self.transform(e) ## Handle import [#ksp_formatitko as ksp]{}, [#ksp_formatitko]{type=module} or [#path/file.md]{type=md} # Import a python module as commands (type=module, the default) or # import all metadata from a md file, dropping its contents. elif re.match(r"^#.+$", e.content[0].text): if not "type" in e.attributes: e.attributes["type"] = "module" if e.attributes["type"] == "md": importedDoc = import_md(open(self.context.dir + "/" + e.content[0].text[1:], "r").read()) self.transform(importedDoc.content) elif e.attributes["type"] == "module": matches = re.match(r"^(\w+)(?: as (\w+))?$", e.content[0].text[1:]) if not matches: raise SyntaxError(f"`{e.content[0].text[1:]}`: invalid syntax") module = importlib.import_module(matches.group(1)) module_name = matches.group(1) if matches.group(2) is None else matches.group(2) self.context.add_commands_from_module(module, module_name) elif e.attributes["type"] == "metadata": data = json.load(open(self.context.dir + "/" + e.content[0].text[1:], "r")) key = "" if not "key" in e.attributes else e.attributes["key"] self.context.import_metadata(data, key) else: raise SyntaxError(f"`{e.attributes['type']}`: invalid import type") return nullify(e) ## Handle metadata print [$key1.key2]{} # This is a shorthand for just printing the content of some metadata. elif re.match(r"^\$[\w.]+$", e.content[0].text): val = self.context.get_metadata(e.content[0].text[1:], False) if isinstance(val, MetaInlines): e = Span(*val.content) e = self.transform(e) elif isinstance(val, MetaString): e = Span(Str(val.string)) elif isinstance(val, MetaBool): e = Span(Str(str(val.boolean))) else: raise TypeError(f"Cannot print value of metadatum '{e.content[0].text[1:]}' of type '{type(val)}'") return e return e def transform_CodeBlock(self, e: CodeBlock) -> Union[CodeBlock, Div, Null]: if "markdown" in e.classes and "group" in e.classes: includedDoc = import_md(e.text) return self.create_Group(*includedDoc.content, new_context=Context(includedDoc, self.context.path, self.context, self.context.trusted)) if "python" in e.classes and "run" in e.classes: if not self.context.trusted: return nullify(e) command_output = parse_command(e.text)(BlockCommand(), self.context, self) e = BlockCommand().replaceSelf(*([] if command_output is None else command_output)) return self.transform(e) if "python" in e.classes and ("define" in e.attributes or "redefine" in e.attributes): if not self.context.trusted: return nullify(e) return handle_command_define(e, self.context) if "c" in e.attributes: return self.transform(CodeCommand(e.text, identifier=e.identifier, classes=e.classes, attributes=e.attributes)) # Pass down metadata 'highlight' and 'highlight_style' as attribute to CodeBlocks # OG now has Context so this is not needed per se, but I'm keeping this here for the handling of attribute > context > default value if not "highlight" in e.attributes: e.attributes["highlight"] = self.context.get_metadata("highlight") if self.context.get_metadata("highlight") is not None else True if not "style" in e.attributes: e.attributes["style"] = self.context.get_metadata("highlight-style") if self.context.get_metadata("highlight-style") is not None else "default" return e def transform_Command(self, e: Command) -> Union[Div, Span]: if not self.context.get_command(e.attributes["c"]): raise NameError(f"Command not defined '{e.attributes['c']}'.") command_output = self.context.get_command(e.attributes["c"])(e, self.context, self) e = e.replaceSelf(*([] if command_output is None else command_output)) return e def transform_Whitespace(self, e: Whitespace) -> Whitespace: if bavlna(e, self.context): return NBSP() else: return e