texsmith 0.0.2.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. texsmith/__init__.py +107 -0
  2. texsmith/_alias.py +59 -0
  3. texsmith/adapters/__init__.py +6 -0
  4. texsmith/adapters/docker.py +258 -0
  5. texsmith/adapters/handlers/__init__.py +3 -0
  6. texsmith/adapters/handlers/_assets.py +363 -0
  7. texsmith/adapters/handlers/_helpers.py +62 -0
  8. texsmith/adapters/handlers/_mermaid.py +122 -0
  9. texsmith/adapters/handlers/admonitions.py +267 -0
  10. texsmith/adapters/handlers/basic.py +228 -0
  11. texsmith/adapters/handlers/blocks.py +851 -0
  12. texsmith/adapters/handlers/code.py +309 -0
  13. texsmith/adapters/handlers/inline.py +937 -0
  14. texsmith/adapters/handlers/links.py +239 -0
  15. texsmith/adapters/handlers/media.py +380 -0
  16. texsmith/adapters/latex/__init__.py +10 -0
  17. texsmith/adapters/latex/engines/__init__.py +726 -0
  18. texsmith/adapters/latex/engines/latex/__init__.py +26 -0
  19. texsmith/adapters/latex/engines/latex/log.py +720 -0
  20. texsmith/adapters/latex/engines/latex/runner.py +28 -0
  21. texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
  22. texsmith/adapters/latex/formatter.py +297 -0
  23. texsmith/adapters/latex/latexmk.py +148 -0
  24. texsmith/adapters/latex/partials/acronym.tex +1 -0
  25. texsmith/adapters/latex/partials/add.tex +1 -0
  26. texsmith/adapters/latex/partials/addition.tex +1 -0
  27. texsmith/adapters/latex/partials/blockquote.tex +3 -0
  28. texsmith/adapters/latex/partials/callout.tex +3 -0
  29. texsmith/adapters/latex/partials/choices.tex +5 -0
  30. texsmith/adapters/latex/partials/citation.tex +1 -0
  31. texsmith/adapters/latex/partials/codeblock.tex +7 -0
  32. texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
  33. texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
  34. texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
  35. texsmith/adapters/latex/partials/codeinline.tex +1 -0
  36. texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
  37. texsmith/adapters/latex/partials/comment.tex +1 -0
  38. texsmith/adapters/latex/partials/del.tex +1 -0
  39. texsmith/adapters/latex/partials/deletion.tex +1 -0
  40. texsmith/adapters/latex/partials/description_list.tex +5 -0
  41. texsmith/adapters/latex/partials/enquote.tex +1 -0
  42. texsmith/adapters/latex/partials/epigraph.tex +1 -0
  43. texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
  44. texsmith/adapters/latex/partials/figure.tex +33 -0
  45. texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
  46. texsmith/adapters/latex/partials/footnote.tex +3 -0
  47. texsmith/adapters/latex/partials/glossary.tex +1 -0
  48. texsmith/adapters/latex/partials/heading.tex +14 -0
  49. texsmith/adapters/latex/partials/highlight.tex +25 -0
  50. texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
  51. texsmith/adapters/latex/partials/href.tex +1 -0
  52. texsmith/adapters/latex/partials/icon.tex +1 -0
  53. texsmith/adapters/latex/partials/include.tex +1 -0
  54. texsmith/adapters/latex/partials/index.tex +1 -0
  55. texsmith/adapters/latex/partials/italic.tex +1 -0
  56. texsmith/adapters/latex/partials/keystroke.tex +46 -0
  57. texsmith/adapters/latex/partials/label.tex +1 -0
  58. texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
  59. texsmith/adapters/latex/partials/list_glossary.tex +8 -0
  60. texsmith/adapters/latex/partials/multicolumn.tex +3 -0
  61. texsmith/adapters/latex/partials/ordered_list.tex +5 -0
  62. texsmith/adapters/latex/partials/pagestyle.tex +1 -0
  63. texsmith/adapters/latex/partials/ref.tex +1 -0
  64. texsmith/adapters/latex/partials/regex.tex +1 -0
  65. texsmith/adapters/latex/partials/smallcaps.tex +1 -0
  66. texsmith/adapters/latex/partials/strikethrough.tex +1 -0
  67. texsmith/adapters/latex/partials/strong.tex +1 -0
  68. texsmith/adapters/latex/partials/subscript.tex +1 -0
  69. texsmith/adapters/latex/partials/substitution.tex +1 -0
  70. texsmith/adapters/latex/partials/superscript.tex +1 -0
  71. texsmith/adapters/latex/partials/tabbed.tex +3 -0
  72. texsmith/adapters/latex/partials/table.tex +48 -0
  73. texsmith/adapters/latex/partials/underline.tex +1 -0
  74. texsmith/adapters/latex/partials/unordered_list.tex +5 -0
  75. texsmith/adapters/latex/partials/url.tex +1 -0
  76. texsmith/adapters/latex/pygments.py +98 -0
  77. texsmith/adapters/latex/pyxindy.py +64 -0
  78. texsmith/adapters/latex/renderer.py +220 -0
  79. texsmith/adapters/latex/tectonic.py +366 -0
  80. texsmith/adapters/latex/utils.py +86 -0
  81. texsmith/adapters/markdown/__init__.py +342 -0
  82. texsmith/adapters/plugins/__init__.py +8 -0
  83. texsmith/adapters/plugins/material.py +174 -0
  84. texsmith/adapters/plugins/snippet.py +1734 -0
  85. texsmith/adapters/transformers/__init__.py +122 -0
  86. texsmith/adapters/transformers/base.py +140 -0
  87. texsmith/adapters/transformers/strategies.py +1456 -0
  88. texsmith/adapters/transformers/utils.py +59 -0
  89. texsmith/api/__init__.py +80 -0
  90. texsmith/api/_utils.py +56 -0
  91. texsmith/api/document.py +645 -0
  92. texsmith/api/pipeline.py +229 -0
  93. texsmith/api/service.py +648 -0
  94. texsmith/api/templates.py +287 -0
  95. texsmith/core/__init__.py +6 -0
  96. texsmith/core/bibliography/__init__.py +57 -0
  97. texsmith/core/bibliography/collection.py +354 -0
  98. texsmith/core/bibliography/doi.py +194 -0
  99. texsmith/core/bibliography/issues.py +15 -0
  100. texsmith/core/bibliography/parsing.py +45 -0
  101. texsmith/core/callouts.py +99 -0
  102. texsmith/core/config.py +191 -0
  103. texsmith/core/context.py +242 -0
  104. texsmith/core/conversion/__init__.py +103 -0
  105. texsmith/core/conversion/core.py +780 -0
  106. texsmith/core/conversion/debug.py +87 -0
  107. texsmith/core/conversion/inputs.py +474 -0
  108. texsmith/core/conversion/renderer.py +578 -0
  109. texsmith/core/conversion/templates.py +646 -0
  110. texsmith/core/conversion_contexts.py +95 -0
  111. texsmith/core/diagnostics.py +118 -0
  112. texsmith/core/exceptions.py +41 -0
  113. texsmith/core/fonts/__init__.py +3 -0
  114. texsmith/core/fragments/__init__.py +716 -0
  115. texsmith/core/fragments/base.py +117 -0
  116. texsmith/core/metadata.py +280 -0
  117. texsmith/core/mustache.py +86 -0
  118. texsmith/core/partials.py +20 -0
  119. texsmith/core/rules.py +394 -0
  120. texsmith/core/templates/__init__.py +58 -0
  121. texsmith/core/templates/base.py +343 -0
  122. texsmith/core/templates/builtins.py +68 -0
  123. texsmith/core/templates/context_usage.py +137 -0
  124. texsmith/core/templates/loader.py +303 -0
  125. texsmith/core/templates/manifest.py +861 -0
  126. texsmith/core/templates/runtime.py +347 -0
  127. texsmith/core/templates/text.py +14 -0
  128. texsmith/core/templates/wrapper.py +340 -0
  129. texsmith/core/user_dir.py +179 -0
  130. texsmith/devtools.py +28 -0
  131. texsmith/extensions/__init__.py +162 -0
  132. texsmith/extensions/index/__init__.py +21 -0
  133. texsmith/extensions/index/markdown.py +94 -0
  134. texsmith/extensions/index/mkdocs_plugin.py +136 -0
  135. texsmith/extensions/index/registry.py +57 -0
  136. texsmith/extensions/index/renderer.py +183 -0
  137. texsmith/extensions/index/templates/index.tex +1 -0
  138. texsmith/extensions/latex_raw.py +115 -0
  139. texsmith/extensions/latex_text.py +117 -0
  140. texsmith/extensions/mermaid.py +267 -0
  141. texsmith/extensions/missing_footnotes.py +125 -0
  142. texsmith/extensions/multi_citations.py +54 -0
  143. texsmith/extensions/progressbar/__init__.py +9 -0
  144. texsmith/extensions/progressbar/markdown.py +212 -0
  145. texsmith/extensions/progressbar/renderer.py +117 -0
  146. texsmith/extensions/smallcaps.py +48 -0
  147. texsmith/extensions/texlogos/__init__.py +10 -0
  148. texsmith/extensions/texlogos/markdown.py +236 -0
  149. texsmith/extensions/texlogos/renderer.py +81 -0
  150. texsmith/extensions/texlogos/specs.py +66 -0
  151. texsmith/fonts/__init__.py +57 -0
  152. texsmith/fonts/cache.py +46 -0
  153. texsmith/fonts/constants.py +60 -0
  154. texsmith/fonts/coverage.py +275 -0
  155. texsmith/fonts/downloader.py +103 -0
  156. texsmith/fonts/fallback.py +438 -0
  157. texsmith/fonts/html_scripts.py +168 -0
  158. texsmith/fonts/logging.py +127 -0
  159. texsmith/fonts/pipeline.py +338 -0
  160. texsmith/fonts/scripts.py +493 -0
  161. texsmith/fonts/ucharclasses.py +164 -0
  162. texsmith/fragments/__init__.py +11 -0
  163. texsmith/fragments/bibliography/__init__.py +65 -0
  164. texsmith/fragments/bibliography/fragment.toml +3 -0
  165. texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
  166. texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
  167. texsmith/fragments/callouts/__init__.py +88 -0
  168. texsmith/fragments/callouts/fragment.toml +3 -0
  169. texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
  170. texsmith/fragments/code/__init__.py +82 -0
  171. texsmith/fragments/code/fragment.toml +3 -0
  172. texsmith/fragments/code/ts-code.jinja.sty +140 -0
  173. texsmith/fragments/extra/__init__.py +180 -0
  174. texsmith/fragments/extra/fragment.toml +3 -0
  175. texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
  176. texsmith/fragments/fonts/__init__.py +880 -0
  177. texsmith/fragments/fonts/fragment.toml +3 -0
  178. texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
  179. texsmith/fragments/frame/__init__.py +170 -0
  180. texsmith/fragments/frame/fragment.toml +3 -0
  181. texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
  182. texsmith/fragments/geometry/__init__.py +169 -0
  183. texsmith/fragments/geometry/fragment.toml +3 -0
  184. texsmith/fragments/geometry/paper.py +526 -0
  185. texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
  186. texsmith/fragments/glossary/__init__.py +67 -0
  187. texsmith/fragments/glossary/fragment.toml +3 -0
  188. texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
  189. texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
  190. texsmith/fragments/index/__init__.py +66 -0
  191. texsmith/fragments/index/fragment.toml +3 -0
  192. texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
  193. texsmith/fragments/index/ts-index.jinja.sty +12 -0
  194. texsmith/fragments/keystrokes/__init__.py +69 -0
  195. texsmith/fragments/keystrokes/fragment.toml +3 -0
  196. texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
  197. texsmith/fragments/todolist/__init__.py +71 -0
  198. texsmith/fragments/todolist/fragment.toml +3 -0
  199. texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
  200. texsmith/fragments/typesetting/__init__.py +214 -0
  201. texsmith/fragments/typesetting/fragment.toml +3 -0
  202. texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
  203. texsmith/index.py +26 -0
  204. texsmith/plugins/__init__.py +14 -0
  205. texsmith/progressbar.py +8 -0
  206. texsmith/quotes.py +40 -0
  207. texsmith/smart_dashes.py +66 -0
  208. texsmith/templates/__init__.py +3 -0
  209. texsmith/templates/article/README.md +33 -0
  210. texsmith/templates/article/__init__.py +281 -0
  211. texsmith/templates/article/template/manifest.toml +125 -0
  212. texsmith/templates/article/template/mermaid-config.json +18 -0
  213. texsmith/templates/article/template/template.tex +74 -0
  214. texsmith/templates/book/README.md +26 -0
  215. texsmith/templates/book/__init__.py +75 -0
  216. texsmith/templates/book/overrides/codeblock.tex +7 -0
  217. texsmith/templates/book/overrides/codeinline.tex +1 -0
  218. texsmith/templates/book/template/fixtoc.sty +81 -0
  219. texsmith/templates/book/template/manifest.toml +198 -0
  220. texsmith/templates/book/template/template.tex +314 -0
  221. texsmith/templates/common/__init__.py +1 -0
  222. texsmith/templates/common/latexmkrc +46 -0
  223. texsmith/templates/letter/README.md +59 -0
  224. texsmith/templates/letter/__init__.py +495 -0
  225. texsmith/templates/letter/demo.md +31 -0
  226. texsmith/templates/letter/fonts/modernline bold.otf +0 -0
  227. texsmith/templates/letter/fonts/modernline.otf +0 -0
  228. texsmith/templates/letter/manifest.toml +197 -0
  229. texsmith/templates/letter/template/callouts.jinja.sty +290 -0
  230. texsmith/templates/letter/template/template.tex +123 -0
  231. texsmith/templates/snippet/README.md +21 -0
  232. texsmith/templates/snippet/__init__.py +80 -0
  233. texsmith/templates/snippet/template/manifest.toml +66 -0
  234. texsmith/templates/snippet/template/template.tex +47 -0
  235. texsmith/texlogos.py +15 -0
  236. texsmith/ui/__init__.py +6 -0
  237. texsmith/ui/cli/__init__.py +22 -0
  238. texsmith/ui/cli/_options.py +338 -0
  239. texsmith/ui/cli/app.py +65 -0
  240. texsmith/ui/cli/bibliography.py +300 -0
  241. texsmith/ui/cli/commands/__init__.py +14 -0
  242. texsmith/ui/cli/commands/render.py +1128 -0
  243. texsmith/ui/cli/commands/templates.py +397 -0
  244. texsmith/ui/cli/diagnostics.py +36 -0
  245. texsmith/ui/cli/presenter.py +663 -0
  246. texsmith/ui/cli/state.py +263 -0
  247. texsmith/ui/cli/utils.py +235 -0
  248. texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
  249. texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
  250. texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
  251. texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
  252. texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,183 @@
