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,363 @@
1
+ """Asset helpers shared across media and block handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ import shutil
9
+ from typing import Any
10
+ from urllib.parse import unquote, urlparse
11
+
12
+ from texsmith.adapters.transformers import (
13
+ drawio2pdf,
14
+ fetch_image,
15
+ image2pdf,
16
+ mermaid2pdf,
17
+ svg2pdf,
18
+ )
19
+ from texsmith.adapters.transformers.strategies import _cairo_dependency_hint
20
+ from texsmith.core.context import RenderContext
21
+ from texsmith.core.conversion.debug import ensure_emitter, record_event
22
+
23
+
24
+ _NATIVE_IMAGE_SUFFIXES: set[str] = {".png", ".jpg", ".jpeg", ".pdf"}
25
+ _FORCED_CONVERSION_SUFFIXES: set[str] = {".svg", ".drawio"}
26
+ _CONVERSION_CACHE_DIR = ".converted"
27
+ _ASSET_MANIFEST = "remote-assets.json"
28
+ _PLACEHOLDER_PDF = b"%PDF-1.4\n1 0 obj<<>>\nendobj\nxref\n0 1\n0000000000 65535 f \ntrailer<<>>\nstartxref\n9\n%%EOF\n"
29
+
30
+
31
+ def store_local_image_asset(context: RenderContext, resolved: Path) -> Path:
32
+ """Copy or convert a local asset and register it on the context."""
33
+ asset_key = str(resolved)
34
+ existing = context.assets.lookup(asset_key)
35
+ if existing is not None:
36
+ return existing
37
+
38
+ suffix = resolved.suffix.lower()
39
+ convert_requested = bool(context.runtime.get("convert_assets", False))
40
+ needs_conversion = _requires_conversion(suffix, convert_requested)
41
+ emitter = ensure_emitter(context.runtime.get("emitter"))
42
+
43
+ if needs_conversion:
44
+ record_event(
45
+ emitter,
46
+ "asset_convert",
47
+ {
48
+ "source": str(resolved),
49
+ "suffix": suffix,
50
+ "reason": "requested" if convert_requested else "forced",
51
+ },
52
+ )
53
+ staged = _convert_local_asset(context, resolved, suffix)
54
+ final_suffix = ".pdf"
55
+ else:
56
+ staged = resolved
57
+ final_suffix = suffix or ".bin"
58
+
59
+ record_event(
60
+ emitter,
61
+ "asset_local",
62
+ {
63
+ "source": str(resolved),
64
+ "stored_suffix": final_suffix,
65
+ "converted": needs_conversion,
66
+ },
67
+ )
68
+ return _persist_asset(
69
+ context,
70
+ asset_key=asset_key,
71
+ staged_path=staged,
72
+ suffix=final_suffix,
73
+ source_path=resolved,
74
+ )
75
+
76
+
77
+ def store_remote_image_asset(context: RenderContext, url: str) -> Path:
78
+ """Fetch a remote asset, mirror it locally, and register it."""
79
+ existing = context.assets.lookup(url)
80
+ if existing is not None:
81
+ return existing
82
+
83
+ convert_requested = bool(context.runtime.get("convert_assets", False))
84
+ metadata: dict[str, str] = {}
85
+ conversion_root = _conversion_cache_root(context)
86
+ manifest_path = conversion_root / _ASSET_MANIFEST
87
+ manifest, manifest_dirty = _load_asset_manifest(manifest_path)
88
+ url_suffix = _suffix_from_url(url)
89
+ suffix_hint = _normalise_suffix(url_suffix, default="") if url_suffix else ""
90
+ emitter = ensure_emitter(context.runtime.get("emitter"))
91
+
92
+ record_event(
93
+ emitter,
94
+ "asset_fetch",
95
+ {
96
+ "url": url,
97
+ "convert": convert_requested,
98
+ "suffix_hint": suffix_hint,
99
+ },
100
+ )
101
+ fetch_options: dict[str, Any] = {
102
+ "convert": convert_requested,
103
+ "metadata": metadata,
104
+ "manifest": manifest,
105
+ "manifest_path": manifest_path,
106
+ "manifest_dirty": manifest_dirty,
107
+ "emitter": emitter,
108
+ }
109
+ if convert_requested:
110
+ fetch_options["output_suffix"] = ".pdf"
111
+ elif suffix_hint:
112
+ fetch_options["output_suffix"] = suffix_hint
113
+ artefact = fetch_image(
114
+ url,
115
+ output_dir=conversion_root,
116
+ **fetch_options,
117
+ )
118
+
119
+ recorded_suffix = metadata.get("suffix") if metadata else None
120
+ if not recorded_suffix:
121
+ detected_suffix = _detect_file_suffix(Path(artefact))
122
+ if detected_suffix:
123
+ recorded_suffix = detected_suffix
124
+ if recorded_suffix:
125
+ final_suffix = _normalise_suffix(recorded_suffix)
126
+ else:
127
+ final_suffix = Path(artefact).suffix or ".pdf"
128
+ prefer_name = _extract_remote_name(url)
129
+
130
+ record_event(
131
+ emitter,
132
+ "asset_fetch_complete",
133
+ {
134
+ "url": url,
135
+ "stored_suffix": final_suffix,
136
+ "converted": convert_requested or suffix_hint in _FORCED_CONVERSION_SUFFIXES,
137
+ },
138
+ )
139
+ if manifest_dirty["dirty"]:
140
+ _save_asset_manifest(manifest_path, manifest)
141
+ return _persist_asset(
142
+ context,
143
+ asset_key=url,
144
+ staged_path=artefact,
145
+ suffix=final_suffix,
146
+ source_path=None,
147
+ prefer_name=prefer_name,
148
+ force_hash=not bool(prefer_name),
149
+ )
150
+
151
+
152
+ def _requires_conversion(suffix: str, convert_requested: bool) -> bool:
153
+ lowered = suffix.lower()
154
+ if lowered == ".pdf":
155
+ return False
156
+ if lowered in _FORCED_CONVERSION_SUFFIXES:
157
+ return True
158
+ if lowered not in _NATIVE_IMAGE_SUFFIXES:
159
+ return True
160
+ return convert_requested
161
+
162
+
163
+ def _write_placeholder_pdf(target: Path) -> Path:
164
+ target.parent.mkdir(parents=True, exist_ok=True)
165
+ target.write_bytes(_PLACEHOLDER_PDF)
166
+ return target
167
+
168
+
169
+ def _convert_local_asset(context: RenderContext, source: Path, suffix: str) -> Path:
170
+ conversion_root = _conversion_cache_root(context)
171
+ emitter = ensure_emitter(context.runtime.get("emitter"))
172
+ match suffix:
173
+ case ".svg":
174
+ record_event(emitter, "diagram_generate", {"source": str(source), "kind": "svg"})
175
+ try:
176
+ return svg2pdf(source, output_dir=conversion_root, emitter=emitter)
177
+ except Exception as exc:
178
+ emitter.warning(
179
+ f"Falling back to placeholder PDF for SVG '{source}': {exc}. "
180
+ f"{_cairo_dependency_hint()}"
181
+ )
182
+ placeholder = conversion_root / f"{source.stem}.pdf"
183
+ return _write_placeholder_pdf(placeholder)
184
+ case ".drawio":
185
+ record_event(emitter, "diagram_generate", {"source": str(source), "kind": "drawio"})
186
+ emit_info = getattr(emitter, "info", None)
187
+ if callable(emit_info):
188
+ emit_info(f"Converting draw.io diagram: {source}")
189
+ backend = context.runtime.get("diagrams_backend")
190
+ return drawio2pdf(source, output_dir=conversion_root, backend=backend, emitter=emitter)
191
+ case ".mmd" | ".mermaid":
192
+ record_event(emitter, "diagram_generate", {"source": str(source), "kind": "mermaid"})
193
+ emit_info = getattr(emitter, "info", None)
194
+ if callable(emit_info):
195
+ emit_info(f"Converting Mermaid diagram: {source}")
196
+ backend = context.runtime.get("diagrams_backend")
197
+ mermaid_config = context.runtime.get("mermaid_config")
198
+ return mermaid2pdf(
199
+ source,
200
+ output_dir=conversion_root,
201
+ backend=backend,
202
+ mermaid_config=mermaid_config,
203
+ emitter=emitter,
204
+ )
205
+ case _:
206
+ record_event(emitter, "asset_convert", {"source": str(source), "kind": "image"})
207
+ return image2pdf(source, output_dir=conversion_root, emitter=emitter)
208
+
209
+
210
+ def _conversion_cache_root(context: RenderContext) -> Path:
211
+ root = context.assets.output_root / _CONVERSION_CACHE_DIR
212
+ root.mkdir(parents=True, exist_ok=True)
213
+ return root
214
+
215
+
216
+ def _load_asset_manifest(path: Path) -> tuple[dict[str, dict[str, Any]], dict[str, bool]]:
217
+ if path.exists():
218
+ try:
219
+ data = json.loads(path.read_text(encoding="utf-8"))
220
+ if isinstance(data, dict):
221
+ return {str(k): dict(v) for k, v in data.items()}, {"dirty": False}
222
+ except Exception:
223
+ pass
224
+ return {}, {"dirty": False}
225
+
226
+
227
+ def _save_asset_manifest(path: Path, manifest: dict[str, dict[str, Any]]) -> None:
228
+ try:
229
+ path.parent.mkdir(parents=True, exist_ok=True)
230
+ path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
231
+ except Exception:
232
+ pass
233
+
234
+
235
+ def _persist_asset(
236
+ context: RenderContext,
237
+ *,
238
+ asset_key: str,
239
+ staged_path: Path,
240
+ suffix: str,
241
+ source_path: Path | None = None,
242
+ prefer_name: str | None = None,
243
+ force_hash: bool = False,
244
+ ) -> Path:
245
+ target = _determine_target_path(
246
+ context,
247
+ asset_key=asset_key,
248
+ suffix=suffix,
249
+ source_path=source_path,
250
+ prefer_name=prefer_name,
251
+ force_hash=force_hash,
252
+ )
253
+ staged = Path(staged_path)
254
+ if staged.resolve() != target.resolve():
255
+ target.parent.mkdir(parents=True, exist_ok=True)
256
+ shutil.copy2(staged, target)
257
+ else:
258
+ target.parent.mkdir(parents=True, exist_ok=True)
259
+ return context.assets.register(asset_key, target)
260
+
261
+
262
+ def _determine_target_path(
263
+ context: RenderContext,
264
+ *,
265
+ asset_key: str,
266
+ suffix: str,
267
+ source_path: Path | None,
268
+ prefer_name: str | None,
269
+ force_hash: bool,
270
+ ) -> Path:
271
+ hash_policy = force_hash or bool(context.runtime.get("hash_assets", False))
272
+ base = context.assets.output_root
273
+
274
+ if not hash_policy:
275
+ candidate = None
276
+ if source_path is not None:
277
+ candidate = _relative_path_for_asset(context, source_path)
278
+ if candidate is None and prefer_name:
279
+ candidate = Path(prefer_name)
280
+ if candidate is None and source_path is not None:
281
+ candidate = Path(source_path.name)
282
+ if candidate is not None:
283
+ adjusted = candidate.with_suffix(suffix)
284
+ candidate_path = (base / adjusted).resolve()
285
+ if not _candidate_conflicts(context, candidate_path, asset_key):
286
+ return candidate_path
287
+
288
+ digest = hashlib.sha256(asset_key.encode("utf-8")).hexdigest()
289
+ filename = f"{digest}{suffix}"
290
+ return (base / filename).resolve()
291
+
292
+
293
+ def _candidate_conflicts(context: RenderContext, candidate: Path, asset_key: str) -> bool:
294
+ candidate_resolved = candidate.resolve()
295
+ for existing_key, stored in context.assets.assets_map.items():
296
+ stored_path = Path(stored).resolve()
297
+ if stored_path == candidate_resolved:
298
+ return existing_key != asset_key
299
+ return False
300
+
301
+
302
+ def _relative_path_for_asset(context: RenderContext, source: Path) -> Path | None:
303
+ project_dir = getattr(context.config, "project_dir", None)
304
+ if project_dir:
305
+ try:
306
+ return source.relative_to(Path(project_dir))
307
+ except ValueError:
308
+ pass
309
+ document_path = context.runtime.get("document_path")
310
+ if document_path:
311
+ try:
312
+ return source.relative_to(Path(document_path).parent)
313
+ except ValueError:
314
+ pass
315
+ source_dir = context.runtime.get("source_dir")
316
+ if source_dir:
317
+ try:
318
+ return source.relative_to(Path(source_dir))
319
+ except ValueError:
320
+ pass
321
+ return None
322
+
323
+
324
+ def _extract_remote_name(url: str) -> str | None:
325
+ parsed = urlparse(url)
326
+ name = Path(unquote(parsed.path or "")).name
327
+ return name or None
328
+
329
+
330
+ def _suffix_from_url(url: str) -> str:
331
+ parsed = urlparse(url)
332
+ return Path(parsed.path or "").suffix.lower()
333
+
334
+
335
+ def _normalise_suffix(value: str | None, default: str = ".bin") -> str:
336
+ candidate = (value or "").strip()
337
+ if not candidate:
338
+ return default
339
+ return candidate if candidate.startswith(".") else f".{candidate.lstrip('.')}"
340
+
341
+
342
+ def _detect_file_suffix(path: Path) -> str | None:
343
+ try:
344
+ with path.open("rb") as handle:
345
+ header = handle.read(12)
346
+ except OSError:
347
+ return None
348
+ if header.startswith(b"%PDF"):
349
+ return ".pdf"
350
+ if header.startswith(b"\xff\xd8\xff"):
351
+ return ".jpg"
352
+ if header.startswith(b"\x89PNG"):
353
+ return ".png"
354
+ if header.startswith(b"GIF8"):
355
+ return ".gif"
356
+ if header.startswith(b"BM"):
357
+ return ".bmp"
358
+ if header[0:4] == b"RIFF" and header[8:12] == b"WEBP":
359
+ return ".webp"
360
+ return None
361
+
362
+
363
+ __all__ = ["store_local_image_asset", "store_remote_image_asset"]
@@ -0,0 +1,62 @@
1
+ """Internal helpers shared across handler modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+ from typing import Any, TypeVar, cast
8
+ from urllib.parse import urlparse
9
+
10
+ from bs4.element import PageElement
11
+
12
+
13
+ NodeT = TypeVar("NodeT", bound=PageElement)
14
+
15
+
16
+ def mark_processed(node: NodeT) -> NodeT:
17
+ """Mark a BeautifulSoup node as processed and return it for chaining."""
18
+ cast(Any, node).processed = True # type: ignore[attr-defined]
19
+ return node
20
+
21
+
22
+ def coerce_attribute(value: Any) -> str | None:
23
+ """Normalise a BeautifulSoup attribute value to a string when possible."""
24
+ if isinstance(value, str):
25
+ return value
26
+ if isinstance(value, bytes):
27
+ try:
28
+ return value.decode("utf-8")
29
+ except UnicodeDecodeError:
30
+ return None
31
+ if isinstance(value, Iterable):
32
+ for item in value:
33
+ if isinstance(item, str):
34
+ return item
35
+ return None
36
+
37
+
38
+ def gather_classes(value: Any) -> list[str]:
39
+ """Return a list of classes extracted from a BeautifulSoup attribute."""
40
+ if isinstance(value, str):
41
+ return [value]
42
+ if isinstance(value, Iterable) and not isinstance(value, (bytes, bytearray)):
43
+ return [cast(str, item) for item in value if isinstance(item, str)]
44
+ return []
45
+
46
+
47
+ def resolve_asset_path(file_path: Path, path: str | Path) -> Path | None:
48
+ """Resolve an asset path relative to a Markdown source file."""
49
+ origin = Path(file_path)
50
+ if origin.name == "index.md":
51
+ origin = origin.parent
52
+ target = (origin / path).resolve()
53
+ return target if target.exists() else None
54
+
55
+
56
+ def is_valid_url(url: str) -> bool:
57
+ """Check whether a URL string has a valid scheme/netloc combination."""
58
+ try:
59
+ result = urlparse(url)
60
+ except ValueError:
61
+ return False
62
+ return bool(result.scheme and result.netloc)
@@ -0,0 +1,122 @@
1
+ """Heuristics and helpers shared across handlers to detect Mermaid diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import json
8
+ from urllib.parse import urlparse
9
+ import zlib
10
+
11
+ from texsmith.core.exceptions import InvalidNodeError
12
+
13
+
14
+ MERMAID_FILE_SUFFIXES = (".mmd", ".mermaid")
15
+
16
+ MERMAID_KEYWORDS = {
17
+ "graph",
18
+ "flowchart",
19
+ "sequencediagram",
20
+ "classdiagram",
21
+ "statediagram",
22
+ "gantt",
23
+ "erdiagram",
24
+ "journey",
25
+ "packet",
26
+ }
27
+
28
+
29
+ def looks_like_mermaid(diagram: str) -> bool:
30
+ """Return True when the payload resembles a Mermaid diagram."""
31
+ if not diagram:
32
+ return False
33
+
34
+ for line in diagram.splitlines():
35
+ stripped = line.strip()
36
+ if not stripped:
37
+ continue
38
+ if stripped.startswith("%%"):
39
+ continue
40
+ token = stripped.split(maxsplit=1)[0].lower()
41
+ return token in MERMAID_KEYWORDS
42
+
43
+ return False
44
+
45
+
46
+ def _extract_mermaid_code(payload: str) -> str:
47
+ """Return Mermaid code embedded in a Mermaid Live payload."""
48
+ candidate = payload.lstrip()
49
+ if not candidate.startswith("{"):
50
+ return payload
51
+
52
+ try:
53
+ data = json.loads(candidate)
54
+ except json.JSONDecodeError:
55
+ return payload
56
+
57
+ code = data.get("code")
58
+ if isinstance(code, str) and code.strip():
59
+ return code
60
+ return payload
61
+
62
+
63
+ def decode_mermaid_pako(payload: str) -> str:
64
+ """Decode a Mermaid Live payload compressed with pako."""
65
+ token = payload.strip()
66
+ if not token:
67
+ raise InvalidNodeError("Mermaid payload is empty")
68
+
69
+ padding = (-len(token)) % 4
70
+ if padding:
71
+ token += "=" * padding
72
+
73
+ try:
74
+ compressed = base64.urlsafe_b64decode(token)
75
+ except (binascii.Error, ValueError) as exc:
76
+ raise InvalidNodeError("Mermaid payload is not valid base64") from exc
77
+
78
+ last_error: Exception | None = None
79
+ for wbits in (zlib.MAX_WBITS, -zlib.MAX_WBITS):
80
+ try:
81
+ data = zlib.decompress(compressed, wbits=wbits)
82
+ try:
83
+ extracted = data.decode("utf-8")
84
+ return _extract_mermaid_code(extracted)
85
+ except UnicodeDecodeError as exc: # pragma: no cover - defensive
86
+ raise InvalidNodeError("Mermaid payload is not UTF-8 text") from exc
87
+ except zlib.error as exc:
88
+ last_error = exc
89
+
90
+ raise InvalidNodeError("Unable to decompress Mermaid payload") from last_error
91
+
92
+
93
+ def extract_mermaid_live_diagram(src: str) -> str | None:
94
+ """Return Mermaid source encoded in a mermaid.live URL."""
95
+ try:
96
+ parsed = urlparse(src)
97
+ except ValueError:
98
+ return None
99
+
100
+ if not parsed.netloc or not parsed.scheme:
101
+ return None
102
+ if not parsed.netloc.endswith("mermaid.live"):
103
+ return None
104
+
105
+ fragment = (parsed.fragment or "").strip()
106
+ if not fragment:
107
+ return None
108
+
109
+ marker = fragment.find("pako:")
110
+ if marker == -1:
111
+ return None
112
+
113
+ payload = fragment[marker + len("pako:") :]
114
+ for delimiter in (";", "&"):
115
+ idx = payload.find(delimiter)
116
+ if idx != -1:
117
+ payload = payload[:idx]
118
+
119
+ if not payload:
120
+ raise InvalidNodeError("Mermaid URL is missing diagram payload")
121
+
122
+ return decode_mermaid_pako(payload)