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,239 @@
1
+ """Link handling utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ from hashlib import sha256
7
+ from pathlib import Path
8
+ from urllib.parse import urlparse
9
+
10
+ from bs4 import BeautifulSoup
11
+ from bs4.element import NavigableString, Tag
12
+ from requests.utils import requote_uri as requote_url
13
+
14
+ from texsmith.core.context import RenderContext
15
+ from texsmith.core.exceptions import AssetMissingError, InvalidNodeError
16
+ from texsmith.core.rules import RenderPhase, renders
17
+
18
+ from ..latex.utils import escape_latex_chars
19
+ from ..markdown import DEFAULT_MARKDOWN_EXTENSIONS, render_markdown
20
+ from ._helpers import coerce_attribute, mark_processed, resolve_asset_path
21
+
22
+
23
+ def _coerce_existing_path(candidate: Path) -> Path | None:
24
+ """Return a concrete file path for a directory or file candidate."""
25
+ if candidate.is_file():
26
+ return candidate.resolve()
27
+ if candidate.is_dir():
28
+ index_file = candidate / "index.md"
29
+ if index_file.exists():
30
+ return index_file.resolve()
31
+ html_index = candidate / "index.html"
32
+ if html_index.exists():
33
+ return html_index.resolve()
34
+ return None
35
+
36
+
37
+ def _iter_local_href_candidates(href: str) -> list[str]:
38
+ """Return potential filesystem paths for a MkDocs-style link."""
39
+ clean_href = href.split("#", 1)[0].split("?", 1)[0].strip()
40
+ if not clean_href:
41
+ return []
42
+
43
+ candidates: list[str] = []
44
+
45
+ def add_candidate(value: str | None) -> None:
46
+ if value and value not in candidates:
47
+ candidates.append(value)
48
+
49
+ add_candidate(clean_href)
50
+
51
+ trimmed = clean_href
52
+ while trimmed.startswith("./"):
53
+ trimmed = trimmed[2:]
54
+ if trimmed.startswith("/"):
55
+ trimmed = trimmed.lstrip("/")
56
+ add_candidate(trimmed)
57
+
58
+ if not trimmed:
59
+ add_candidate("index.md")
60
+ return candidates
61
+
62
+ if trimmed.endswith("/"):
63
+ stripped = trimmed.rstrip("/")
64
+ add_candidate(stripped)
65
+ add_candidate(f"{trimmed}index.md")
66
+ add_candidate(f"{trimmed}index.html")
67
+ if stripped:
68
+ add_candidate(f"{stripped}/index.md")
69
+ add_candidate(f"{stripped}/index.html")
70
+ add_candidate(f"{stripped}.md")
71
+ add_candidate(f"{stripped}.html")
72
+ else:
73
+ suffix = Path(trimmed).suffix
74
+ if not suffix:
75
+ add_candidate(f"{trimmed}.md")
76
+ add_candidate(f"{trimmed}.html")
77
+ add_candidate(f"{trimmed}/index.md")
78
+ add_candidate(f"{trimmed}/index.html")
79
+
80
+ return candidates
81
+
82
+
83
+ def _resolve_local_target(context: RenderContext, href: str) -> Path | None:
84
+ runtime_dir = context.runtime.get("source_dir")
85
+ if runtime_dir is not None:
86
+ for candidate in _iter_local_href_candidates(href):
87
+ candidate_path = _coerce_existing_path(Path(runtime_dir) / candidate)
88
+ if candidate_path is not None:
89
+ return candidate_path
90
+
91
+ document_path = context.runtime.get("document_path")
92
+ if document_path is not None:
93
+ for candidate in _iter_local_href_candidates(href):
94
+ resolved = resolve_asset_path(Path(document_path), candidate)
95
+ if resolved is not None:
96
+ coerced = _coerce_existing_path(resolved)
97
+ if coerced is not None:
98
+ return coerced
99
+
100
+ project_dir = getattr(context.config, "project_dir", None)
101
+ if project_dir:
102
+ for candidate in _iter_local_href_candidates(href):
103
+ candidate_path = _coerce_existing_path(Path(project_dir) / candidate)
104
+ if candidate_path is not None:
105
+ return candidate_path
106
+
107
+ return None
108
+
109
+
110
+ @renders(
111
+ "autoref",
112
+ phase=RenderPhase.INLINE,
113
+ priority=10,
114
+ name="autoref_tags",
115
+ nestable=False,
116
+ )
117
+ def render_autoref(element: Tag, context: RenderContext) -> None:
118
+ """Render <autoref> custom tags."""
119
+ identifier = coerce_attribute(element.get("identifier"))
120
+ if not identifier:
121
+ legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
122
+ literal = f"<{element.name}>"
123
+ latex = escape_latex_chars(literal, legacy_accents=legacy_latex_accents)
124
+ element.replace_with(mark_processed(NavigableString(latex)))
125
+ return
126
+ text = element.get_text(strip=False)
127
+
128
+ latex = context.formatter.ref(text, ref=identifier)
129
+ element.replace_with(mark_processed(NavigableString(latex)))
130
+
131
+
132
+ @renders(
133
+ "span",
134
+ phase=RenderPhase.INLINE,
135
+ priority=15,
136
+ name="autoref_spans",
137
+ nestable=False,
138
+ auto_mark=False,
139
+ )
140
+ def render_autoref_spans(element: Tag, context: RenderContext) -> None:
141
+ """Render MkDocs autoref span placeholders."""
142
+ identifier = coerce_attribute(element.get("data-autorefs-identifier"))
143
+ if not identifier:
144
+ return
145
+ text = element.get_text(strip=False)
146
+
147
+ latex = context.formatter.ref(text, ref=identifier)
148
+ node = mark_processed(NavigableString(latex))
149
+ context.mark_processed(element)
150
+ context.suppress_children(element)
151
+ element.replace_with(node)
152
+
153
+
154
+ @renders("a", phase=RenderPhase.INLINE, priority=60, name="links", nestable=False)
155
+ def render_links(element: Tag, context: RenderContext) -> None:
156
+ """Render hyperlinks and internal references."""
157
+ href = coerce_attribute(element.get("href")) or ""
158
+ element_id = coerce_attribute(element.get("id"))
159
+ text = element.get_text(strip=False)
160
+
161
+ # Already handled in preprocessing modules
162
+ if element.name != "a":
163
+ return
164
+
165
+ parsed_href = urlparse(href)
166
+ scheme = (parsed_href.scheme or "").lower()
167
+ fragment = parsed_href.fragment.strip() if parsed_href.fragment else ""
168
+
169
+ if scheme in {"http", "https"}:
170
+ latex = context.formatter.href(text=text, url=requote_url(href))
171
+ elif scheme:
172
+ raise InvalidNodeError(f"Unsupported link scheme '{scheme}' for '{href}'.")
173
+ elif href.startswith("#"):
174
+ latex = context.formatter.ref(text, ref=href[1:])
175
+ elif href == "" and element_id:
176
+ latex = context.formatter.label(element_id)
177
+ elif href:
178
+ resolved = _resolve_local_target(context, href)
179
+ if resolved is None:
180
+ raise AssetMissingError(f"Unable to resolve link target '{href}'")
181
+ target_ref = fragment or _infer_heading_reference(resolved)
182
+ if target_ref:
183
+ latex = context.formatter.ref(text or "", ref=target_ref)
184
+ else:
185
+ content = resolved.read_bytes()
186
+ digest = sha256(content).hexdigest()
187
+ reference = f"snippet:{digest}"
188
+ context.state.register_snippet(
189
+ reference,
190
+ {
191
+ "path": resolved,
192
+ "content": content,
193
+ "format": resolved.suffix[1:] if resolved.suffix else "",
194
+ },
195
+ )
196
+ latex = context.formatter.ref(text or "extrait", ref=reference)
197
+ else:
198
+ legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
199
+ latex = escape_latex_chars(text, legacy_accents=legacy_latex_accents)
200
+
201
+ element.replace_with(mark_processed(NavigableString(latex)))
202
+
203
+
204
+ _HEADING_TAGS = ("h1", "h2", "h3", "h4", "h5", "h6")
205
+ _HTML_EXTENSIONS = {".html", ".htm"}
206
+ _MARKDOWN_EXTENSIONS = {".md", ".markdown", ".mkd", ".mkdn"}
207
+
208
+
209
+ @lru_cache(maxsize=256)
210
+ def _extract_primary_heading(path: str) -> str | None:
211
+ candidate = Path(path)
212
+ suffix = candidate.suffix.lower()
213
+ try:
214
+ payload = candidate.read_text(encoding="utf-8")
215
+ except (OSError, UnicodeDecodeError):
216
+ return None
217
+
218
+ if suffix in _HTML_EXTENSIONS:
219
+ soup = BeautifulSoup(payload, "html.parser")
220
+ elif suffix in _MARKDOWN_EXTENSIONS:
221
+ document = render_markdown(
222
+ payload,
223
+ extensions=DEFAULT_MARKDOWN_EXTENSIONS,
224
+ base_path=candidate.parent,
225
+ )
226
+ soup = BeautifulSoup(document.html, "html.parser")
227
+ else:
228
+ return None
229
+
230
+ heading = soup.find(_HEADING_TAGS, id=True)
231
+ if heading is None:
232
+ return None
233
+ identifier = heading.get("id")
234
+ return identifier or None
235
+
236
+
237
+ def _infer_heading_reference(path: Path) -> str | None:
238
+ reference = _extract_primary_heading(str(path.resolve()))
239
+ return reference
@@ -0,0 +1,380 @@
1
+ """Handlers responsible for assets such as images and diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+ from typing import Any
8
+ import warnings
9
+
10
+ from bs4.element import NavigableString, Tag
11
+ from requests.utils import requote_uri as requote_url
12
+
13
+ from texsmith.core.context import RenderContext
14
+ from texsmith.core.diagnostics import DiagnosticEmitter
15
+ from texsmith.core.exceptions import (
16
+ AssetMissingError,
17
+ InvalidNodeError,
18
+ exception_hint,
19
+ exception_messages,
20
+ )
21
+ from texsmith.core.rules import RenderPhase, renders
22
+ from texsmith.fonts.scripts import render_moving_text
23
+
24
+ from ..latex.utils import escape_latex_chars
25
+ from ..transformers import mermaid2pdf
26
+ from ._assets import store_local_image_asset, store_remote_image_asset
27
+ from ._helpers import (
28
+ coerce_attribute,
29
+ gather_classes,
30
+ is_valid_url,
31
+ mark_processed,
32
+ resolve_asset_path,
33
+ )
34
+ from ._mermaid import (
35
+ MERMAID_FILE_SUFFIXES,
36
+ extract_mermaid_live_diagram as _extract_mermaid_live_diagram,
37
+ looks_like_mermaid as _looks_like_mermaid,
38
+ )
39
+
40
+
41
+ def _runtime_emitter(context: RenderContext) -> DiagnosticEmitter | None:
42
+ """Return the diagnostic emitter bound to the current runtime, if any."""
43
+ emitter = context.runtime.get("emitter")
44
+ if isinstance(emitter, DiagnosticEmitter):
45
+ return emitter
46
+ return None
47
+
48
+
49
+ def _load_mermaid_diagram(context: RenderContext, src: str) -> tuple[str, str] | None:
50
+ """Attempt to load a Mermaid diagram from a local file or URL."""
51
+ lowercase = src.lower()
52
+ if lowercase.endswith(MERMAID_FILE_SUFFIXES):
53
+ resolved = _resolve_source_path(context, src)
54
+ if resolved is None:
55
+ raise AssetMissingError(f"Unable to resolve Mermaid diagram '{src}'")
56
+ try:
57
+ return resolved.read_text(encoding="utf-8"), "file"
58
+ except OSError as exc:
59
+ raise AssetMissingError(f"Unable to read Mermaid diagram '{resolved}'") from exc
60
+
61
+ diagram = _extract_mermaid_live_diagram(src)
62
+ if diagram is not None:
63
+ return diagram, "url"
64
+
65
+ return None
66
+
67
+
68
+ def _figure_template_for(element: Tag) -> str | None:
69
+ """Determine which figure template to use based on ancestor metadata."""
70
+ current = element
71
+ while current is not None:
72
+ raw_classes = getattr(current, "get", lambda *_: None)("class")
73
+ class_list = gather_classes(raw_classes)
74
+ if any(cls in {"admonition", "exercise"} for cls in class_list):
75
+ return "figure_tcolorbox"
76
+ if getattr(current, "name", None) == "details":
77
+ return "figure_tcolorbox"
78
+ current = getattr(current, "parent", None)
79
+ return None
80
+
81
+
82
+ def _resolve_source_path(context: RenderContext, src: str) -> Path | None:
83
+ """Resolve a local asset path using runtime hints."""
84
+ runtime_dir = context.runtime.get("source_dir")
85
+ if runtime_dir is not None:
86
+ candidate = Path(runtime_dir) / src
87
+ if candidate.exists():
88
+ return candidate.resolve()
89
+
90
+ document_path = context.runtime.get("document_path")
91
+ if document_path is not None:
92
+ resolved = resolve_asset_path(Path(document_path), src)
93
+ if resolved is not None:
94
+ return resolved
95
+
96
+ project_dir = getattr(context.config, "project_dir", None)
97
+ if project_dir:
98
+ candidate = Path(project_dir) / src
99
+ if candidate.exists():
100
+ return candidate.resolve()
101
+
102
+ return None
103
+
104
+
105
+ _MKDOCS_THEME_VARIANTS = {"only-light", "only-dark"}
106
+
107
+
108
+ def _strip_mkdocs_theme_variant(src: str) -> str:
109
+ """Drop MkDocs Material light/dark suffixes appended to image URLs."""
110
+ base, sep, fragment = src.partition("#")
111
+ if not sep:
112
+ return src
113
+ if fragment.lower() in _MKDOCS_THEME_VARIANTS:
114
+ return base
115
+ return src
116
+
117
+
118
+ def _apply_figure_template(
119
+ context: RenderContext,
120
+ *,
121
+ path: Path,
122
+ caption: str | None = None,
123
+ shortcaption: str | None = None,
124
+ alt: str | None = None,
125
+ label: str | None = None,
126
+ width: str | None = None,
127
+ template: str | None = None,
128
+ adjustbox: bool = False,
129
+ link: str | None = None,
130
+ ) -> NavigableString:
131
+ """Render the shared figure template and return a new node."""
132
+ template_name = template or context.runtime.get("figure_template", "figure")
133
+ formatter = getattr(context.formatter, template_name)
134
+ asset_path = context.assets.latex_path(path)
135
+ legacy_accents = getattr(context.config, "legacy_latex_accents", False)
136
+ caption = render_moving_text(caption, context, legacy_accents=legacy_accents, wrap_scripts=True)
137
+ shortcaption = render_moving_text(
138
+ shortcaption, context, legacy_accents=legacy_accents, wrap_scripts=True
139
+ )
140
+ alt_text = render_moving_text(alt, context, legacy_accents=legacy_accents, wrap_scripts=True)
141
+ effective_shortcaption = shortcaption or alt_text or caption
142
+ safe_link = (
143
+ escape_latex_chars(requote_url(link), legacy_accents=legacy_accents) if link else None
144
+ )
145
+ latex = formatter(
146
+ path=asset_path,
147
+ caption=caption,
148
+ shortcaption=effective_shortcaption,
149
+ label=label,
150
+ width=width,
151
+ adjustbox=adjustbox,
152
+ link=safe_link,
153
+ )
154
+ return mark_processed(NavigableString(latex))
155
+
156
+
157
+ def _render_mermaid_diagram(
158
+ context: RenderContext,
159
+ diagram: str,
160
+ *,
161
+ template: str | None = None,
162
+ caption: str | None = None,
163
+ width: str | None = None,
164
+ ) -> NavigableString | None:
165
+ """Render a Mermaid diagram and return the resulting LaTeX node."""
166
+ extracted_caption, body = _mermaid_caption(diagram)
167
+ effective_caption = extracted_caption if extracted_caption is not None else caption
168
+ if not context.runtime.get("copy_assets", True):
169
+ placeholder = effective_caption or "Mermaid diagram"
170
+ return mark_processed(NavigableString(placeholder))
171
+ if not _looks_like_mermaid(body):
172
+ return None
173
+ if "```" in body or "~~~" in body:
174
+ return None
175
+
176
+ render_options: dict[str, Any] = {}
177
+ mermaid_config = getattr(context.config, "mermaid_config", None)
178
+ if mermaid_config:
179
+ project_dir = getattr(context.config, "project_dir", None)
180
+ if project_dir is None:
181
+ raise AssetMissingError("Project directory required to render Mermaid diagrams")
182
+ render_options["config_filename"] = Path(project_dir) / mermaid_config
183
+ backend = context.runtime.get("diagrams_backend")
184
+ if backend:
185
+ render_options["backend"] = backend
186
+ runtime_mermaid_config = context.runtime.get("mermaid_config")
187
+ if runtime_mermaid_config is not None:
188
+ render_options["mermaid_config"] = runtime_mermaid_config
189
+
190
+ try:
191
+ artefact = mermaid2pdf(body, output_dir=context.assets.output_root, **render_options)
192
+ except Exception as exc: # pragma: no cover - safeguard
193
+ _warn_mermaid_failure(context, exc)
194
+ placeholder = effective_caption or "Mermaid diagram"
195
+ return mark_processed(NavigableString(f"[{placeholder} unavailable]"))
196
+
197
+ asset_key = f"mermaid::{hashlib.sha256(body.encode('utf-8')).hexdigest()}"
198
+ stored_path = context.assets.register(asset_key, artefact)
199
+
200
+ return _apply_figure_template(
201
+ context,
202
+ path=stored_path,
203
+ caption=effective_caption,
204
+ label=None,
205
+ width=width,
206
+ template=template,
207
+ adjustbox=True,
208
+ )
209
+
210
+
211
+ def _mermaid_caption(diagram: str) -> tuple[str | None, str]:
212
+ """Extract caption from the diagram comment header."""
213
+ lines = diagram.splitlines()
214
+ if lines and lines[0].strip().startswith("%%"):
215
+ caption = lines[0].strip()[2:].strip() or None
216
+ body = "\n".join(lines[1:])
217
+ return caption, body
218
+ return None, diagram
219
+
220
+
221
+ @renders("img", phase=RenderPhase.BLOCK, name="render_images", nestable=False)
222
+ def render_images(element: Tag, context: RenderContext) -> None:
223
+ """Convert <img> nodes into LaTeX figures and manage assets."""
224
+ if not context.runtime.get("copy_assets", True):
225
+ alt_text = (
226
+ coerce_attribute(element.get("alt")) or coerce_attribute(element.get("title")) or ""
227
+ )
228
+ placeholder = alt_text.strip() or "[image]"
229
+ element.replace_with(mark_processed(NavigableString(placeholder)))
230
+ return
231
+
232
+ src = coerce_attribute(element.get("src"))
233
+ if not src:
234
+ raise InvalidNodeError("Image tag without 'src' attribute")
235
+
236
+ src = _strip_mkdocs_theme_variant(src)
237
+
238
+ classes = gather_classes(element.get("class"))
239
+ if {"twemoji", "emojione"}.intersection(classes):
240
+ return
241
+
242
+ if element.find_parent("figure"):
243
+ return
244
+
245
+ raw_alt = coerce_attribute(element.get("alt"))
246
+ alt_text = raw_alt.strip() if raw_alt else None
247
+ raw_title = coerce_attribute(element.get("title"))
248
+ caption_text = raw_title.strip() if raw_title else None
249
+ width = coerce_attribute(element.get("width")) or None
250
+ template = _figure_template_for(element)
251
+
252
+ link_wrapper = None
253
+ link_target = None
254
+ parent = element.parent
255
+ if isinstance(parent, Tag) and parent.name == "a":
256
+ candidates = [
257
+ child
258
+ for child in parent.contents
259
+ if not (isinstance(child, NavigableString) and not child.strip())
260
+ ]
261
+ if len(candidates) == 1 and candidates[0] is element:
262
+ link_wrapper = parent
263
+ link_target = coerce_attribute(parent.get("href"))
264
+
265
+ mermaid_payload = _load_mermaid_diagram(context, src)
266
+ if mermaid_payload is not None:
267
+ diagram, _ = mermaid_payload
268
+ figure_node = _render_mermaid_diagram(
269
+ context,
270
+ diagram,
271
+ template=template,
272
+ caption=caption_text,
273
+ )
274
+ if figure_node is None:
275
+ raise InvalidNodeError(f"Mermaid source '{src}' does not contain a valid diagram")
276
+ element.replace_with(figure_node)
277
+ return
278
+
279
+ if not caption_text:
280
+ caption_text = alt_text
281
+
282
+ if is_valid_url(src):
283
+ stored_path = store_remote_image_asset(context, src)
284
+ else:
285
+ resolved = _resolve_source_path(context, src)
286
+ if resolved is None:
287
+ raise AssetMissingError(f"Unable to resolve image asset '{src}'")
288
+
289
+ stored_path = store_local_image_asset(context, resolved)
290
+
291
+ figure_node = _apply_figure_template(
292
+ context,
293
+ path=stored_path,
294
+ caption=caption_text,
295
+ alt=alt_text,
296
+ label=None,
297
+ width=width,
298
+ template=template,
299
+ adjustbox=False,
300
+ link=link_target,
301
+ )
302
+ if link_wrapper:
303
+ link_wrapper.replace_with(figure_node)
304
+ else:
305
+ element.replace_with(figure_node)
306
+
307
+
308
+ @renders("div", phase=RenderPhase.BLOCK, name="render_mermaid", nestable=False)
309
+ def render_mermaid(element: Tag, context: RenderContext) -> None:
310
+ """Convert Mermaid code blocks inside highlight containers."""
311
+ classes = gather_classes(element.get("class"))
312
+ if "highlight" not in classes and "mermaid" not in classes:
313
+ return
314
+
315
+ code = element.find("code")
316
+ if code is None:
317
+ return
318
+
319
+ diagram = code.get_text()
320
+ width = coerce_attribute(code.get("width") or element.get("width"))
321
+ template = _figure_template_for(element)
322
+ figure_node = _render_mermaid_diagram(context, diagram, template=template, width=width)
323
+ if figure_node is None:
324
+ return
325
+
326
+ element.replace_with(figure_node)
327
+
328
+
329
+ @renders("pre", phase=RenderPhase.BLOCK, name="render_mermaid_pre", nestable=False)
330
+ def render_mermaid_pre(element: Tag, context: RenderContext) -> None:
331
+ """Handle <pre class=\"mermaid\"> blocks."""
332
+ classes = gather_classes(element.get("class"))
333
+ if "mermaid" not in classes:
334
+ return
335
+
336
+ diagram: str | None = None
337
+ source_hint = coerce_attribute(element.get("data-mermaid-source"))
338
+ if source_hint:
339
+ payload = _load_mermaid_diagram(context, source_hint)
340
+ if payload is not None:
341
+ diagram, _ = payload
342
+ if diagram is None:
343
+ diagram = element.get_text()
344
+ width = coerce_attribute(element.get("width"))
345
+
346
+ template = _figure_template_for(element)
347
+ figure_node = _render_mermaid_diagram(context, diagram, template=template, width=width)
348
+ if figure_node is None:
349
+ return
350
+
351
+ element.replace_with(figure_node)
352
+
353
+
354
+ def _warn_mermaid_failure(context: RenderContext, exc: Exception) -> None:
355
+ """Emit a CLI-friendly warning describing Mermaid rendering failures."""
356
+ emitter = _runtime_emitter(context)
357
+ guidance = (
358
+ "Install Docker and the 'minlag/mermaid-cli' image (or register a custom Mermaid "
359
+ "converter) to enable diagram rendering."
360
+ )
361
+ summary = "Mermaid diagram could not be rendered. TexSmith inserted a placeholder instead."
362
+ hint = exception_hint(exc)
363
+
364
+ if emitter is None:
365
+ detail = f" ({hint})" if hint else ""
366
+ message = f"{summary}{detail}. {guidance}"
367
+ warnings.warn(message, stacklevel=3)
368
+ return
369
+
370
+ if emitter.debug_enabled:
371
+ chain = exception_messages(exc)
372
+ detail_block = ""
373
+ if chain:
374
+ detail_lines = "\n".join(f"- {line}" for line in chain)
375
+ detail_block = f"\nDetails:\n{detail_lines}"
376
+ emitter.warning(f"{summary}{detail_block}\n{guidance}", exc=exc)
377
+ return
378
+
379
+ detail = f" ({hint})" if hint else ""
380
+ emitter.warning(f"{summary}{detail}. {guidance} Run with --debug for technical details.")
@@ -0,0 +1,10 @@
1
+ """LaTeX-specific utilities exposed by Texsmith."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .formatter import LaTeXFormatter, optimize_list
6
+ from .renderer import LaTeXRenderer
7
+ from .utils import escape_latex_chars
8
+
9
+
10
+ __all__ = ["LaTeXFormatter", "LaTeXRenderer", "escape_latex_chars", "optimize_list"]