1
+ """TeXSmith renderer hooks converting hashtag spans into LaTeX index entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import re
7
+
8
+ from bs4 import NavigableString, Tag
9
+
10
+ from texsmith.adapters.handlers._helpers import (
11
+ coerce_attribute,
12
+ gather_classes,
13
+ mark_processed,
14
+ )
15
+ from texsmith.adapters.latex.utils import escape_latex_chars
16
+ from texsmith.core.context import RenderContext
17
+ from texsmith.core.rules import RenderPhase, renders
18
+
19
+ from .registry import get_registry
20
+
21
+
22
+ TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
23
+ INDEX_TEMPLATE = TEMPLATE_DIR / "index.tex"
24
+
25
+
26
+ def _collect_tags(element: Tag) -> list[str]:
27
+ tags: list[str] = []
28
+ index = 0
29
+ while True:
30
+ key = "data-tag" if index == 0 else f"data-tag{index}"
31
+ value = coerce_attribute(element.get(key))
32
+ if not value:
33
+ break
34
+ cleaned = str(value).strip()
35
+ if cleaned:
36
+ tags.append(cleaned)
37
+ index += 1
38
+ return tags
39
+
40
+
41
+ def _strip_formatting(text: str) -> str:
42
+ """Remove markdown formatting from text."""
43
+ text = re.sub(r"\*\*\*(.*?)\*\*\*|___(.*?)___", r"\1\2", text)
44
+ text = re.sub(r"\*\*(.*?)\*\*|__(.*?)__", r"\1\2", text)
45
+ text = re.sub(r"\*(.*?)\*|_(.*?)_", r"\1\2", text)
46
+ return text
47
+
48
+
49
+ def _format_tag(text: str, legacy: bool) -> str:
50
+ """Convert markdown formatting to LaTeX."""
51
+ parts = re.split(r"(\*\*\*.*?\*\*\*)", text)
52
+ processed = []
53
+ for part in parts:
54
+ if part.startswith("***") and part.endswith("***"):
55
+ content = part[3:-3]
56
+ processed.append(
57
+ f"\\textbf{{\\textit{{{escape_latex_chars(content, legacy_accents=legacy)}}}}}"
58
+ )
59
+ else:
60
+ subparts = re.split(r"(\*\*.*?\*\*)", part)
61
+ for subpart in subparts:
62
+ if subpart.startswith("**") and subpart.endswith("**"):
63
+ content = subpart[2:-2]
64
+ processed.append(
65
+ f"\\textbf{{{escape_latex_chars(content, legacy_accents=legacy)}}}"
66
+ )
67
+ else:
68
+ subsubparts = re.split(r"(\*.*?\*)", subpart)
69
+ for subsubpart in subsubparts:
70
+ if subsubpart.startswith("*") and subsubpart.endswith("*"):
71
+ content = subsubpart[1:-1]
72
+ processed.append(
73
+ f"\\textit{{{escape_latex_chars(content, legacy_accents=legacy)}}}"
74
+ )
75
+ else:
76
+ processed.append(escape_latex_chars(subsubpart, legacy_accents=legacy))
77
+ return "".join(processed)
78
+
79
+
80
+ def _normalise_style(value: str | None) -> str:
81
+ if not value:
82
+ return ""
83
+ cleaned = str(value).strip().lower()
84
+ if cleaned == "ib":
85
+ cleaned = "bi"
86
+ if cleaned in {"b", "i", "bi"}:
87
+ return cleaned
88
+ return ""
89
+
90
+
91
+ def _apply_style(fragment: str, style: str) -> str:
92
+ if style == "b":
93
+ return f"\\textbf{{{fragment}}}"
94
+ if style == "i":
95
+ return f"\\textit{{{fragment}}}"
96
+ if style == "bi":
97
+ return f"\\textbf{{\\textit{{{fragment}}}}}"
98
+ return fragment
99
+
100
+
101
+ _REGISTER_ATTR = "_texsmith_index_registered"
102
+
103
+
104
+ @renders(
105
+ "span",
106
+ phase=RenderPhase.INLINE,
107
+ priority=44,
108
+ name="texsmith_index",
109
+ nestable=False,
110
+ auto_mark=False,
111
+ )
112
+ def render_index(element: Tag, context: RenderContext) -> None:
113
+ """Convert ``<span class="ts-hashtag">`` elements into LaTeX index commands."""
114
+ classes = gather_classes(element.get("class"))
115
+ if "ts-hashtag" not in classes and "ts-index" not in classes:
116
+ return
117
+
118
+ tags = _collect_tags(element)
119
+ if not tags:
120
+ return
121
+
122
+ registry_name = coerce_attribute(element.get("data-registry"))
123
+ style_value = _normalise_style(coerce_attribute(element.get("data-style")))
124
+ legacy = getattr(context.config, "legacy_latex_accents", False)
125
+
126
+ formatted_tags = [_format_tag(tag, legacy=legacy) for tag in tags]
127
+ if style_value and formatted_tags:
128
+ formatted_tags[-1] = _apply_style(formatted_tags[-1], style_value)
129
+
130
+ sort_tags = [_strip_formatting(tag) for tag in tags]
131
+
132
+ entries = []
133
+ for f_tag, s_tag in zip(formatted_tags, sort_tags, strict=True):
134
+ escaped_s_tag = escape_latex_chars(s_tag, legacy_accents=legacy)
135
+ if f_tag == escaped_s_tag:
136
+ entries.append(f_tag)
137
+ else:
138
+ entries.append(f"{escaped_s_tag}@{f_tag}")
139
+
140
+ entry_str = "!".join(entries)
141
+
142
+ visible_text = element.get_text(strip=False) or ""
143
+
144
+ latex = context.formatter.index(
145
+ visible_text,
146
+ entry=entry_str,
147
+ style="",
148
+ styled_entry=None,
149
+ registry=registry_name,
150
+ )
151
+
152
+ registry = get_registry()
153
+ registry.add(tuple(sort_tags))
154
+
155
+ state = context.state
156
+ state.has_index_entries = True
157
+ index_entries = getattr(state, "index_entries", None)
158
+ if isinstance(index_entries, list):
159
+ index_entries.append(tuple(sort_tags))
160
+
161
+ node = mark_processed(NavigableString(latex))
162
+ context.mark_processed(element)
163
+ context.suppress_children(element)
164
+ element.replace_with(node)
165
+
166
+
167
+ def register(renderer: object) -> None:
168
+ """Register the index renderer on the provided TeXSmith renderer."""
169
+ register_callable = getattr(renderer, "register", None)
170
+ if not callable(register_callable):
171
+ raise TypeError("Renderer does not expose a 'register' method.")
172
+ if getattr(renderer, _REGISTER_ATTR, False):
173
+ return
174
+ register_callable(render_index)
175
+
176
+ formatter = getattr(renderer, "formatter", None)
177
+ override_template = getattr(formatter, "override_template", None)
178
+ if callable(override_template):
179
+ override_template("index", INDEX_TEMPLATE)
180
+ setattr(renderer, _REGISTER_ATTR, True)
181
+
182
+
183
+ __all__ = ["register", "render_index"]
@@ -0,0 +1 @@
1
+ \VAR{text}\index\BLOCK{ if registry }[\VAR{registry}]\BLOCK{ endif }{\BLOCK{ if styled_entry }\VAR{styled_entry}\BLOCK{ elif style == 'b' }\textbf{\VAR{entry}}\BLOCK{ elif style == 'i' }\textit{\VAR{entry}}\BLOCK{ elif style in ('bi', 'ib') }\textbf{\textit{\VAR{entry}}}\BLOCK{ else }\VAR{entry}\BLOCK{ endif }}
@@ -0,0 +1,115 @@
1
+ """Markdown extension adding support for raw LaTeX fence blocks and inline snippets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html import escape
6
+ import re
7
+ from xml.etree import ElementTree
8
+
9
+ from markdown import Markdown
10
+ from markdown.extensions import Extension
11
+ from markdown.inlinepatterns import InlineProcessor
12
+ from markdown.preprocessors import Preprocessor
13
+
14
+
15
+ class _LatexRawPreprocessor(Preprocessor):
16
+ """Transform custom `/// latex` fences into hidden HTML nodes."""
17
+
18
+ _START_RE = re.compile(r"^\s*///\s+latex\s*$")
19
+ _END_RE = re.compile(r"^\s*///\s*$")
20
+ _FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})(.*)$")
21
+
22
+ def run(self, lines: list[str]) -> list[str]:
23
+ result: list[str] = []
24
+ index = 0
25
+ total = len(lines)
26
+ in_fence = False
27
+ fence_char: str | None = None
28
+ fence_len = 0
29
+
30
+ while index < total:
31
+ line = lines[index]
32
+
33
+ fence_match = self._FENCE_RE.match(line)
34
+ if fence_match:
35
+ fence_token = fence_match.group(1)
36
+ token_char = fence_token[0]
37
+ token_len = len(fence_token)
38
+ if not in_fence:
39
+ in_fence = True
40
+ fence_char = token_char
41
+ fence_len = token_len
42
+ else:
43
+ if token_char == fence_char and token_len >= fence_len:
44
+ in_fence = False
45
+ fence_char = None
46
+ fence_len = 0
47
+ result.append(line)
48
+ index += 1
49
+ continue
50
+
51
+ if in_fence:
52
+ result.append(line)
53
+ index += 1
54
+ continue
55
+
56
+ if not self._START_RE.match(line):
57
+ result.append(line)
58
+ index += 1
59
+ continue
60
+
61
+ start_index = index
62
+ index += 1
63
+ contents: list[str] = []
64
+
65
+ while index < total and not self._END_RE.match(lines[index]):
66
+ contents.append(lines[index])
67
+ index += 1
68
+
69
+ if index >= total:
70
+ # No closing fence; fall back to raw lines
71
+ result.extend(lines[start_index:])
72
+ break
73
+
74
+ escaped = escape("\n".join(contents), quote=False)
75
+ result.append(f'<p class="latex-raw" style="display:none;">{escaped}</p>')
76
+ index += 1 # Skip closing fence
77
+
78
+ return result
79
+
80
+
81
+ class _LatexInlineProcessor(InlineProcessor):
82
+ """Inline handler for ``{latex}[payload]`` markers."""
83
+
84
+ def handleMatch( # noqa: N802 - Markdown inline API requires camelCase
85
+ self,
86
+ match: re.Match[str],
87
+ data: str,
88
+ ) -> tuple[ElementTree.Element | None, int, int]: # type: ignore[override]
89
+ del data
90
+ payload = match.group("payload")
91
+ if payload is None:
92
+ return None, match.start(0), match.end(0)
93
+
94
+ element = ElementTree.Element("span")
95
+ element.set("class", "latex-raw")
96
+ element.set("style", "display:none;")
97
+ element.text = escape(payload, quote=False)
98
+ return element, match.start(0), match.end(0)
99
+
100
+
101
+ class LatexRawExtension(Extension):
102
+ """Register the raw LaTeX block preprocessor."""
103
+
104
+ def extendMarkdown(self, md: Markdown) -> None: # type: ignore[override] # noqa: N802
105
+ md.preprocessors.register(_LatexRawPreprocessor(md), "texsmith_latex_raw", priority=27)
106
+ pattern = r"\{latex\}\[(?P<payload>[^\]]+)\]"
107
+ processor = _LatexInlineProcessor(pattern, md)
108
+ md.inlinePatterns.register(processor, "texsmith_latex_inline", 181)
109
+
110
+
111
+ def makeExtension(**_: object) -> LatexRawExtension: # pragma: no cover - API hook # noqa: N802
112
+ return LatexRawExtension()
113
+
114
+
115
+ __all__ = ["LatexRawExtension", "makeExtension"]
@@ -0,0 +1,117 @@
1
+ """Markdown extension to stylize the literal ``LaTeX`` token inside paragraphs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import xml.etree.ElementTree as ElementTree
6
+
7
+ from markdown import Markdown
8
+ from markdown.extensions import Extension
9
+ from markdown.treeprocessors import Treeprocessor
10
+
11
+
12
+ _TARGET = "LaTeX"
13
+
14
+
15
+ class _LatexTextTreeprocessor(Treeprocessor):
16
+ """Replace plain ``LaTeX`` occurrences with a styled HTML fragment."""
17
+
18
+ def run(self, root: ElementTree.Element) -> None: # type: ignore[override]
19
+ for paragraph in root.iter("p"):
20
+ self._process_element(paragraph)
21
+
22
+ # -- internals -----------------------------------------------------
23
+ def _process_element(self, element: ElementTree.Element) -> None:
24
+ self._replace_text_node(element)
25
+ for child in list(element):
26
+ if self._is_code_element(child):
27
+ if child.tail:
28
+ self._replace_tail(element, child)
29
+ continue
30
+
31
+ self._process_element(child)
32
+ if child.tail:
33
+ self._replace_tail(element, child)
34
+
35
+ def _replace_text_node(self, element: ElementTree.Element) -> None:
36
+ text = element.text
37
+ if not text or _TARGET not in text:
38
+ return
39
+
40
+ parts = text.split(_TARGET)
41
+ element.text = parts[0]
42
+ for insert_pos, remainder in enumerate(parts[1:]):
43
+ fragment = self._build_fragment()
44
+ element.insert(insert_pos, fragment)
45
+ if remainder:
46
+ fragment.tail = remainder
47
+
48
+ def _replace_tail(self, parent: ElementTree.Element, child: ElementTree.Element) -> None:
49
+ tail = child.tail
50
+ if not tail or _TARGET not in tail:
51
+ return
52
+
53
+ parts = tail.split(_TARGET)
54
+ child.tail = parts[0]
55
+ base_index = list(parent).index(child) + 1
56
+ for offset, remainder in enumerate(parts[1:]):
57
+ fragment = self._build_fragment()
58
+ parent.insert(base_index + offset, fragment)
59
+ if remainder:
60
+ fragment.tail = remainder
61
+
62
+ def _is_code_element(self, element: ElementTree.Element) -> bool:
63
+ tag = element.tag
64
+ if isinstance(tag, str):
65
+ tag = tag.lower()
66
+ return tag in {"code", "pre"}
67
+
68
+ def _build_fragment(self) -> ElementTree.Element:
69
+ outer = ElementTree.Element(
70
+ "span",
71
+ {
72
+ "class": "latex-text",
73
+ "style": "font-family: 'Times New Roman', serif;",
74
+ },
75
+ )
76
+ outer.text = "L"
77
+
78
+ lowered_a = ElementTree.SubElement(
79
+ outer,
80
+ "span",
81
+ {"style": ("position: relative; top: 0.2em; left: -0.05em; font-size: 0.9em;")},
82
+ )
83
+ lowered_a.text = "a"
84
+ lowered_a.tail = "T"
85
+
86
+ small_caps_e = ElementTree.SubElement(
87
+ outer,
88
+ "span",
89
+ {
90
+ "style": "font-variant: small-caps;",
91
+ },
92
+ )
93
+ small_caps_e.text = "e"
94
+
95
+ ElementTree.SubElement(
96
+ outer,
97
+ "span",
98
+ {
99
+ "style": "font-variant: small-caps; letter-spacing: -0.05em;",
100
+ },
101
+ ).text = "X"
102
+
103
+ return outer
104
+
105
+
106
+ class LatexTextExtension(Extension):
107
+ """Register the ``LaTeX`` paragraph replacement processor."""
108
+
109
+ def extendMarkdown(self, md: Markdown) -> None: # type: ignore[override] # noqa: N802
110
+ md.treeprocessors.register(_LatexTextTreeprocessor(md), "texsmith_latex_text", priority=15)
111
+
112
+
113
+ def makeExtension(**_: object) -> LatexTextExtension: # pragma: no cover - API hook # noqa: N802
114
+ return LatexTextExtension()
115
+
116
+
117
+ __all__ = ["LatexTextExtension", "makeExtension"]
@@ -0,0 +1,267 @@
1
+ """Markdown extension that inlines Mermaid diagrams referenced via images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ from pathlib import Path
7
+ import xml.etree.ElementTree as ElementTree
8
+
9
+ from markdown import Markdown
10
+ from markdown.extensions import Extension
11
+ from markdown.treeprocessors import Treeprocessor
12
+
13
+ from ..adapters.handlers._mermaid import (
14
+ MERMAID_FILE_SUFFIXES,
15
+ extract_mermaid_live_diagram,
16
+ looks_like_mermaid,
17
+ )
18
+ from ..core.exceptions import InvalidNodeError
19
+
20
+
21
+ def _collapse_relative_path(src: str) -> str:
22
+ """Return a relative path with ``..`` segments normalised away."""
23
+ if not src or src.startswith(("/", "\\")):
24
+ return src
25
+
26
+ segments: list[str] = []
27
+ for segment in src.split("/"):
28
+ if not segment or segment == ".":
29
+ continue
30
+ if segment == "..":
31
+ if segments:
32
+ segments.pop()
33
+ continue
34
+ segments.append(segment)
35
+ return "/".join(segments)
36
+
37
+
38
+ class _MermaidImageTreeprocessor(Treeprocessor):
39
+ """Replace standalone Mermaid image references with code blocks."""
40
+
41
+ def __init__(
42
+ self,
43
+ md: Markdown,
44
+ *,
45
+ extra_base_paths: Sequence[Path] | None = None,
46
+ ) -> None:
47
+ super().__init__(md)
48
+ self._extra_base_paths = [path for path in (extra_base_paths or []) if path]
49
+
50
+ def run(self, root: ElementTree.Element) -> ElementTree.Element: # type: ignore[override]
51
+ base_path_value = getattr(self.md, "texsmith_mermaid_base_path", None)
52
+ base_path: Path | None = None
53
+ if base_path_value:
54
+ try:
55
+ base_path = Path(base_path_value).resolve()
56
+ except OSError:
57
+ base_path = Path(base_path_value)
58
+
59
+ parent_map: dict[ElementTree.Element, ElementTree.Element] = {}
60
+ for parent in root.iter():
61
+ for child in list(parent):
62
+ parent_map[child] = parent
63
+
64
+ for paragraph in list(root.iter("p")):
65
+ image, link_href = self._extract_image(paragraph)
66
+ if image is None:
67
+ continue
68
+
69
+ diagram_payload = self._load_diagram(image, base_path)
70
+ if diagram_payload is None:
71
+ continue
72
+ diagram, source, trusted = diagram_payload
73
+
74
+ caption = (image.get("alt") or image.get("title") or "").strip()
75
+ body = self._apply_caption(diagram, caption)
76
+ if not trusted and not looks_like_mermaid(body):
77
+ continue
78
+
79
+ replacement = self._build_mermaid_block(body, link_href, source)
80
+ wrapper = self._wrap_with_link(replacement, link_href)
81
+ self._replace_paragraph(parent_map.get(paragraph), paragraph, wrapper)
82
+
83
+ return root
84
+
85
+ def _extract_image(
86
+ self, paragraph: ElementTree.Element
87
+ ) -> tuple[ElementTree.Element | None, str | None]:
88
+ if any((node.tail or "").strip() for node in paragraph):
89
+ return None, None
90
+ paragraph_text = (paragraph.text or "").strip()
91
+ if paragraph_text:
92
+ return None, None
93
+ if len(paragraph) != 1:
94
+ return None, None
95
+
96
+ node = paragraph[0]
97
+ if node.tag == "img":
98
+ if (node.tail or "").strip():
99
+ return None, None
100
+ return node, None
101
+
102
+ if node.tag == "a" and len(node) == 1 and node[0].tag == "img":
103
+ image = node[0]
104
+ if (node.text or "").strip() or (image.tail or "").strip():
105
+ return None, None
106
+ href = node.get("href")
107
+ return image, href
108
+
109
+ return None, None
110
+
111
+ def _load_diagram(
112
+ self,
113
+ image: ElementTree.Element,
114
+ base_path: Path | None,
115
+ ) -> tuple[str, str, bool] | None:
116
+ raw_src = image.get("src")
117
+ if not raw_src:
118
+ return None
119
+
120
+ cleaned = raw_src.replace("\n", "").replace("\r", "").strip()
121
+ if not cleaned:
122
+ return None
123
+
124
+ source_hint = cleaned
125
+ simplified = cleaned.split("?", 1)[0].split("#", 1)[0].lower()
126
+ if simplified.endswith(MERMAID_FILE_SUFFIXES):
127
+ if cleaned.startswith(("http://", "https://")):
128
+ return None
129
+ resolved = self._resolve_path(cleaned, base_path)
130
+ if resolved is None:
131
+ return None
132
+ try:
133
+ payload = resolved.read_text(encoding="utf-8")
134
+ except OSError:
135
+ return None
136
+ return payload, str(resolved), True
137
+
138
+ try:
139
+ diagram = extract_mermaid_live_diagram(cleaned)
140
+ except InvalidNodeError:
141
+ return None
142
+ if diagram is not None:
143
+ return diagram, source_hint, False
144
+
145
+ return None
146
+
147
+ def _resolve_path(self, src: str, base_path: Path | None) -> Path | None:
148
+ normalised = _collapse_relative_path(src.replace("\\", "/"))
149
+ candidate = Path(normalised)
150
+ if candidate.is_absolute():
151
+ return candidate if candidate.exists() else None
152
+
153
+ search_roots: list[Path] = []
154
+ if base_path is not None:
155
+ search_roots.append(base_path)
156
+ for fallback in self._extra_base_paths:
157
+ if fallback not in search_roots:
158
+ search_roots.append(fallback)
159
+
160
+ for root in search_roots:
161
+ resolved = (root / normalised).resolve()
162
+ if resolved.exists():
163
+ return resolved
164
+ return None
165
+
166
+ def _apply_caption(self, diagram: str, caption: str) -> str:
167
+ if not caption:
168
+ return diagram
169
+
170
+ meaningful_line = None
171
+ for line in diagram.splitlines():
172
+ stripped = line.strip()
173
+ if not stripped:
174
+ continue
175
+ meaningful_line = stripped
176
+ break
177
+
178
+ if meaningful_line and meaningful_line.startswith("%%"):
179
+ return diagram
180
+
181
+ sanitized = caption.strip()
182
+ if not sanitized:
183
+ return diagram
184
+
185
+ body = diagram.lstrip("\ufeff")
186
+ return f"%% {sanitized}\n{body}"
187
+
188
+ def _build_mermaid_block(
189
+ self,
190
+ diagram: str,
191
+ link_href: str | None,
192
+ source_hint: str,
193
+ ) -> ElementTree.Element:
194
+ pre = ElementTree.Element("pre", {"class": "mermaid"})
195
+ if link_href:
196
+ pre.set("data-mermaid-link", link_href)
197
+ if source_hint:
198
+ pre.set("data-mermaid-source", source_hint)
199
+ pre.text = diagram
200
+ return pre
201
+
202
+ def _wrap_with_link(
203
+ self,
204
+ node: ElementTree.Element,
205
+ link_href: str | None,
206
+ ) -> ElementTree.Element:
207
+ if not link_href:
208
+ return node
209
+
210
+ anchor = ElementTree.Element("a", {"href": link_href})
211
+ anchor.append(node)
212
+ return anchor
213
+
214
+ def _replace_paragraph(
215
+ self,
216
+ parent: ElementTree.Element | None,
217
+ paragraph: ElementTree.Element,
218
+ replacement: ElementTree.Element,
219
+ ) -> None:
220
+ replacement.tail = paragraph.tail
221
+ if parent is None:
222
+ return
223
+ for index, child in enumerate(list(parent)):
224
+ if child is paragraph:
225
+ parent.insert(index, replacement)
226
+ parent.remove(paragraph)
227
+ return
228
+
229
+
230
+ class MermaidExtension(Extension):
231
+ """Register the Mermaid image treeprocessor."""
232
+
233
+ def __init__(self, **kwargs: object) -> None:
234
+ self.config = {
235
+ "base_paths": [(), "Additional directories searched for Mermaid files."],
236
+ }
237
+ super().__init__(**kwargs)
238
+
239
+ @staticmethod
240
+ def _coerce_paths(paths: Iterable[object]) -> list[Path]:
241
+ resolved: list[Path] = []
242
+ for entry in paths:
243
+ if entry is None:
244
+ continue
245
+ try:
246
+ candidate = Path(entry).expanduser()
247
+ except TypeError:
248
+ continue
249
+ try:
250
+ resolved.append(candidate.resolve())
251
+ except OSError:
252
+ resolved.append(candidate)
253
+ return resolved
254
+
255
+ def extendMarkdown(self, md: Markdown) -> None: # type: ignore[override] # noqa: N802
256
+ extra_paths = self._coerce_paths(self.getConfig("base_paths") or ())
257
+ processor = _MermaidImageTreeprocessor(md, extra_base_paths=extra_paths)
258
+ md.treeprocessors.register(processor, "texsmith_mermaid_images", priority=15)
259
+
260
+
261
+ def makeExtension( # noqa: N802 - Markdown expects this entry point name
262
+ **kwargs: object,
263
+ ) -> MermaidExtension: # pragma: no cover - entry point
264
+ return MermaidExtension(**kwargs)
265
+
266
+
267
+ __all__ = ["MermaidExtension", "makeExtension"]