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

1import itertools 

2import re 

3from xml.etree.ElementTree import Element 

4 

5import markdown 

6import markdown.core 

7 

8 

9def to_typst_string(elem: Element) -> str: 

10 """Recursively convert XML Element tree to Typst markup string. 

11 

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. 

16 

17 Args: 

18 elem: XML Element from Markdown parser. 

19 

20 Returns: 

21 Typst-formatted string. 

22 """ 

23 result = [] 

24 

25 # Handle the element's text content 

26 if elem.text: 

27 result.append(escape_typst_characters(elem.text)) 

28 

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}]" 

36 

37 case "em": 

38 # Italic: *text* -> #emph[text] 

39 inner = to_typst_string(child) 

40 child_content = f"#emph[{inner}]" 

41 

42 case "code": 

43 # Inline code: `text` -> `text` 

44 # Code content is already escaped by the parser 

45 child_content = f"`{child.text}`" 

46 

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}]' 

52 

53 case "div": 

54 child_content = ( 

55 "#summary[" 

56 + to_typst_string(child).strip("\n").replace("\n", " \\ ") 

57 + "]" 

58 ) 

59 

60 case _: 

61 if getattr(child, "attrib", {}).get("class") == "admonition-title": 

62 continue 

63 child_content = to_typst_string(child) 

64 

65 result.append(child_content) 

66 

67 # Handle tail text (text after the closing tag of child) 

68 if child.tail: 

69 result.append(escape_typst_characters(child.tail)) 

70 

71 return "".join(result) 

72 

73 

74typst_command_pattern = re.compile(r"#([A-Za-z][^\s()\[]*)(\([^)]*\))?(\[[^\]]*\])?") 

75math_pattern = re.compile(r"(\$\$.*?\$\$)") 

76 

77 

78def escape_typst_characters(string: str) -> str: 

79 """Escape Typst special characters while preserving Typst commands and math. 

80 

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. 

85 

86 Args: 

87 string: Text to escape. 

88 

89 Returns: 

90 Escaped string safe for Typst. 

91 """ 

92 if string == "\n": 

93 return string 

94 

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 ) 

110 

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 } 

127 

128 string = string.translate(str.maketrans(escape_dictionary)) 

129 

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) 

138 

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) 

142 

143 return string 

144 

145 

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 

156 

157 

158def markdown_to_typst(markdown_string: str) -> str: 

159 """Convert Markdown string to Typst markup. 

160 

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. 

168 

169 Args: 

170 markdown_string: Markdown content. 

171 

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) 

193 

194 

195def markdown_to_html(markdown_string: str) -> str: 

196 """Convert Markdown string to HTML using python-markdown library. 

197 

198 Args: 

199 markdown_string: Markdown content. 

200 

201 Returns: 

202 HTML-formatted string. 

203 """ 

204 return markdown.markdown(markdown_string)