Coverage for src / rendercv / renderer / templater / markdown_parser.py: 100%
80 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
1import itertools
2import re
3from xml.etree.ElementTree import Element
5import markdown
6import markdown.core
9def to_typst_string(elem: Element) -> str:
10 """Recursively convert XML Element tree to Typst markup string.
12 Why:
13 Python Markdown library outputs XML Element tree. Typst requires its
14 own markup syntax for bold, italic, links, etc. Recursive traversal
15 converts entire element tree including nested formatting.
17 Args:
18 elem: XML Element from Markdown parser.
20 Returns:
21 Typst-formatted string.
22 """
23 result = []
25 # Handle the element's text content
26 if elem.text:
27 result.append(escape_typst_characters(elem.text))
29 # Process child elements
30 for child in elem:
31 match child.tag:
32 case "strong":
33 # Bold: **text** -> #strong[text]
34 inner = to_typst_string(child)
35 child_content = f"#strong[{inner}]"
37 case "em":
38 # Italic: *text* -> #emph[text]
39 inner = to_typst_string(child)
40 child_content = f"#emph[{inner}]"
42 case "code":
43 # Inline code: `text` -> `text`
44 # Code content is already escaped by the parser
45 child_content = f"`{child.text}`"
47 case "a":
48 # Link: [text](url) -> #link("url")[text]
49 href = child.get("href") if child.get("href") else "https://example.com"
50 inner = to_typst_string(child)
51 child_content = f'#link("{href}")[{inner}]'
53 case "div":
54 child_content = (
55 "#summary["
56 + to_typst_string(child).strip("\n").replace("\n", " \\ ")
57 + "]"
58 )
60 case _:
61 if getattr(child, "attrib", {}).get("class") == "admonition-title":
62 continue
63 child_content = to_typst_string(child)
65 result.append(child_content)
67 # Handle tail text (text after the closing tag of child)
68 if child.tail:
69 result.append(escape_typst_characters(child.tail))
71 return "".join(result)
74typst_command_pattern = re.compile(r"#([A-Za-z][^\s()\[]*)(\([^)]*\))?(\[[^\]]*\])?")
75math_pattern = re.compile(r"(\$\$.*?\$\$)")
78def escape_typst_characters(string: str) -> str:
79 """Escape Typst special characters while preserving Typst commands and math.
81 Why:
82 User content may contain Typst special characters like `#`, `$`, `[` that
83 would break compilation. Escaping prevents interpretation as commands.
84 Existing Typst commands and math must remain unescaped.
86 Args:
87 string: Text to escape.
89 Returns:
90 Escaped string safe for Typst.
91 """
92 if string == "\n":
93 return string
95 # Find all the Typst commands, and keep them separate so that nothing is escaped
96 # inside the commands.
97 typst_command_mapping = {}
98 for i, match in enumerate(
99 itertools.chain(
100 math_pattern.finditer(string),
101 typst_command_pattern.finditer(string),
102 )
103 ):
104 dummy_name = f"RENDERCVTYPSTCOMMANDORMATH{i}"
105 typst_command_mapping[dummy_name] = match.group(0)
106 string = string.replace(typst_command_mapping[dummy_name], dummy_name)
107 typst_command_mapping[dummy_name] = typst_command_mapping[dummy_name].replace(
108 "$$", "$"
109 )
111 # Add the tail after the last match
112 escape_dictionary = {
113 "[": "\\[",
114 "]": "\\]",
115 "\\": "\\\\",
116 '"': '\\"',
117 "#": "\\#",
118 "$": "\\$",
119 "@": "\\@",
120 "%": "\\%",
121 "~": "\\~",
122 "_": "\\_",
123 "/": "\\/",
124 ">": "\\>",
125 "<": "\\<",
126 }
128 string = string.translate(str.maketrans(escape_dictionary))
130 # string.translate() only supports single-character replacements, so we need to
131 # handle the longer replacements separately.
132 longer_escape_dictionary = {
133 "* ": "#sym.ast.basic ",
134 "*": "#sym.ast.basic#h(0pt, weak: true) ",
135 }
136 for key, value in longer_escape_dictionary.items():
137 string = string.replace(key, value)
139 # Replace the dummy names with the full Typst commands
140 for dummy_name, full_command in typst_command_mapping.items():
141 string = string.replace(dummy_name, full_command)
143 return string
146# Create a Markdown instance
147md = markdown.core.Markdown(extensions=["admonition"])
148md.output_formats["typst"] = to_typst_string # pyright: ignore[reportArgumentType]
149md.set_output_format("typst") # pyright: ignore[reportArgumentType]
150md.parser.blockprocessors.deregister("hashheader")
151md.parser.blockprocessors.deregister("setextheader")
152md.parser.blockprocessors.deregister("olist")
153md.parser.blockprocessors.deregister("ulist")
154md.parser.blockprocessors.deregister("quote")
155md.stripTopLevelTags = False
158def markdown_to_typst(markdown_string: str) -> str:
159 """Convert Markdown string to Typst markup.
161 Why:
162 Users write content in Markdown for readability. Typst compilation
163 requires Typst markup. Lines are processed independently to prevent
164 emphasis markers on adjacent lines from interacting in the Markdown
165 parser (single-newline-separated lines form one paragraph in Markdown,
166 causing cross-line marker interference). Admonition blocks are kept
167 together since they span multiple lines by design.
169 Args:
170 markdown_string: Markdown content.
172 Returns:
173 Typst-formatted string.
174 """
175 lines = markdown_string.split("\n")
176 result_parts: list[str] = []
177 i = 0
178 while i < len(lines):
179 if lines[i].startswith("!!!"):
180 # Admonition block: collect the !!! line + all following indented lines
181 block = [lines[i]]
182 i += 1
183 while i < len(lines) and lines[i].startswith(" "):
184 block.append(lines[i])
185 i += 1
186 md.reset()
187 result_parts.append(md.convert("\n".join(block)))
188 else:
189 md.reset()
190 result_parts.append(md.convert(lines[i]))
191 i += 1
192 return "\n".join(result_parts)
195def markdown_to_html(markdown_string: str) -> str:
196 """Convert Markdown string to HTML using python-markdown library.
198 Args:
199 markdown_string: Markdown content.
201 Returns:
202 HTML-formatted string.
203 """
204 return markdown.markdown(markdown_string)