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,1734 @@
1
+ """Plugin rendering fenced snippet blocks into standalone PDF assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ import contextlib
7
+ from dataclasses import dataclass
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+ import shutil
13
+ import tempfile
14
+ from typing import Any
15
+
16
+ from bs4 import BeautifulSoup
17
+ from bs4.element import NavigableString, Tag
18
+ from PIL import Image, ImageDraw
19
+ import yaml
20
+
21
+ from texsmith.adapters.handlers._helpers import (
22
+ coerce_attribute,
23
+ gather_classes,
24
+ mark_processed,
25
+ )
26
+ from texsmith.adapters.markdown import (
27
+ DEFAULT_MARKDOWN_EXTENSIONS,
28
+ render_markdown,
29
+ split_front_matter,
30
+ )
31
+ from texsmith.api.document import (
32
+ Document,
33
+ DocumentRenderOptions,
34
+ DocumentSlots,
35
+ TitleStrategy,
36
+ _resolve_title_strategy,
37
+ front_matter_has_title,
38
+ )
39
+ from texsmith.api.pipeline import RenderSettings
40
+ from texsmith.api.templates import TemplateSession
41
+ from texsmith.core.context import RenderContext
42
+ from texsmith.core.conversion.inputs import InputKind
43
+ from texsmith.core.diagnostics import DiagnosticEmitter
44
+ from texsmith.core.exceptions import AssetMissingError, InvalidNodeError, LatexRenderingError
45
+ from texsmith.core.metadata import PressMetadataError, normalise_press_metadata
46
+ from texsmith.core.rules import RenderPhase, renders
47
+ from texsmith.core.templates import TemplateError, TemplateRuntime, load_template_runtime
48
+ from texsmith.core.user_dir import get_user_dir
49
+
50
+
51
+ SNIPPET_DIR = "snippets"
52
+ _SNIPPET_PREFIX = "snippet-"
53
+ _TRUE_VALUES = {"1", "true", "on", "yes"}
54
+ _FALSE_VALUES = {"0", "false", "off", "no"}
55
+ _SNIPPET_CACHE_NAMESPACE = "snippets"
56
+ _SNIPPET_CACHE_FILENAME = "metadata.json"
57
+ _SNIPPET_CACHE_VERSION = 3
58
+ _log = logging.getLogger(__name__)
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class SnippetBlock:
63
+ """Parsed representation of a snippet fence."""
64
+
65
+ content: str | None
66
+ sources: list[Path]
67
+ layout: tuple[int, int] | None
68
+ preview_dogear: bool
69
+ preview_fold_size: float | None
70
+ template_id: str | None
71
+ cwd: Path | None
72
+ caption: str | None
73
+ label: str | None
74
+ figure_width: str | None
75
+ template_overrides: dict[str, Any]
76
+ digest: str
77
+ bibliography_files: list[Path]
78
+ promote_title: bool
79
+ drop_title: bool
80
+ suppress_title_metadata: bool
81
+
82
+ @property
83
+ def asset_basename(self) -> str:
84
+ return f"{_SNIPPET_PREFIX}{self.digest}"
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class _SnippetAssets:
89
+ pdf: Path
90
+ png: Path
91
+
92
+
93
+ _SNIPPET_RUNTIME: TemplateRuntime | None = None
94
+ _SNIPPET_CACHE: _SnippetCache | None = None
95
+
96
+
97
+ @dataclass(slots=True)
98
+ class _SnippetCache:
99
+ """Disk-backed cache storing rendered snippet artefacts."""
100
+
101
+ root: Path
102
+ metadata_path: Path
103
+ metadata: dict[str, Any]
104
+ dirty: bool = False
105
+
106
+ def lookup(self, digest: str, template_version: str | None) -> _SnippetAssets | None:
107
+ """Return cached assets when they exist and match the signature."""
108
+ entries = self._entries()
109
+ payload = entries.get(digest)
110
+ if not isinstance(payload, dict):
111
+ self.discard(digest)
112
+ return None
113
+
114
+ signature = payload.get("signature")
115
+ if signature and signature != digest:
116
+ self.discard(digest)
117
+ return None
118
+
119
+ recorded_version = payload.get("template_version")
120
+ if template_version and recorded_version and recorded_version != template_version:
121
+ self.discard(digest)
122
+ return None
123
+
124
+ pdf_name = payload.get("pdf") or asset_filename(digest, ".pdf")
125
+ png_name = payload.get("png") or asset_filename(digest, ".png")
126
+ pdf_path = (self.root / pdf_name).resolve()
127
+ png_path = (self.root / png_name).resolve()
128
+ if not pdf_path.exists() or not png_path.exists():
129
+ self.discard(digest)
130
+ return None
131
+
132
+ return _SnippetAssets(pdf=pdf_path, png=png_path)
133
+
134
+ def store(
135
+ self,
136
+ digest: str,
137
+ pdf_path: Path,
138
+ png_path: Path,
139
+ *,
140
+ template_version: str | None,
141
+ block: SnippetBlock | None = None,
142
+ source_path: Path | None = None,
143
+ ) -> None:
144
+ """Persist compiled assets in the cache directory."""
145
+ entries = self._entries()
146
+ cached_pdf = (self.root / asset_filename(digest, ".pdf")).resolve()
147
+ cached_png = (self.root / asset_filename(digest, ".png")).resolve()
148
+ cached_pdf.parent.mkdir(parents=True, exist_ok=True)
149
+ cached_md = (self.root / asset_filename(digest, ".md")).resolve()
150
+
151
+ cached_md_path: Path | None = None
152
+ if block is not None and block.content is not None:
153
+ try:
154
+ cached_md.write_text(block.content, encoding="utf-8")
155
+ cached_md_path = cached_md
156
+ except OSError:
157
+ cached_md_path = None
158
+
159
+ attributes: dict[str, Any] | None = None
160
+ if block is not None:
161
+ attributes = {
162
+ "caption": block.caption,
163
+ "label": block.label,
164
+ "figure_width": block.figure_width,
165
+ "layout": block.layout,
166
+ "overrides": block.template_overrides,
167
+ "cwd": str(block.cwd) if block.cwd else None,
168
+ }
169
+
170
+ linked_files: dict[str, str] = {
171
+ "pdf": cached_pdf.name,
172
+ "png": cached_png.name,
173
+ }
174
+ if cached_md_path is not None:
175
+ linked_files["md"] = cached_md_path.name
176
+
177
+ source_hint = str(source_path) if source_path is not None else None
178
+
179
+ try:
180
+ source_pdf = Path(pdf_path).resolve()
181
+ source_png = Path(png_path).resolve()
182
+ if source_pdf != cached_pdf:
183
+ shutil.copy2(source_pdf, cached_pdf)
184
+ if source_png != cached_png:
185
+ shutil.copy2(source_png, cached_png)
186
+ except OSError:
187
+ return
188
+
189
+ entries[digest] = {
190
+ "signature": digest,
191
+ "pdf": cached_pdf.name,
192
+ "png": cached_png.name,
193
+ "template_version": template_version,
194
+ "files": linked_files,
195
+ "attributes": attributes,
196
+ "source": source_hint,
197
+ }
198
+ self.dirty = True
199
+
200
+ def discard(self, digest: str) -> None:
201
+ """Remove a cache entry when it becomes invalid."""
202
+ entries = self._entries()
203
+ if digest in entries:
204
+ entries.pop(digest, None)
205
+ self.dirty = True
206
+
207
+ def flush(self) -> None:
208
+ """Persist metadata to disk when modified."""
209
+ if not self.dirty:
210
+ return
211
+
212
+ payload = {
213
+ "version": _SNIPPET_CACHE_VERSION,
214
+ "entries": self._entries(),
215
+ }
216
+ tmp_path = self.metadata_path.with_suffix(".tmp")
217
+ try:
218
+ tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
219
+ tmp_path.replace(self.metadata_path)
220
+ self.dirty = False
221
+ except OSError:
222
+ return
223
+
224
+ def _entries(self) -> dict[str, Any]:
225
+ entries = self.metadata.setdefault("entries", {})
226
+ if not isinstance(entries, dict):
227
+ entries = {}
228
+ self.metadata["entries"] = entries
229
+ return entries
230
+
231
+
232
+ def _resolve_runtime() -> TemplateRuntime:
233
+ global _SNIPPET_RUNTIME
234
+ if _SNIPPET_RUNTIME is None:
235
+ _SNIPPET_RUNTIME = load_template_runtime("snippet")
236
+ return _SNIPPET_RUNTIME
237
+
238
+
239
+ def _resolve_cache_root() -> Path | None:
240
+ try:
241
+ return get_user_dir().cache_dir(_SNIPPET_CACHE_NAMESPACE)
242
+ except OSError:
243
+ return None
244
+
245
+
246
+ def _load_cache_metadata(path: Path) -> dict[str, Any]:
247
+ default = {"version": _SNIPPET_CACHE_VERSION, "entries": {}}
248
+ try:
249
+ raw = path.read_text(encoding="utf-8")
250
+ except FileNotFoundError:
251
+ return default
252
+ except OSError:
253
+ return default
254
+
255
+ try:
256
+ payload = json.loads(raw)
257
+ except json.JSONDecodeError:
258
+ return default
259
+
260
+ if payload.get("version") != _SNIPPET_CACHE_VERSION:
261
+ return default
262
+
263
+ entries = payload.get("entries")
264
+ if not isinstance(entries, dict):
265
+ payload["entries"] = {}
266
+ return payload
267
+
268
+
269
+ def _resolve_cache() -> _SnippetCache | None:
270
+ global _SNIPPET_CACHE
271
+ if _SNIPPET_CACHE is not None:
272
+ return _SNIPPET_CACHE
273
+
274
+ root = _resolve_cache_root()
275
+ if root is None:
276
+ return None
277
+
278
+ metadata_path = root / _SNIPPET_CACHE_FILENAME
279
+ metadata = _load_cache_metadata(metadata_path)
280
+ _SNIPPET_CACHE = _SnippetCache(root=root, metadata_path=metadata_path, metadata=metadata)
281
+ return _SNIPPET_CACHE
282
+
283
+
284
+ def _resolve_caches() -> list[_SnippetCache]:
285
+ """Return the user-level cache only."""
286
+ cache = _resolve_cache()
287
+ return [cache] if cache is not None else []
288
+
289
+
290
+ def _resolve_bibliography_files(
291
+ files: list[str],
292
+ cwd: str | Path | None,
293
+ host_path: Path | None,
294
+ ) -> list[Path]:
295
+ """Resolve bibliography file paths relative to the snippet host document."""
296
+ if not files:
297
+ return []
298
+
299
+ base_dir: Path | None = None
300
+ cwd_path = Path(cwd).expanduser() if cwd else None
301
+ if cwd_path is not None:
302
+ if cwd_path.is_absolute():
303
+ base_dir = cwd_path
304
+ elif host_path is not None:
305
+ try:
306
+ base_dir = (host_path.parent / cwd_path).resolve()
307
+ except OSError:
308
+ base_dir = host_path.parent
309
+ else:
310
+ try:
311
+ base_dir = cwd_path.resolve()
312
+ except OSError:
313
+ base_dir = None
314
+ elif host_path is not None:
315
+ base_dir = host_path.parent
316
+
317
+ resolved: list[Path] = []
318
+ for entry in files:
319
+ candidate = Path(entry)
320
+ if not candidate.is_absolute():
321
+ if base_dir is None:
322
+ try:
323
+ base_dir = Path.cwd()
324
+ except OSError:
325
+ base_dir = None
326
+ if base_dir is not None:
327
+ candidate = base_dir / candidate
328
+ with contextlib.suppress(OSError):
329
+ candidate = candidate.resolve()
330
+ resolved.append(candidate)
331
+ return resolved
332
+
333
+
334
+ def _resolve_host_path(document_path: Path | str | None, source_dir: Path | None) -> Path | None:
335
+ """Normalise the host document path to an absolute path when possible."""
336
+ if document_path is None:
337
+ return None
338
+
339
+ try:
340
+ candidate = Path(document_path)
341
+ except TypeError:
342
+ return None
343
+
344
+ if candidate.is_absolute():
345
+ return candidate
346
+
347
+ if source_dir is not None:
348
+ try:
349
+ return (Path(source_dir) / candidate).resolve()
350
+ except OSError:
351
+ return Path(source_dir) / candidate
352
+
353
+ try:
354
+ return candidate.resolve()
355
+ except OSError:
356
+ return candidate
357
+
358
+
359
+ def _resolve_base_dir(block: SnippetBlock, host_path: Path | None) -> Path:
360
+ """Resolve the effective base directory for snippet assets."""
361
+ if block.cwd:
362
+ base = block.cwd
363
+ if not base.is_absolute() and host_path is not None:
364
+ try:
365
+ return (host_path.parent / base).resolve()
366
+ except OSError:
367
+ return host_path.parent
368
+ try:
369
+ return base.resolve()
370
+ except OSError:
371
+ pass
372
+
373
+ if host_path is not None:
374
+ return host_path.parent
375
+
376
+ try:
377
+ return Path.cwd()
378
+ except OSError:
379
+ return Path()
380
+
381
+
382
+ def _resolve_template_runtime(
383
+ block: SnippetBlock, documents: list[Document], base_dir: Path
384
+ ) -> TemplateRuntime:
385
+ """Select the template runtime from front matter when provided."""
386
+ template_id: str | None = block.template_id
387
+
388
+ def _template_from_front_matter(front_matter: Mapping[str, Any]) -> str | None:
389
+ payload = dict(front_matter)
390
+ with contextlib.suppress(PressMetadataError):
391
+ normalise_press_metadata(payload)
392
+ raw = payload.get("template")
393
+ if raw:
394
+ return str(raw).strip()
395
+ press_section = payload.get("press")
396
+ if isinstance(press_section, Mapping):
397
+ inner = press_section.get("template")
398
+ if inner:
399
+ return str(inner).strip()
400
+ return None
401
+
402
+ for document in documents:
403
+ front_matter = document.front_matter
404
+ if not isinstance(front_matter, Mapping):
405
+ continue
406
+ candidate = _template_from_front_matter(front_matter)
407
+ if candidate:
408
+ template_id = candidate
409
+ break
410
+
411
+ if not template_id:
412
+ return _resolve_runtime()
413
+
414
+ # Try resolving relative to the snippet cwd / host directory first.
415
+ candidates: list[str] = []
416
+ tpl_path = Path(template_id)
417
+ if not tpl_path.is_absolute():
418
+ candidates.append(str((base_dir / tpl_path).resolve()))
419
+ candidates.append(template_id)
420
+
421
+ last_exc: Exception | None = None
422
+ for candidate in candidates:
423
+ try:
424
+ return load_template_runtime(candidate)
425
+ except TemplateError as exc:
426
+ last_exc = exc
427
+ continue
428
+
429
+ if last_exc is not None:
430
+ raise last_exc
431
+
432
+ return _resolve_runtime()
433
+
434
+
435
+ def _snippet_template_version() -> str | None:
436
+ runtime = _resolve_runtime()
437
+ info = getattr(runtime.instance, "info", None)
438
+ if info is None:
439
+ return None
440
+ version = getattr(info, "version", None)
441
+ return str(version) if version is not None else None
442
+
443
+
444
+ def _resolve_emitter(context: RenderContext) -> DiagnosticEmitter | None:
445
+ emitter = context.runtime.get("emitter")
446
+ if isinstance(emitter, DiagnosticEmitter):
447
+ return emitter
448
+ return None
449
+
450
+
451
+ def asset_filename(digest: str, suffix: str) -> str:
452
+ """Return the deterministic filename for a snippet artefact."""
453
+ return f"{_SNIPPET_PREFIX}{digest}{suffix}"
454
+
455
+
456
+ def _hash_payload(
457
+ content: str,
458
+ overrides: Mapping[str, Any],
459
+ *,
460
+ cwd: str | None = None,
461
+ template_id: str | None = None,
462
+ layout: tuple[int, int] | None = None,
463
+ sources: list[Path] | None = None,
464
+ promote_title: bool = True,
465
+ drop_title: bool = False,
466
+ suppress_title: bool = False,
467
+ transparent_corner: bool = False,
468
+ fold_size: float | None = None,
469
+ ) -> str:
470
+ def _hash_file(path: Path) -> str:
471
+ try:
472
+ data = path.read_bytes()
473
+ except OSError:
474
+ return ""
475
+ return hashlib.sha256(data).hexdigest()
476
+
477
+ def _normalise(value: Any) -> Any:
478
+ if isinstance(value, Path):
479
+ return str(value)
480
+ if isinstance(value, Mapping):
481
+ return {str(k): _normalise(v) for k, v in value.items()}
482
+ if isinstance(value, (list, tuple)):
483
+ return [_normalise(item) for item in value]
484
+ return value
485
+
486
+ payload = {
487
+ "content": content,
488
+ "overrides": _normalise(dict(overrides)),
489
+ "cwd": cwd,
490
+ "template": template_id,
491
+ "layout": layout,
492
+ "sources": [
493
+ {"path": str(path), "sha256": _hash_file(path)} for path in list(sources or [])
494
+ ],
495
+ "promote_title": promote_title,
496
+ "drop_title": drop_title,
497
+ "suppress_title": suppress_title,
498
+ "transparent_corner": transparent_corner,
499
+ "fold_size": fold_size,
500
+ }
501
+ encoded = json.dumps(payload, sort_keys=True).encode("utf-8")
502
+ return hashlib.sha256(encoded).hexdigest()
503
+
504
+
505
+ def _coerce_bool_option(value: Any, default: bool) -> bool:
506
+ if value is None:
507
+ return default
508
+ if isinstance(value, bool):
509
+ return value
510
+ if isinstance(value, (int, float)):
511
+ return bool(value)
512
+ if isinstance(value, str):
513
+ token = value.strip().lower()
514
+ if token in _TRUE_VALUES:
515
+ return True
516
+ if token in _FALSE_VALUES:
517
+ return False
518
+ return default
519
+
520
+
521
+ def _detect_language(element: Tag) -> str | None:
522
+ """Return the declared language from the fenced block classes."""
523
+ candidates = [element, element.find("pre"), element.find("code")]
524
+ for candidate in candidates:
525
+ if candidate is None:
526
+ continue
527
+ for cls in gather_classes(candidate.get("class")):
528
+ if cls.startswith("language-"):
529
+ return cls[len("language-") :]
530
+ if cls in {"yaml", "yml", "md", "markdown"}:
531
+ return cls
532
+ return None
533
+
534
+
535
+ def _frame_dogear_enabled(overrides: Mapping[str, Any]) -> bool:
536
+ """Detect whether the page frame dogear should be shown for a snippet preview."""
537
+
538
+ def _coerce_frame(value: Any) -> tuple[bool, bool]:
539
+ if value is None:
540
+ return False, False
541
+ if isinstance(value, Mapping):
542
+ enabled = _coerce_bool_option(value.get("enabled"), True)
543
+ mode = value.get("mode")
544
+ dogear = _coerce_bool_option(value.get("dogear"), True)
545
+ if isinstance(mode, str):
546
+ token = mode.strip().lower()
547
+ if token == "border":
548
+ dogear = False
549
+ enabled = True
550
+ elif token in {"dogear", "fold"}:
551
+ dogear = True
552
+ enabled = True
553
+ if not enabled:
554
+ return False, False
555
+ return True, dogear
556
+ if isinstance(value, (bool, int, float)):
557
+ flag = bool(value)
558
+ return flag, flag
559
+ if isinstance(value, str):
560
+ token = value.strip().lower()
561
+ if not token or token in {"false", "off", "no", "0", "none"}:
562
+ return False, False
563
+ if token == "border":
564
+ return True, False
565
+ if token in {"dogear", "true", "yes", "on", "1"}:
566
+ return True, True
567
+ return False, False
568
+
569
+ press_section = overrides.get("press") if isinstance(overrides, Mapping) else None
570
+ frame_value = overrides.get("frame")
571
+ if frame_value is None and isinstance(press_section, Mapping):
572
+ frame_value = press_section.get("frame")
573
+ enabled, dogear = _coerce_frame(frame_value)
574
+ if enabled and dogear:
575
+ return True
576
+
577
+ fragments_value = overrides.get("fragments")
578
+ if fragments_value is None and isinstance(press_section, Mapping):
579
+ fragments_value = press_section.get("fragments")
580
+ return isinstance(fragments_value, list) and any(str(f) == "ts-frame" for f in fragments_value)
581
+
582
+
583
+ def _frame_fold_size_px(
584
+ overrides: Mapping[str, Any], image_size: tuple[int, int], *, default_mm: float = 10.0
585
+ ) -> int:
586
+ """Resolve the frame fold size (in pixels) from press.frame settings."""
587
+
588
+ def _parse_length(value: str | None, fallback_mm: float) -> float:
589
+ if not value:
590
+ return fallback_mm
591
+ raw = str(value).strip().lower()
592
+ if not raw:
593
+ return fallback_mm
594
+ unit = "mm"
595
+ numeric = raw
596
+ for candidate in ("mm", "cm", "in", "pt"):
597
+ if raw.endswith(candidate):
598
+ unit = candidate
599
+ numeric = raw[: -len(candidate)]
600
+ break
601
+ try:
602
+ mag = float(numeric)
603
+ except ValueError:
604
+ return fallback_mm
605
+ if unit == "cm":
606
+ mag *= 10.0
607
+ elif unit == "in":
608
+ mag *= 25.4
609
+ elif unit == "pt":
610
+ mag *= 25.4 / 72.27
611
+ return max(mag, 0.0)
612
+
613
+ press_section = overrides.get("press") if isinstance(overrides, Mapping) else None
614
+ frame_value = overrides.get("frame")
615
+ if frame_value is None and isinstance(press_section, Mapping):
616
+ frame_value = press_section.get("frame")
617
+
618
+ fold_spec: str | None = None
619
+ if isinstance(frame_value, Mapping):
620
+ fold_spec = (
621
+ frame_value.get("fold-size")
622
+ or frame_value.get("fold_size")
623
+ or frame_value.get("fold")
624
+ or frame_value.get("foldsize")
625
+ )
626
+ elif isinstance(frame_value, (int, float, str)):
627
+ # When a bare value is supplied, respect it as fold size if it looks like a length.
628
+ token = str(frame_value).strip().lower()
629
+ if any(ch.isdigit() for ch in token):
630
+ fold_spec = token
631
+
632
+ fold_mm = _parse_length(fold_spec, default_mm)
633
+ px_per_mm = 220.0 / 25.4 # match DPI used in _pdf_to_png_grid
634
+ estimated_px = round(fold_mm * px_per_mm)
635
+ width, height = image_size
636
+ max_dim = max(min(width, height) * 0.2, 24)
637
+ return int(max(12, min(estimated_px, max_dim)))
638
+
639
+
640
+ def _load_yaml_mapping(payload: str) -> dict[str, Any]:
641
+ """Parse a YAML string into a mapping, enforcing a dictionary output."""
642
+ if not payload.strip():
643
+ return {}
644
+ try:
645
+ loaded = yaml.safe_load(payload)
646
+ except yaml.YAMLError as exc:
647
+ raise InvalidNodeError(f"Invalid YAML snippet payload: {exc}") from exc
648
+ if loaded is None:
649
+ return {}
650
+ if not isinstance(loaded, Mapping):
651
+ raise InvalidNodeError("Snippet configuration must be a YAML mapping.")
652
+ return dict(loaded)
653
+
654
+
655
+ def _resolve_layout_value(value: Any) -> tuple[int, int] | None:
656
+ if isinstance(value, (list, tuple)) and len(value) == 2:
657
+ try:
658
+ return (int(value[0]), int(value[1]))
659
+ except (ValueError, TypeError):
660
+ return None
661
+ if isinstance(value, str):
662
+ return _parse_layout(value)
663
+ return None
664
+
665
+
666
+ def _resolve_base_dir_value(value: str | Path | None, host_path: Path | None) -> Path | None:
667
+ if value is None:
668
+ return host_path.parent if host_path is not None else None
669
+ candidate = Path(value).expanduser()
670
+ if candidate.is_absolute():
671
+ return candidate
672
+ if host_path is not None:
673
+ try:
674
+ return (host_path.parent / candidate).resolve()
675
+ except OSError:
676
+ return host_path.parent / candidate
677
+ try:
678
+ return candidate.resolve()
679
+ except OSError:
680
+ return candidate
681
+
682
+
683
+ def _resolve_sources(raw_sources: Any, base_dir: Path | None) -> list[Path]:
684
+ if raw_sources is None:
685
+ return []
686
+ if not isinstance(raw_sources, list):
687
+ raise InvalidNodeError("The 'sources' field must be a list of paths.")
688
+
689
+ resolved: list[Path] = []
690
+ for entry in raw_sources:
691
+ if entry is None:
692
+ continue
693
+ candidate = Path(str(entry))
694
+ if not candidate.is_absolute():
695
+ if base_dir is None:
696
+ raise InvalidNodeError("Relative snippet sources require a host document path.")
697
+ candidate = base_dir / candidate
698
+ with contextlib.suppress(OSError):
699
+ candidate = candidate.resolve()
700
+ if not candidate.exists():
701
+ raise InvalidNodeError(f"Snippet source '{candidate}' does not exist.")
702
+ resolved.append(candidate)
703
+ return resolved
704
+
705
+
706
+ def _merge_press_section(overrides: dict[str, Any], fragments: Any) -> None:
707
+ if fragments is None:
708
+ return
709
+ press_section = overrides.setdefault("press", {})
710
+ if not isinstance(press_section, dict):
711
+ overrides["press"] = {"fragments": fragments}
712
+ return
713
+ existing = press_section.get("fragments")
714
+ if existing is None:
715
+ press_section["fragments"] = fragments
716
+ elif isinstance(existing, dict) and isinstance(fragments, Mapping):
717
+ press_section["fragments"] = {**existing, **dict(fragments)}
718
+ elif isinstance(existing, list) and isinstance(fragments, list):
719
+ press_section["fragments"] = [*existing, *fragments]
720
+ else:
721
+ press_section["fragments"] = fragments
722
+
723
+
724
+ def _extract_snippet_block(element: Tag, host_path: Path | None = None) -> SnippetBlock | None:
725
+ pre_element = element.find("pre")
726
+ code_element = element.find("code")
727
+
728
+ classes = set(gather_classes(element.get("class")))
729
+ classes.update(gather_classes(pre_element.get("class") if pre_element else None))
730
+ classes.update(gather_classes(code_element.get("class") if code_element else None))
731
+ if "snippet" not in classes:
732
+ return None
733
+
734
+ if code_element is None:
735
+ raise InvalidNodeError("Snippet block is missing an inner <code> element.")
736
+ raw_content = code_element.get_text(strip=False)
737
+
738
+ def _attr(name: str) -> Any:
739
+ for candidate in (element, pre_element, code_element):
740
+ if candidate is None:
741
+ continue
742
+ for key in (name, f"data-{name}"):
743
+ value = candidate.get(key)
744
+ if value is not None:
745
+ return value
746
+ return None
747
+
748
+ def _meta_attrs() -> dict[str, str]:
749
+ raw_meta = _attr("meta") or _attr("data-meta")
750
+ if not raw_meta or not isinstance(raw_meta, str):
751
+ return {}
752
+ result: dict[str, str] = {}
753
+ import shlex
754
+
755
+ try:
756
+ tokens = shlex.split(raw_meta)
757
+ except ValueError:
758
+ tokens = raw_meta.split()
759
+ for token in tokens:
760
+ if "=" not in token:
761
+ continue
762
+ key, val = token.split("=", 1)
763
+ result[key.strip()] = val.strip().strip('"').strip("'")
764
+ return result
765
+
766
+ caption = coerce_attribute(_attr("caption")) or None
767
+ label = coerce_attribute(_attr("label")) or None
768
+ figure_width = coerce_attribute(_attr("width")) or None
769
+ layout_literal: Any = coerce_attribute(_attr("layout")) or None
770
+ config_literal = coerce_attribute(_attr("config")) or None
771
+
772
+ config_from_file: dict[str, Any] = {}
773
+ if config_literal:
774
+ config_path = Path(config_literal)
775
+ if not config_path.is_absolute():
776
+ if host_path is None:
777
+ raise InvalidNodeError("Relative snippet configuration requires a host document.")
778
+ config_path = (host_path.parent / config_path).resolve()
779
+ try:
780
+ config_payload = config_path.read_text(encoding="utf-8")
781
+ except OSError as exc:
782
+ raise InvalidNodeError(
783
+ f"Unable to read snippet configuration '{config_path}': {exc}"
784
+ ) from exc
785
+ config_from_file = _load_yaml_mapping(config_payload)
786
+
787
+ language = _detect_language(element)
788
+ config_from_body: dict[str, Any] = {}
789
+ inline_content: str | None = None
790
+ if language in {"yaml", "yml"}:
791
+ config_from_body = _load_yaml_mapping(raw_content)
792
+ else:
793
+ metadata, body = split_front_matter(raw_content)
794
+ config_from_body = dict(metadata or {})
795
+ if body.strip():
796
+ inline_content = body
797
+ if not config_from_body and inline_content is not None:
798
+ # Fallback: accept pure-YAML snippet bodies even when the language class is missing.
799
+ try:
800
+ parsed_yaml = _load_yaml_mapping(raw_content)
801
+ except InvalidNodeError:
802
+ parsed_yaml = None
803
+ if isinstance(parsed_yaml, dict) and parsed_yaml:
804
+ config_from_body = parsed_yaml
805
+ inline_content = None
806
+
807
+ merged_config: dict[str, Any] = {**config_from_file, **config_from_body}
808
+ meta_attrs = _meta_attrs()
809
+ figure_width = (
810
+ coerce_attribute(merged_config.pop("width", figure_width))
811
+ or coerce_attribute(meta_attrs.get("width"))
812
+ or figure_width
813
+ )
814
+ caption = coerce_attribute(merged_config.pop("caption", caption)) or caption
815
+ label = coerce_attribute(merged_config.pop("label", label)) or label
816
+ layout_literal = merged_config.pop("layout", layout_literal) or layout_literal
817
+ template_id_raw = merged_config.pop("template", None)
818
+ template_id = coerce_attribute(template_id_raw) or None
819
+ cwd_value = merged_config.pop("cwd", None)
820
+ drop_title_value = _coerce_bool_option(merged_config.pop("drop_title", False), False)
821
+ promote_title_value = _coerce_bool_option(
822
+ merged_config.pop("promote_title", None),
823
+ True,
824
+ )
825
+ suppress_title_value = _coerce_bool_option(
826
+ merged_config.pop("suppress_title_metadata", merged_config.pop("suppress_title", None)),
827
+ False,
828
+ )
829
+
830
+ base_dir = _resolve_base_dir_value(cwd_value, host_path)
831
+ sources = _resolve_sources(merged_config.pop("sources", None), base_dir)
832
+ bibliography_files = [
833
+ path for path in sources if path.suffix.lower() in {".bib", ".bibtex", ".ris"}
834
+ ]
835
+ if inline_content is None and not sources:
836
+ return None
837
+
838
+ fragments = merged_config.pop("fragments", None)
839
+ press_overrides = merged_config.pop("press", None)
840
+ template_overrides: dict[str, Any] = dict(merged_config)
841
+ if press_overrides is not None:
842
+ if not isinstance(press_overrides, Mapping):
843
+ raise InvalidNodeError("The 'press' section must be a mapping.")
844
+ template_overrides["press"] = dict(press_overrides)
845
+ _merge_press_section(template_overrides, fragments)
846
+
847
+ layout = _resolve_layout_value(layout_literal)
848
+ preview_dogear = _frame_dogear_enabled(template_overrides)
849
+ preview_fold_size = None
850
+ if preview_dogear:
851
+ preview_fold_size = _frame_fold_size_px(template_overrides, (0, 0))
852
+ if inline_content is not None and not sources and template_id is None:
853
+ template_id = "snippet"
854
+ preview_dogear = True
855
+ elif inline_content is None and sources and template_id is None:
856
+ template_id = "article"
857
+ digest = _hash_payload(
858
+ inline_content or "",
859
+ template_overrides,
860
+ cwd=str(base_dir) if base_dir else None,
861
+ template_id=template_id,
862
+ layout=layout,
863
+ sources=sources,
864
+ promote_title=promote_title_value,
865
+ drop_title=drop_title_value,
866
+ suppress_title=suppress_title_value,
867
+ transparent_corner=preview_dogear,
868
+ fold_size=preview_fold_size,
869
+ )
870
+
871
+ return SnippetBlock(
872
+ content=inline_content,
873
+ sources=sources,
874
+ layout=layout,
875
+ preview_dogear=preview_dogear,
876
+ preview_fold_size=preview_fold_size,
877
+ template_id=template_id,
878
+ cwd=base_dir,
879
+ caption=caption,
880
+ label=label,
881
+ figure_width=figure_width,
882
+ template_overrides=template_overrides,
883
+ digest=digest,
884
+ bibliography_files=bibliography_files,
885
+ promote_title=promote_title_value,
886
+ drop_title=drop_title_value,
887
+ suppress_title_metadata=suppress_title_value,
888
+ )
889
+
890
+
891
+ def _build_document(
892
+ block: SnippetBlock,
893
+ *,
894
+ host_dir: Path,
895
+ host_name: str,
896
+ ) -> Document | None:
897
+ if not block.content:
898
+ return None
899
+ return _build_document_from_markup(
900
+ block.content,
901
+ host_dir / f"{host_name}-{block.asset_basename}.md",
902
+ base_dir=host_dir,
903
+ promote_title=block.promote_title,
904
+ drop_title=block.drop_title,
905
+ suppress_title=block.suppress_title_metadata,
906
+ )
907
+
908
+
909
+ def _build_document_from_markup(
910
+ content: str,
911
+ source_path: Path,
912
+ *,
913
+ base_dir: Path,
914
+ promote_title: bool,
915
+ drop_title: bool,
916
+ suppress_title: bool,
917
+ ) -> Document:
918
+ rendered = render_markdown(
919
+ content,
920
+ extensions=list(DEFAULT_MARKDOWN_EXTENSIONS),
921
+ base_path=base_dir,
922
+ )
923
+ title_strategy = _resolve_title_strategy(
924
+ explicit=TitleStrategy.DROP if drop_title else None,
925
+ promote_title=promote_title,
926
+ strip_heading=drop_title,
927
+ has_declared_title=front_matter_has_title(rendered.front_matter),
928
+ )
929
+ options = DocumentRenderOptions(
930
+ base_level=0,
931
+ title_strategy=title_strategy,
932
+ numbered=False,
933
+ suppress_title_metadata=suppress_title,
934
+ )
935
+ document = Document(
936
+ source_path=source_path,
937
+ kind=InputKind.MARKDOWN,
938
+ _html=rendered.html,
939
+ _front_matter=rendered.front_matter,
940
+ options=options,
941
+ slots=DocumentSlots(),
942
+ )
943
+ document._initialise_slots_from_front_matter() # noqa: SLF001
944
+ return document
945
+
946
+
947
+ def _build_document_from_yaml(
948
+ content: str,
949
+ source_path: Path,
950
+ *,
951
+ promote_title: bool,
952
+ drop_title: bool,
953
+ suppress_title: bool,
954
+ ) -> Document:
955
+ """Create a Document using YAML front matter only (no body)."""
956
+ docs: list[Mapping[str, Any]] = []
957
+ try:
958
+ for doc in yaml.safe_load_all(content):
959
+ if isinstance(doc, Mapping):
960
+ docs.append(doc)
961
+ except yaml.YAMLError as exc: # pragma: no cover - defensive
962
+ raise InvalidNodeError(f"Invalid YAML snippet source '{source_path}': {exc}") from exc
963
+
964
+ payload: Mapping[str, Any] = docs[0] if docs else {}
965
+ if not isinstance(payload, Mapping):
966
+ raise InvalidNodeError(
967
+ f"Snippet source '{source_path}' must contain a YAML mapping, got {type(payload)}."
968
+ )
969
+ title_strategy = _resolve_title_strategy(
970
+ explicit=TitleStrategy.DROP if drop_title else None,
971
+ promote_title=promote_title,
972
+ strip_heading=drop_title,
973
+ has_declared_title=front_matter_has_title(payload),
974
+ )
975
+ options = DocumentRenderOptions(
976
+ base_level=0,
977
+ title_strategy=title_strategy,
978
+ numbered=False,
979
+ suppress_title_metadata=suppress_title,
980
+ )
981
+ document = Document(
982
+ source_path=source_path,
983
+ kind=InputKind.MARKDOWN,
984
+ _html="",
985
+ _front_matter=dict(payload),
986
+ options=options,
987
+ slots=DocumentSlots(),
988
+ )
989
+ document._initialise_slots_from_front_matter() # noqa: SLF001
990
+ return document
991
+
992
+
993
+ def _build_documents_from_sources(
994
+ sources: list[Path],
995
+ *,
996
+ promote_title: bool,
997
+ drop_title: bool,
998
+ suppress_title: bool,
999
+ ) -> list[Document]:
1000
+ documents: list[Document] = []
1001
+ for path in sources:
1002
+ suffix = path.suffix.lower()
1003
+ if suffix in {".md", ".markdown", ".mkd"}:
1004
+ documents.append(
1005
+ Document.from_markdown(
1006
+ path,
1007
+ base_level=0,
1008
+ title_strategy=None,
1009
+ promote_title=promote_title,
1010
+ strip_heading=drop_title,
1011
+ suppress_title=suppress_title,
1012
+ numbered=False,
1013
+ )
1014
+ )
1015
+ continue
1016
+ if suffix in {".yml", ".yaml"}:
1017
+ try:
1018
+ yaml_content = path.read_text(encoding="utf-8")
1019
+ except OSError as exc:
1020
+ raise InvalidNodeError(f"Unable to read snippet source '{path}': {exc}") from exc
1021
+ documents.append(
1022
+ _build_document_from_yaml(
1023
+ yaml_content,
1024
+ path,
1025
+ promote_title=promote_title,
1026
+ drop_title=drop_title,
1027
+ suppress_title=suppress_title,
1028
+ )
1029
+ )
1030
+ continue
1031
+ raise InvalidNodeError(
1032
+ f"Unsupported snippet source '{path}'. Only Markdown or YAML inputs are allowed."
1033
+ )
1034
+ return documents
1035
+
1036
+
1037
+ def _compile_pdf(render_result: Any) -> Path:
1038
+ from texsmith.adapters.latex.engines import (
1039
+ EngineResult,
1040
+ build_engine_command,
1041
+ build_tex_env,
1042
+ compute_features,
1043
+ ensure_command_paths,
1044
+ missing_dependencies,
1045
+ parse_latex_log,
1046
+ resolve_engine,
1047
+ run_engine_command,
1048
+ )
1049
+ from texsmith.adapters.latex.pyxindy import is_available as pyxindy_available
1050
+ from texsmith.adapters.latex.tectonic import (
1051
+ BiberAcquisitionError,
1052
+ MakeglossariesAcquisitionError,
1053
+ TectonicAcquisitionError,
1054
+ select_biber_binary,
1055
+ select_makeglossaries,
1056
+ select_tectonic_binary,
1057
+ )
1058
+
1059
+ engine_choice = resolve_engine("tectonic", render_result.template_engine)
1060
+ template_context = getattr(render_result, "template_context", None) or getattr(
1061
+ render_result, "context", None
1062
+ )
1063
+ features = compute_features(
1064
+ requires_shell_escape=render_result.requires_shell_escape,
1065
+ bibliography=render_result.has_bibliography,
1066
+ document_state=render_result.document_state,
1067
+ template_context=template_context,
1068
+ )
1069
+ biber_binary: Path | None = None
1070
+ makeglossaries_binary: Path | None = None
1071
+ bundled_bin: Path | None = None
1072
+ try:
1073
+ selection = select_tectonic_binary(False, console=None)
1074
+ if features.bibliography:
1075
+ biber_binary = select_biber_binary(console=None)
1076
+ bundled_bin = biber_binary.parent
1077
+ if features.has_glossary and not pyxindy_available():
1078
+ glossaries = select_makeglossaries(console=None)
1079
+ makeglossaries_binary = glossaries.path
1080
+ if glossaries.source == "bundled":
1081
+ bundled_bin = bundled_bin or glossaries.path.parent
1082
+ except (TectonicAcquisitionError, BiberAcquisitionError, MakeglossariesAcquisitionError) as exc:
1083
+ raise AssetMissingError(str(exc)) from exc
1084
+ tectonic_binary = selection.path
1085
+
1086
+ available_bins: dict[str, Path] = {}
1087
+ if biber_binary:
1088
+ available_bins["biber"] = biber_binary
1089
+ if makeglossaries_binary:
1090
+ available_bins["makeglossaries"] = makeglossaries_binary
1091
+
1092
+ missing = missing_dependencies(
1093
+ engine_choice,
1094
+ features,
1095
+ use_system_tectonic=False,
1096
+ available_binaries=available_bins or None,
1097
+ )
1098
+ if missing:
1099
+ formatted = ", ".join(sorted(missing))
1100
+ raise AssetMissingError(f"Missing LaTeX tools for snippet rendering: {formatted}")
1101
+
1102
+ command_plan = ensure_command_paths(
1103
+ build_engine_command(
1104
+ engine_choice,
1105
+ features,
1106
+ main_tex_path=render_result.main_tex_path,
1107
+ tectonic_binary=tectonic_binary,
1108
+ )
1109
+ )
1110
+ env = build_tex_env(
1111
+ render_result.main_tex_path.parent,
1112
+ isolate_cache=True,
1113
+ extra_path=bundled_bin,
1114
+ biber_path=biber_binary,
1115
+ )
1116
+ result: EngineResult = run_engine_command(
1117
+ command_plan,
1118
+ backend=engine_choice.backend,
1119
+ workdir=render_result.main_tex_path.parent,
1120
+ env=env,
1121
+ console=None,
1122
+ classic_output=True,
1123
+ features=features,
1124
+ )
1125
+ if result.returncode != 0:
1126
+ log_path = command_plan.log_path
1127
+ messages = result.messages or parse_latex_log(log_path)
1128
+ detail = messages[0].summary if messages else f"{engine_choice.label} failed"
1129
+ raise LatexRenderingError(f"Failed to compile snippet: {detail} (log: {log_path})")
1130
+
1131
+ return command_plan.pdf_path
1132
+
1133
+
1134
+ def _load_pymupdf() -> object:
1135
+ try:
1136
+ import pymupdf as fitz # type: ignore[import-not-found]
1137
+ except ModuleNotFoundError:
1138
+ try:
1139
+ import fitz # type: ignore[import-not-found]
1140
+ except ModuleNotFoundError as exc: # pragma: no cover - optional dependency
1141
+ raise AssetMissingError(
1142
+ "PyMuPDF is required to generate snippet previews. Install the 'pymupdf' package."
1143
+ ) from exc
1144
+
1145
+ if not hasattr(fitz, "open"): # type: ignore[attr-defined]
1146
+ raise AssetMissingError(
1147
+ "A conflicting 'fitz' package is installed. Remove it and install 'pymupdf' instead."
1148
+ )
1149
+
1150
+ return fitz # type: ignore[return-value]
1151
+
1152
+
1153
+ def _pdf_to_png(pdf_path: Path, png_path: Path, *, transparent_corner: bool = False) -> None:
1154
+ fitz = _load_pymupdf()
1155
+
1156
+ with fitz.open(pdf_path) as document:
1157
+ if document.page_count == 0:
1158
+ raise LatexRenderingError(f"Snippet PDF '{pdf_path}' did not produce any pages.")
1159
+ page = document.load_page(0)
1160
+ pixmap = page.get_pixmap(dpi=220)
1161
+
1162
+ mode = "RGBA" if pixmap.alpha else "RGB"
1163
+ image = Image.frombytes(mode, (pixmap.width, pixmap.height), pixmap.samples)
1164
+ if transparent_corner:
1165
+ image = _apply_dogear_transparency(image)
1166
+ image.save(png_path)
1167
+
1168
+
1169
+ def _pdf_to_png_grid(
1170
+ pdf_path: Path,
1171
+ png_path: Path,
1172
+ *,
1173
+ layout: tuple[int, int] | None = None,
1174
+ transparent_corner: bool = False,
1175
+ spacing: int | None = None,
1176
+ decorate_page: Callable[[Image.Image], Image.Image] | None = None,
1177
+ fold_size: int | None = None,
1178
+ ) -> None:
1179
+ fitz = _load_pymupdf()
1180
+
1181
+ with fitz.open(pdf_path) as document:
1182
+ page_count = document.page_count
1183
+ if page_count == 0:
1184
+ raise LatexRenderingError(f"Snippet PDF '{pdf_path}' did not produce any pages.")
1185
+
1186
+ cols, rows = (1, 1)
1187
+ if layout:
1188
+ c, r = layout
1189
+ if c > 0:
1190
+ cols = c
1191
+ if r > 0:
1192
+ rows = r
1193
+ pages_needed = cols * rows
1194
+ if pages_needed <= 0:
1195
+ cols = 1
1196
+ rows = 1
1197
+ pages_needed = 1
1198
+ use_pages = min(page_count, pages_needed)
1199
+
1200
+ images: list[Image.Image] = []
1201
+ for index in range(use_pages):
1202
+ page = document.load_page(index)
1203
+ pixmap = page.get_pixmap(dpi=220)
1204
+ mode = "RGBA" if pixmap.alpha else "RGB"
1205
+ images.append(Image.frombytes(mode, (pixmap.width, pixmap.height), pixmap.samples))
1206
+
1207
+ if not images:
1208
+ raise LatexRenderingError(f"Snippet PDF '{pdf_path}' did not produce any pages.")
1209
+
1210
+ page_w, page_h = images[0].size
1211
+ inferred_spacing = 0
1212
+ if spacing is None and cols * rows > 1:
1213
+ inferred_spacing = max(round(min(page_w, page_h) * 0.04), 18)
1214
+ elif spacing:
1215
+ inferred_spacing = max(spacing, 0)
1216
+
1217
+ total_w = cols * page_w + inferred_spacing * (cols - 1)
1218
+ total_h = rows * page_h + inferred_spacing * (rows - 1)
1219
+ canvas = Image.new("RGBA", (total_w, total_h), (255, 255, 255, 0))
1220
+
1221
+ for idx, img in enumerate(images):
1222
+ if transparent_corner:
1223
+ img = _apply_dogear_transparency(img, fold_size=fold_size)
1224
+ if decorate_page is not None:
1225
+ img = decorate_page(img)
1226
+
1227
+ r = idx // cols
1228
+ c = idx % cols
1229
+ x = c * (page_w + inferred_spacing)
1230
+ y = r * (page_h + inferred_spacing)
1231
+ mask = img if "A" in img.getbands() else None
1232
+ canvas.paste(img, (x, y), mask=mask)
1233
+
1234
+ canvas.save(png_path)
1235
+
1236
+
1237
+ def _apply_dogear_transparency(image: Image.Image, *, fold_size: int | None = None) -> Image.Image:
1238
+ rgba = image.convert("RGBA")
1239
+ width, height = rgba.size
1240
+ if width <= 0 or height <= 0:
1241
+ return rgba
1242
+ magenta = (255, 0, 255, 255)
1243
+ if fold_size is not None:
1244
+ _ = fold_size # keep signature meaningful; floodfill is boundary-aware.
1245
+ seed_x = width - 2 if width > 1 else 0
1246
+ seed_y = 1 if height > 1 else 0
1247
+ try:
1248
+ ImageDraw.floodfill(rgba, (seed_x, seed_y), magenta, thresh=8)
1249
+ except Exception:
1250
+ return rgba
1251
+
1252
+ pixels = []
1253
+ for r, g, b, a in rgba.getdata():
1254
+ if r == 255 and g == 0 and b == 255:
1255
+ pixels.append((r, g, b, 0))
1256
+ else:
1257
+ pixels.append((r, g, b, a))
1258
+ rgba.putdata(pixels)
1259
+ return rgba
1260
+
1261
+
1262
+ def _parse_layout(value: str | None) -> tuple[int, int] | None:
1263
+ """Parse layout string like '2x2', '3', or '1x3' into (cols, rows)."""
1264
+ if not value:
1265
+ return None
1266
+ raw = value.strip().lower()
1267
+ if not raw:
1268
+ return None
1269
+ if "x" in raw:
1270
+ parts = raw.split("x", 1)
1271
+ try:
1272
+ cols = int(parts[0])
1273
+ rows = int(parts[1])
1274
+ except ValueError:
1275
+ return None
1276
+ return (cols, rows)
1277
+ try:
1278
+ cols = int(raw)
1279
+ except ValueError:
1280
+ return None
1281
+ return (cols, 1)
1282
+
1283
+
1284
+ def _overlay_dogear_frame(
1285
+ image: Image.Image,
1286
+ *,
1287
+ margin: int | None = None,
1288
+ dogear: int | None = None,
1289
+ border_width: int | None = None,
1290
+ border_color: tuple[int, int, int, int] = (0, 0, 0, 255),
1291
+ dogear_enabled: bool = True,
1292
+ ) -> Image.Image:
1293
+ """Draw a frame with a folded corner directly onto the PNG."""
1294
+ base = image.convert("RGBA")
1295
+ width, height = base.size
1296
+ if width <= 0 or height <= 0:
1297
+ return base
1298
+
1299
+ size_hint = min(width, height)
1300
+ m = 0 if margin is None else max(margin, 0)
1301
+ fold = dogear if dogear is not None else max(int(size_hint * 0.07), 18)
1302
+ stroke = border_width if border_width is not None else max(int(size_hint * 0.0025), 1)
1303
+
1304
+ x0, y0 = m, m
1305
+ x1, y1 = width - 1 - m, height - 1 - m
1306
+
1307
+ scale = 6
1308
+ overlay = Image.new("RGBA", (width * scale, height * scale), (0, 0, 0, 0))
1309
+ draw = ImageDraw.Draw(overlay)
1310
+ bw = max(1, stroke * scale)
1311
+
1312
+ def sx(val: float) -> int:
1313
+ return round(val * scale)
1314
+
1315
+ def sy(val: float) -> int:
1316
+ return round(val * scale)
1317
+
1318
+ draw.line([(sx(x0), sy(y0)), (sx(x1 - fold), sy(y0))], fill=border_color, width=bw)
1319
+ draw.line([(sx(x1), sy(y0 + fold)), (sx(x1), sy(y1))], fill=border_color, width=bw)
1320
+ draw.line([(sx(x1), sy(y1)), (sx(x0), sy(y1))], fill=border_color, width=bw)
1321
+ draw.line([(sx(x0), sy(y1)), (sx(x0), sy(y0))], fill=border_color, width=bw)
1322
+
1323
+ mask = Image.new("L", overlay.size, color=255)
1324
+ mask_draw = ImageDraw.Draw(mask)
1325
+
1326
+ if dogear_enabled:
1327
+ corner = (x1 - fold, y0 + fold)
1328
+ bdown = (x1, y0 + fold)
1329
+ bleft = (x1 - fold, y0)
1330
+
1331
+ def bezier_points(
1332
+ p0: tuple[float, float],
1333
+ p1: tuple[float, float],
1334
+ p2: tuple[float, float],
1335
+ p3: tuple[float, float],
1336
+ steps: int = 192,
1337
+ ) -> list[tuple[int, int]]:
1338
+ pts = []
1339
+ for i in range(steps + 1):
1340
+ t = i / steps
1341
+ mt = 1 - t
1342
+ x = (
1343
+ mt * mt * mt * p0[0]
1344
+ + 3 * mt * mt * t * p1[0]
1345
+ + 3 * mt * t * t * p2[0]
1346
+ + t * t * t * p3[0]
1347
+ )
1348
+ y = (
1349
+ mt * mt * mt * p0[1]
1350
+ + 3 * mt * mt * t * p1[1]
1351
+ + 3 * mt * t * t * p2[1]
1352
+ + t * t * t * p3[1]
1353
+ )
1354
+ pts.append((sx(x), sy(y)))
1355
+ return pts
1356
+
1357
+ spline1 = bezier_points(
1358
+ corner,
1359
+ (corner[0] + 0.3 * fold, corner[1] - 0.1 * fold),
1360
+ (bdown[0] - 0.3 * fold, bdown[1] + 0.1 * fold),
1361
+ bdown,
1362
+ )
1363
+ spline2 = bezier_points(
1364
+ bleft,
1365
+ (bleft[0] + 0.1 * fold, bleft[1] - 0.3 * fold),
1366
+ (corner[0] - 0.1 * fold, corner[1] + 0.3 * fold),
1367
+ corner,
1368
+ )
1369
+
1370
+ draw.line(spline1, fill=border_color, width=bw, joint="curve")
1371
+ draw.line(spline2, fill=border_color, width=bw, joint="curve")
1372
+
1373
+ mask_draw.polygon(
1374
+ [
1375
+ (sx(x1 - fold), sy(y0)),
1376
+ (sx(x1 + 1), sy(y0)),
1377
+ (sx(x1 + 1), sy(y0 + fold + 1)),
1378
+ ],
1379
+ fill=0,
1380
+ )
1381
+
1382
+ composited = Image.alpha_composite(base, overlay.resize(base.size, Image.LANCZOS))
1383
+ if dogear_enabled:
1384
+ mask_small = mask.resize(base.size, Image.LANCZOS)
1385
+ composited.putalpha(mask_small)
1386
+ return composited
1387
+
1388
+
1389
+ def ensure_snippet_assets(
1390
+ block: SnippetBlock,
1391
+ *,
1392
+ output_dir: Path,
1393
+ source_path: Path | str | None = None,
1394
+ emitter: DiagnosticEmitter | None = None,
1395
+ ) -> _SnippetAssets:
1396
+ """Render snippet assets into the provided directory when missing."""
1397
+ destination = Path(output_dir).resolve()
1398
+ destination.mkdir(parents=True, exist_ok=True)
1399
+ pdf_path = destination / asset_filename(block.digest, ".pdf")
1400
+ png_path = destination / asset_filename(block.digest, ".png")
1401
+
1402
+ host_path = Path(source_path) if source_path is not None else destination / "snippet.md"
1403
+ host_dir = _resolve_base_dir(block, host_path)
1404
+ host_name = host_path.stem or "snippet"
1405
+
1406
+ documents: list[Document] = []
1407
+ inline_document = _build_document(block, host_dir=host_dir, host_name=host_name)
1408
+ if inline_document is not None:
1409
+ documents.append(inline_document)
1410
+
1411
+ bibliography_files = list(block.bibliography_files)
1412
+ document_sources: list[Path] = []
1413
+ for path in block.sources:
1414
+ suffix = path.suffix.lower()
1415
+ if suffix in {".bib", ".bibtex", ".ris"}:
1416
+ if path not in bibliography_files:
1417
+ bibliography_files.append(path)
1418
+ continue
1419
+ document_sources.append(path)
1420
+
1421
+ if document_sources:
1422
+ documents.extend(
1423
+ _build_documents_from_sources(
1424
+ document_sources,
1425
+ promote_title=block.promote_title,
1426
+ drop_title=block.drop_title,
1427
+ suppress_title=block.suppress_title_metadata,
1428
+ )
1429
+ )
1430
+
1431
+ if not documents:
1432
+ raise InvalidNodeError("Snippet block is empty; provide inline content or sources.")
1433
+
1434
+ runtime = _resolve_template_runtime(block, documents, host_dir)
1435
+ merged_overrides = _merge_fragment_defaults(block.template_overrides, runtime)
1436
+ dogear_enabled = _frame_dogear_enabled(merged_overrides) or block.preview_dogear
1437
+ preview_fold_px: int | None = None
1438
+ if dogear_enabled:
1439
+ preview_fold_px = _frame_fold_size_px(
1440
+ merged_overrides,
1441
+ (0, 0),
1442
+ )
1443
+
1444
+ caches = _resolve_caches()
1445
+ template_version = None
1446
+ if caches:
1447
+ info = getattr(runtime.instance, "info", None)
1448
+ if info is not None:
1449
+ version = getattr(info, "version", None)
1450
+ template_version = str(version) if version is not None else None
1451
+
1452
+ pdf_missing = not pdf_path.exists()
1453
+ png_missing = not png_path.exists()
1454
+ assets = _SnippetAssets(pdf=pdf_path, png=png_path)
1455
+
1456
+ def _flush_caches() -> None:
1457
+ for cache in caches:
1458
+ cache.flush()
1459
+
1460
+ def _store_in_caches() -> None:
1461
+ if not caches:
1462
+ return
1463
+ for cache in caches:
1464
+ cache.store(
1465
+ block.digest,
1466
+ pdf_path,
1467
+ png_path,
1468
+ template_version=template_version,
1469
+ block=block,
1470
+ source_path=Path(source_path) if source_path is not None else None,
1471
+ )
1472
+ _flush_caches()
1473
+
1474
+ if not pdf_missing and not png_missing:
1475
+ _store_in_caches()
1476
+ return assets
1477
+
1478
+ if caches and (pdf_missing or png_missing):
1479
+ for cache in caches:
1480
+ cached_assets = cache.lookup(block.digest, template_version=template_version)
1481
+ if cached_assets is None:
1482
+ continue
1483
+ try:
1484
+ if pdf_missing:
1485
+ shutil.copy2(cached_assets.pdf, pdf_path)
1486
+ pdf_missing = False
1487
+ if png_missing:
1488
+ shutil.copy2(cached_assets.png, png_path)
1489
+ png_missing = False
1490
+ except OSError:
1491
+ cache.discard(block.digest)
1492
+ continue
1493
+ if not pdf_missing and not png_missing:
1494
+ _store_in_caches()
1495
+ return assets
1496
+ _flush_caches()
1497
+
1498
+ if not pdf_missing and png_missing:
1499
+ _pdf_to_png_grid(
1500
+ pdf_path,
1501
+ png_path,
1502
+ layout=block.layout,
1503
+ transparent_corner=dogear_enabled,
1504
+ spacing=None if block.layout else 0,
1505
+ fold_size=preview_fold_px,
1506
+ )
1507
+ png_missing = False
1508
+ _store_in_caches()
1509
+ return assets
1510
+
1511
+ _announce_build(block, source_path, emitter)
1512
+
1513
+ settings = RenderSettings(
1514
+ copy_assets=True,
1515
+ convert_assets=False,
1516
+ hash_assets=False,
1517
+ manifest=False,
1518
+ )
1519
+ session = TemplateSession(runtime=runtime, settings=settings, emitter=emitter)
1520
+ for document in documents:
1521
+ session.add_document(document)
1522
+ if bibliography_files:
1523
+ session.add_bibliography(*bibliography_files)
1524
+ if merged_overrides:
1525
+ session.update_options(merged_overrides)
1526
+
1527
+ work_dir = destination / f".build-{block.digest}"
1528
+ shutil.rmtree(work_dir, ignore_errors=True)
1529
+ work_dir.mkdir(parents=True, exist_ok=True)
1530
+
1531
+ debug_dir: Path | None = None
1532
+ try:
1533
+ render_result = session.render(work_dir)
1534
+ compiled_pdf = _compile_pdf(render_result)
1535
+ shutil.copy2(compiled_pdf, pdf_path)
1536
+ except Exception as exc:
1537
+ # Preserve the work directory for post-mortem inspection when compilation fails.
1538
+ try:
1539
+ cache_root = _resolve_cache_root() or Path(tempfile.gettempdir()) / "texsmith"
1540
+ debug_dir = (cache_root / "snippet-fail" / block.digest).resolve()
1541
+ if debug_dir.exists():
1542
+ shutil.rmtree(debug_dir, ignore_errors=True)
1543
+ shutil.copytree(work_dir, debug_dir, dirs_exist_ok=True)
1544
+ except Exception:
1545
+ debug_dir = None
1546
+ if debug_dir is not None:
1547
+ raise exc.__class__(f"{exc} (debug: {debug_dir})") from exc
1548
+ raise
1549
+ else:
1550
+ shutil.rmtree(work_dir, ignore_errors=True)
1551
+
1552
+ total_cells = 1
1553
+ if block.layout:
1554
+ cols, rows = block.layout
1555
+ total_cells = max(cols, 1) * max(rows, 1)
1556
+ _pdf_to_png_grid(
1557
+ pdf_path,
1558
+ png_path,
1559
+ layout=block.layout,
1560
+ transparent_corner=dogear_enabled,
1561
+ spacing=None if total_cells > 1 else 0,
1562
+ decorate_page=None,
1563
+ fold_size=preview_fold_px,
1564
+ )
1565
+
1566
+ _store_in_caches()
1567
+ return assets
1568
+
1569
+
1570
+ def _render_snippet_assets(block: SnippetBlock, context: RenderContext) -> _SnippetAssets:
1571
+ emitter = _resolve_emitter(context)
1572
+ document_path = context.runtime.get("document_path")
1573
+ source_dir = context.runtime.get("source_dir")
1574
+ host_path = _resolve_host_path(document_path, source_dir)
1575
+ return ensure_snippet_assets(
1576
+ block,
1577
+ output_dir=context.assets.output_root / SNIPPET_DIR,
1578
+ source_path=host_path or (context.assets.output_root / "snippet.md"),
1579
+ emitter=emitter,
1580
+ )
1581
+
1582
+
1583
+ def _render_figure(
1584
+ context: RenderContext, assets: _SnippetAssets, block: SnippetBlock
1585
+ ) -> NavigableString:
1586
+ template_name = context.runtime.get("figure_template", "figure")
1587
+ formatter = getattr(context.formatter, template_name)
1588
+ # Prefer PDF for LaTeX; PNGs are optional previews and may be missing.
1589
+ figure_source = assets.pdf
1590
+ latex_path = context.assets.latex_path(figure_source)
1591
+ latex = formatter(
1592
+ path=latex_path,
1593
+ caption=block.caption,
1594
+ shortcaption=block.caption,
1595
+ label=block.label,
1596
+ width=block.figure_width,
1597
+ adjustbox=True,
1598
+ )
1599
+ return mark_processed(NavigableString(latex))
1600
+
1601
+
1602
+ def _merge_fragment_defaults(
1603
+ overrides: Mapping[str, Any] | None, runtime: TemplateRuntime | None
1604
+ ) -> dict[str, Any]:
1605
+ """Ensure explicit fragment overrides keep template defaults such as ts-extra."""
1606
+
1607
+ def _as_list(value: Any) -> list[str]:
1608
+ if value is None:
1609
+ return []
1610
+ if isinstance(value, list):
1611
+ return [str(item) for item in value]
1612
+ if isinstance(value, Mapping):
1613
+ return [str(key) for key in value]
1614
+ return [str(value)]
1615
+
1616
+ if not overrides:
1617
+ return {}
1618
+
1619
+ merged: dict[str, Any] = dict(overrides)
1620
+ press_section = merged.get("press") if isinstance(merged.get("press"), Mapping) else None
1621
+
1622
+ provided = merged.get("fragments")
1623
+ if provided is None and isinstance(press_section, Mapping):
1624
+ provided = press_section.get("fragments")
1625
+
1626
+ defaults = _as_list(runtime.extras.get("fragments") if runtime and runtime.extras else [])
1627
+ requested = _as_list(provided)
1628
+
1629
+ if defaults or requested:
1630
+ merged["fragments"] = list(dict.fromkeys([*defaults, *requested]))
1631
+ if isinstance(press_section, Mapping):
1632
+ updated_press = dict(press_section)
1633
+ updated_press.setdefault("fragments", provided)
1634
+ merged["press"] = updated_press
1635
+
1636
+ return merged
1637
+
1638
+
1639
+ @renders(
1640
+ "div",
1641
+ "pre",
1642
+ phase=RenderPhase.PRE,
1643
+ priority=32,
1644
+ name="snippet_blocks",
1645
+ nestable=False,
1646
+ )
1647
+ def render_snippet_block(element: Tag, context: RenderContext) -> None:
1648
+ """Convert `.snippet` code fences into rendered figures."""
1649
+ document_path = context.runtime.get("document_path")
1650
+ source_dir = context.runtime.get("source_dir")
1651
+ host_path = _resolve_host_path(document_path, source_dir)
1652
+ block = _extract_snippet_block(element, host_path=host_path)
1653
+ if block is None:
1654
+ return
1655
+
1656
+ assets = _render_snippet_assets(block, context)
1657
+ asset_key = f"snippet::{block.digest}"
1658
+ context.assets.register(asset_key, assets.pdf)
1659
+
1660
+ node = _render_figure(context, assets, block)
1661
+ context.suppress_children(element)
1662
+ element.replace_with(node)
1663
+
1664
+
1665
+ def rewrite_html_snippets(
1666
+ html: str,
1667
+ resolver: Callable[[SnippetBlock], tuple[str, str]],
1668
+ *,
1669
+ source_path: Path | str | None = None,
1670
+ ) -> str:
1671
+ """Replace snippet fences in an HTML fragment with linked previews."""
1672
+ if "snippet" not in html:
1673
+ return html
1674
+ host_path = Path(source_path) if source_path is not None else None
1675
+
1676
+ soup = BeautifulSoup(html, "html.parser")
1677
+ mutated = False
1678
+ for element in soup.find_all(["div", "pre"]):
1679
+ block = _extract_snippet_block(element, host_path=host_path)
1680
+ if block is None:
1681
+ continue
1682
+ pdf_url, png_url = resolver(block)
1683
+ anchor = soup.new_tag(
1684
+ "a",
1685
+ href=pdf_url,
1686
+ target="_blank",
1687
+ rel="noopener noreferrer",
1688
+ )
1689
+ image_attrs = {"src": png_url, "alt": block.caption or "Snippet", "class": ["ts-snippet"]}
1690
+ if block.figure_width:
1691
+ image_attrs["width"] = block.figure_width
1692
+ image = soup.new_tag("img", **image_attrs)
1693
+ anchor.append(image)
1694
+ element.replace_with(anchor)
1695
+ mutated = True
1696
+
1697
+ return str(soup) if mutated else html
1698
+
1699
+
1700
+ def register(renderer: Any) -> None:
1701
+ """Register the snippet handler on a renderer instance."""
1702
+ renderer.register(render_snippet_block)
1703
+
1704
+
1705
+ def _announce_build(
1706
+ block: SnippetBlock, host_path: Path | str | None, emitter: DiagnosticEmitter | None
1707
+ ) -> None:
1708
+ """Emit an informational message when a snippet is actually compiled."""
1709
+ source_hint = f" from {host_path}" if host_path else ""
1710
+ short_digest = block.digest[:10] + "..." if block.digest else block.asset_basename
1711
+ _log.info("texsmith: building snippet %s%s", short_digest, source_hint)
1712
+ if emitter is not None:
1713
+ try:
1714
+ emitter.event(
1715
+ "snippet_build",
1716
+ {
1717
+ "digest": block.digest,
1718
+ "source": str(host_path) if host_path else "",
1719
+ "destination": block.asset_basename,
1720
+ },
1721
+ )
1722
+ except Exception:
1723
+ _log.debug("failed to emit snippet_build event", exc_info=True)
1724
+
1725
+
1726
+ __all__ = [
1727
+ "SNIPPET_DIR",
1728
+ "SnippetBlock",
1729
+ "asset_filename",
1730
+ "ensure_snippet_assets",
1731
+ "register",
1732
+ "render_snippet_block",
1733
+ "rewrite_html_snippets",
1734
+ ]