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,646 @@
1
+ """Template orchestration helpers powering the conversion pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ import contextlib
7
+ import copy
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ import re
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from bs4 import BeautifulSoup, FeatureNotFound
14
+ from bs4.element import NavigableString, Tag
15
+ from pybtex.exceptions import PybtexError
16
+ from slugify import slugify
17
+ import yaml
18
+
19
+ from ..bibliography.collection import BibliographyCollection
20
+ from ..bibliography.parsing import (
21
+ bibliography_data_from_inline_entry,
22
+ bibliography_data_from_string,
23
+ )
24
+ from ..config import BookConfig
25
+ from ..conversion_contexts import BinderContext, DocumentContext, GenerationStrategy
26
+ from ..diagnostics import DiagnosticEmitter
27
+ from ..mustache import replace_mustaches, replace_mustaches_in_structure
28
+ from ..templates import (
29
+ TemplateBinding,
30
+ TemplateError,
31
+ TemplateRuntime,
32
+ TemplateSlot,
33
+ build_template_overrides,
34
+ resolve_template_binding,
35
+ resolve_template_language,
36
+ )
37
+ from .debug import debug_enabled, ensure_emitter, raise_conversion_error, record_event
38
+ from .inputs import (
39
+ DOCUMENT_SELECTOR_SENTINEL,
40
+ InlineBibliographyEntry,
41
+ InlineBibliographyValidationError,
42
+ extract_front_matter_bibliography,
43
+ )
44
+
45
+
46
+ if TYPE_CHECKING: # pragma: no cover - typing only
47
+ from ..bibliography.doi import DoiBibliographyFetcher
48
+
49
+
50
+ _DOI_SUPPORT: dict[str, Any] | None = None
51
+ # Exported for compatibility with existing monkeypatch patterns in tests.
52
+ DoiBibliographyFetcher: type[Any] | None = None
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class SlotFragment:
57
+ """HTML fragment mapped to a template slot with position metadata."""
58
+
59
+ name: str
60
+ html: str
61
+ position: int
62
+ heading_levels: list[int] = field(default_factory=list)
63
+
64
+
65
+ def build_binder_context(
66
+ *,
67
+ document_context: DocumentContext,
68
+ template: str | None,
69
+ template_runtime: TemplateRuntime | None,
70
+ requested_language: str | None,
71
+ bibliography_files: list[Path],
72
+ slot_overrides: Mapping[str, str] | None,
73
+ output_dir: Path,
74
+ strategy: GenerationStrategy,
75
+ emitter: DiagnosticEmitter | None,
76
+ legacy_latex_accents: bool,
77
+ session_overrides: Mapping[str, Any] | None = None,
78
+ preloaded_bibliography: BibliographyCollection | None = None,
79
+ seen_bibliography_issues: set[tuple[str, str | None, str | None]] | None = None,
80
+ ) -> BinderContext:
81
+ """Prepare template bindings, bibliography data, and slot mappings."""
82
+ emitter = ensure_emitter(emitter)
83
+ resolved_language = resolve_template_language(requested_language, document_context.front_matter)
84
+ document_context.language = resolved_language
85
+
86
+ config = BookConfig(
87
+ project_dir=document_context.source_path.parent,
88
+ language=resolved_language,
89
+ legacy_latex_accents=legacy_latex_accents,
90
+ )
91
+
92
+ try:
93
+ inline_bibliography = extract_front_matter_bibliography(document_context.front_matter)
94
+ except InlineBibliographyValidationError as exc:
95
+ raise_conversion_error(emitter, str(exc), exc)
96
+
97
+ issue_signatures = seen_bibliography_issues if seen_bibliography_issues is not None else set()
98
+ bibliography_collection: BibliographyCollection | None = (
99
+ preloaded_bibliography.clone() if preloaded_bibliography is not None else None
100
+ )
101
+ bibliography_map: dict[str, dict[str, Any]] = {}
102
+
103
+ if bibliography_collection is None:
104
+ bibliography_collection = BibliographyCollection()
105
+ if bibliography_files:
106
+ bibliography_collection.load_files(bibliography_files)
107
+
108
+ if inline_bibliography:
109
+ _load_inline_bibliography(
110
+ bibliography_collection,
111
+ inline_bibliography,
112
+ source_label=document_context.source_path.stem,
113
+ output_dir=output_dir,
114
+ emitter=emitter,
115
+ )
116
+
117
+ bibliography_map = bibliography_collection.to_dict()
118
+ for issue in bibliography_collection.issues:
119
+ signature = (issue.message, issue.key, str(issue.source) if issue.source else None)
120
+ if signature in issue_signatures:
121
+ continue
122
+ prefix = f"[{issue.key}] " if issue.key else ""
123
+ source_hint = f" ({issue.source})" if issue.source else ""
124
+ emitter.warning(f"{prefix}{issue.message}{source_hint}")
125
+ issue_signatures.add(signature)
126
+
127
+ document_context.bibliography = bibliography_map
128
+
129
+ slot_requests = dict(document_context.slot_requests)
130
+ if slot_overrides:
131
+ slot_requests.update(dict(slot_overrides))
132
+
133
+ template_overrides = build_template_overrides(document_context.front_matter)
134
+ if session_overrides:
135
+ template_overrides = _merge_template_overrides(template_overrides, session_overrides)
136
+
137
+ press_section = template_overrides.get("press")
138
+ if not isinstance(press_section, dict):
139
+ press_section = None
140
+
141
+ def _ensure_press_section() -> dict[str, Any]:
142
+ nonlocal press_section
143
+ if press_section is None:
144
+ press_section = {}
145
+ template_overrides["press"] = press_section
146
+ return press_section
147
+
148
+ if document_context.extracted_title:
149
+ template_overrides.setdefault("title", document_context.extracted_title)
150
+ _ensure_press_section().setdefault("title", document_context.extracted_title)
151
+
152
+ template_overrides.setdefault("language", resolved_language)
153
+ if press_section is not None or "press" in template_overrides:
154
+ _ensure_press_section().setdefault("language", resolved_language)
155
+
156
+ template_overrides.setdefault("_source_dir", str(document_context.source_path.parent))
157
+ template_overrides.setdefault("_source_path", str(document_context.source_path))
158
+ template_overrides.setdefault("source_dir", str(document_context.source_path.parent))
159
+ template_overrides["output_dir"] = str(output_dir)
160
+
161
+ raw_contexts = (template_overrides, document_context.front_matter)
162
+ template_overrides = replace_mustaches_in_structure(
163
+ template_overrides, raw_contexts, emitter=emitter, source="template attributes"
164
+ )
165
+ document_context.front_matter = replace_mustaches_in_structure(
166
+ document_context.front_matter,
167
+ raw_contexts,
168
+ emitter=emitter,
169
+ source=str(document_context.source_path),
170
+ )
171
+ merged_contexts = (template_overrides, document_context.front_matter)
172
+ if isinstance(document_context.html, str):
173
+ document_context.html = replace_mustaches(
174
+ document_context.html,
175
+ merged_contexts,
176
+ emitter=emitter,
177
+ source=str(document_context.source_path),
178
+ )
179
+
180
+ active_slot_requests: dict[str, str] = {}
181
+ binding: TemplateBinding | None = None
182
+ try:
183
+ binding, active_slot_requests = resolve_template_binding(
184
+ template=template,
185
+ template_runtime=template_runtime,
186
+ template_overrides=template_overrides,
187
+ slot_requests=slot_requests,
188
+ warn=lambda message: emitter.warning(message),
189
+ )
190
+ except TemplateError as exc:
191
+ if debug_enabled(emitter):
192
+ raise
193
+ raise_conversion_error(emitter, str(exc), exc)
194
+ if binding is None: # pragma: no cover - defensive
195
+ raise RuntimeError("Failed to resolve template binding.")
196
+
197
+ if binding.runtime and binding.runtime.extras:
198
+ template_mermaid = binding.runtime.extras.get("mermaid_config")
199
+ if template_mermaid and not config.mermaid_config:
200
+ config.mermaid_config = Path(template_mermaid)
201
+
202
+ binder_context = BinderContext(
203
+ output_dir=output_dir,
204
+ config=config,
205
+ strategy=strategy,
206
+ language=resolved_language,
207
+ slot_requests=active_slot_requests,
208
+ template_overrides=dict(template_overrides),
209
+ bibliography_map=bibliography_map,
210
+ bibliography_collection=bibliography_collection,
211
+ template_binding=binding,
212
+ )
213
+ binder_context.documents.append(document_context)
214
+
215
+ return binder_context
216
+
217
+
218
+ def _merge_template_overrides(
219
+ base: Mapping[str, Any], overrides: Mapping[str, Any]
220
+ ) -> dict[str, Any]:
221
+ merged: dict[str, Any] = copy.deepcopy(dict(base))
222
+
223
+ def _merge(target: dict[str, Any], source: Mapping[str, Any]) -> None:
224
+ for key, value in source.items():
225
+ if isinstance(value, Mapping):
226
+ existing = target.get(key)
227
+ nested: dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {}
228
+ _merge(nested, value)
229
+ target[key] = nested
230
+ else:
231
+ target[key] = copy.deepcopy(value)
232
+
233
+ _merge(merged, overrides)
234
+ return merged
235
+
236
+
237
+ def extract_slot_fragments(
238
+ html: str,
239
+ requests: Mapping[str, str],
240
+ default_slot: str,
241
+ *,
242
+ slot_definitions: Mapping[str, TemplateSlot],
243
+ parser_backend: str,
244
+ ) -> tuple[list[SlotFragment], list[str]]:
245
+ """Split the HTML document into fragments mapped to template slots."""
246
+ try:
247
+ soup = BeautifulSoup(html, parser_backend)
248
+ except FeatureNotFound:
249
+ soup = BeautifulSoup(html, "html.parser")
250
+
251
+ container = soup.body or soup
252
+ document_html = "".join(str(node) for node in container.contents)
253
+
254
+ wildcard_values = {
255
+ DOCUMENT_SELECTOR_SENTINEL,
256
+ DOCUMENT_SELECTOR_SENTINEL.lower(),
257
+ "*",
258
+ }
259
+ full_document_slots: list[str] = []
260
+ filtered_requests: dict[str, str] = {}
261
+ for slot_name, selector in requests.items():
262
+ if not selector:
263
+ continue
264
+ token = selector.strip()
265
+ if token.lower() in wildcard_values:
266
+ full_document_slots.append(slot_name)
267
+ continue
268
+ filtered_requests[slot_name] = selector
269
+
270
+ headings: list[tuple[int, Tag]] = []
271
+ for index, heading in enumerate(container.find_all(re.compile(r"^h[1-6]$"), recursive=True)):
272
+ headings.append((index, heading))
273
+
274
+ matched: dict[str, tuple[int, Tag]] = {}
275
+ missing: list[str] = []
276
+ occupied_nodes: set[int] = set()
277
+ document_nodes = list(container.contents)
278
+
279
+ for slot_name, selector in filtered_requests.items():
280
+ if not selector:
281
+ continue
282
+ target_label = selector.lstrip("#")
283
+ matched_heading: tuple[int, Tag] | None = None
284
+ for index, heading in headings:
285
+ if id(heading) in occupied_nodes:
286
+ continue
287
+ node_id = heading.get("id")
288
+ if isinstance(node_id, str) and node_id == target_label:
289
+ matched_heading = (index, heading)
290
+ break
291
+ if matched_heading is None:
292
+ for index, heading in headings:
293
+ if id(heading) in occupied_nodes:
294
+ continue
295
+ if heading.get_text(strip=True) == selector:
296
+ matched_heading = (index, heading)
297
+ break
298
+ if matched_heading is None:
299
+ missing.append(f"unable to locate section '{selector}' for slot '{slot_name}'")
300
+ continue
301
+ matched_index, heading = matched_heading
302
+ occupied_nodes.add(id(heading))
303
+ matched[slot_name] = (matched_index, heading)
304
+
305
+ fragments: list[SlotFragment] = []
306
+
307
+ for offset, slot_name in enumerate(full_document_slots):
308
+ fragments.append(
309
+ SlotFragment(
310
+ name=slot_name,
311
+ html=document_html,
312
+ position=-(len(full_document_slots) - offset),
313
+ heading_levels=_heading_levels_for_nodes(document_nodes),
314
+ )
315
+ )
316
+
317
+ for slot_name, (order, heading) in sorted(matched.items(), key=lambda item: item[1][0]):
318
+ section_nodes = collect_section_nodes(heading)
319
+ slot_config = slot_definitions.get(slot_name)
320
+ strip_heading = bool(slot_config.strip_heading) if slot_config else False
321
+ render_nodes = list(section_nodes)
322
+ if strip_heading and render_nodes:
323
+ render_nodes = render_nodes[1:]
324
+ while render_nodes and isinstance(render_nodes[0], NavigableString):
325
+ if str(render_nodes[0]).strip():
326
+ break
327
+ render_nodes.pop(0)
328
+ html_fragment = "".join(str(node) for node in render_nodes)
329
+ fragments.append(
330
+ SlotFragment(
331
+ name=slot_name,
332
+ html=html_fragment,
333
+ position=order,
334
+ heading_levels=_heading_levels_for_nodes(render_nodes),
335
+ )
336
+ )
337
+ for node in section_nodes:
338
+ if hasattr(node, "extract"):
339
+ node.extract()
340
+
341
+ container = soup.body or soup
342
+ if full_document_slots:
343
+ remainder_html = ""
344
+ else:
345
+ remainder_html = "".join(str(node) for node in container.contents)
346
+
347
+ remainder_position = max(fragment.position for fragment in fragments) + 1 if fragments else 0
348
+
349
+ fragments.append(
350
+ SlotFragment(
351
+ name=default_slot,
352
+ html=remainder_html,
353
+ position=remainder_position,
354
+ heading_levels=_heading_levels_for_nodes(container.contents),
355
+ )
356
+ )
357
+
358
+ fragments.sort(key=lambda fragment: fragment.position)
359
+ return fragments, missing
360
+
361
+
362
+ def _heading_levels_for_nodes(nodes: Iterable[Any]) -> list[int]:
363
+ """Return heading levels discovered in the given node sequence (document order)."""
364
+ levels: list[int] = []
365
+
366
+ def _walk(node: Any) -> None:
367
+ if isinstance(node, Tag):
368
+ if re.fullmatch(r"h[1-6]", node.name or ""):
369
+ with contextlib.suppress(ValueError):
370
+ levels.append(heading_level_for(node))
371
+ for child in node.children:
372
+ _walk(child)
373
+
374
+ for node in nodes:
375
+ _walk(node)
376
+
377
+ return levels
378
+
379
+
380
+ def collect_section_nodes(heading: Tag) -> list[Any]:
381
+ """Collect a heading node and its associated section content."""
382
+ nodes: list[Any] = [heading]
383
+ heading_level = heading_level_for(heading)
384
+ for sibling in heading.next_siblings:
385
+ if isinstance(sibling, NavigableString):
386
+ nodes.append(sibling)
387
+ continue
388
+ if isinstance(sibling, Tag):
389
+ if re.fullmatch(r"h[1-6]", sibling.name or ""):
390
+ sibling_level = heading_level_for(sibling)
391
+ if sibling_level <= heading_level:
392
+ break
393
+ nodes.append(sibling)
394
+ return nodes
395
+
396
+
397
+ def heading_level_for(node: Tag) -> int:
398
+ """Return the numeric level of a heading element."""
399
+ name = node.name or ""
400
+ if not re.fullmatch(r"h[1-6]", name):
401
+ raise ValueError(f"Expected heading element, got '{name}'.")
402
+ return int(name[1])
403
+
404
+
405
+ def compute_heading_offset(
406
+ html: str,
407
+ *,
408
+ drop_first_heading: bool = False,
409
+ parser_backend: str = "html.parser",
410
+ ) -> int:
411
+ """Return the offset required to align the top heading to level 1.
412
+
413
+ The shallowest heading in the fragment counts as offset ``0``; headings
414
+ starting at ``<h2>`` therefore yield ``-1``. When ``drop_first_heading`` is
415
+ true the first heading is ignored to mirror title promotion.
416
+ """
417
+ try:
418
+ soup = BeautifulSoup(html, parser_backend)
419
+ except FeatureNotFound:
420
+ soup = BeautifulSoup(html, "html.parser")
421
+
422
+ headings = soup.find_all(re.compile(r"^h[1-6]$"), recursive=True)
423
+ if drop_first_heading and headings:
424
+ headings = headings[1:]
425
+
426
+ minimum: int | None = None
427
+ for heading in headings:
428
+ try:
429
+ level = heading_level_for(heading)
430
+ except ValueError:
431
+ continue
432
+ if minimum is None or level < minimum:
433
+ minimum = level
434
+
435
+ if minimum is None:
436
+ return 0
437
+ return 1 - minimum
438
+
439
+
440
+ def _load_inline_bibliography(
441
+ collection: BibliographyCollection,
442
+ entries: Mapping[str, InlineBibliographyEntry],
443
+ *,
444
+ source_label: str,
445
+ output_dir: Path | None = None,
446
+ emitter: DiagnosticEmitter,
447
+ fetcher: DoiBibliographyFetcher | None = None,
448
+ ) -> None:
449
+ if not entries:
450
+ return
451
+
452
+ resolver = fetcher
453
+ source_path = _inline_bibliography_source_path(source_label)
454
+ cache_entries, cache_path = _initialise_doi_cache(output_dir)
455
+ cache_dirty = False
456
+ doi_support: tuple[type[Exception], Any] | None = None
457
+
458
+ for key, entry in entries.items():
459
+ if entry.doi:
460
+ if doi_support is None:
461
+ _, lookup_error_cls, normalise_doi_fn = _ensure_doi_support()
462
+ doi_support = (lookup_error_cls, normalise_doi_fn)
463
+ lookup_error_cls, normalise_doi_fn = doi_support
464
+ doi_value = entry.doi
465
+ try:
466
+ doi_key = normalise_doi_fn(doi_value)
467
+ except lookup_error_cls as exc:
468
+ emitter.warning(f"Failed to resolve DOI '{doi_value}' for '{key}': {exc}")
469
+ continue
470
+
471
+ payload = cache_entries.get(doi_key)
472
+ cache_mode = "doi_cache" if payload is not None else "doi"
473
+
474
+ if payload is None:
475
+ if resolver is None:
476
+ resolver = _resolve_bibliography_fetcher()
477
+ try:
478
+ payload = resolver.fetch(doi_value)
479
+ except lookup_error_cls as exc:
480
+ emitter.warning(f"Failed to resolve DOI '{doi_value}' for '{key}': {exc}")
481
+ continue
482
+ cache_entries[doi_key] = payload
483
+ cache_dirty = True
484
+
485
+ try:
486
+ data = bibliography_data_from_string(payload, key)
487
+ except PybtexError as exc:
488
+ emitter.warning(f"Failed to parse bibliography entry '{key}': {exc}")
489
+ if cache_mode == "doi":
490
+ cache_entries.pop(doi_key, None)
491
+ continue
492
+ doi_source = source_path.with_stem(f"{source_path.stem}-doi")
493
+ collection.load_data(data, source=doi_source)
494
+ record_event(
495
+ emitter,
496
+ "doi_fetch",
497
+ {
498
+ "key": key,
499
+ "value": doi_value,
500
+ "mode": cache_mode,
501
+ "source": source_label,
502
+ "resolved_source": str(source_path),
503
+ },
504
+ )
505
+ continue
506
+
507
+ if entry.is_manual:
508
+ try:
509
+ data = bibliography_data_from_inline_entry(key, entry)
510
+ except (ValueError, PybtexError) as exc:
511
+ emitter.warning(f"Failed to materialise bibliography entry '{key}': {exc}")
512
+ continue
513
+ collection.load_data(data, source=source_path)
514
+ record_event(
515
+ emitter,
516
+ "inline_bibliography",
517
+ {
518
+ "key": key,
519
+ "mode": "manual",
520
+ "source": source_label,
521
+ "resolved_source": str(source_path),
522
+ },
523
+ )
524
+ continue
525
+
526
+ emitter.warning(
527
+ f"Bibliography entry '{key}' does not provide a DOI or manual fields; skipping."
528
+ )
529
+
530
+ if cache_dirty and cache_path is not None:
531
+ _write_doi_cache(cache_path, cache_entries)
532
+
533
+
534
+ def _inline_bibliography_source_path(label: str) -> Path:
535
+ slug = slugify(label, separator="-")
536
+ if not slug:
537
+ slug = "frontmatter"
538
+ return Path(f"frontmatter-{slug}.bib")
539
+
540
+
541
+ def _resolve_bibliography_fetcher() -> DoiBibliographyFetcher:
542
+ fetcher_cls, _, _ = _ensure_doi_support()
543
+ return fetcher_cls()
544
+
545
+
546
+ _DOI_CACHE_FILENAME = "texsmith-doi-cache.yaml"
547
+
548
+
549
+ def _initialise_doi_cache(output_dir: Path | None) -> tuple[dict[str, str], Path | None]:
550
+ if output_dir is None:
551
+ return {}, None
552
+
553
+ cache_path = output_dir / _DOI_CACHE_FILENAME
554
+ try:
555
+ raw_text = cache_path.read_text(encoding="utf-8")
556
+ except FileNotFoundError:
557
+ return {}, cache_path
558
+ except OSError:
559
+ return {}, cache_path
560
+
561
+ try:
562
+ payload = yaml.safe_load(raw_text) or {}
563
+ except yaml.YAMLError:
564
+ return {}, cache_path
565
+
566
+ entries_payload: Any
567
+ if isinstance(payload, dict) and isinstance(payload.get("entries"), dict):
568
+ entries_payload = payload["entries"]
569
+ elif isinstance(payload, dict):
570
+ entries_payload = payload
571
+ else:
572
+ entries_payload = {}
573
+
574
+ entries: dict[str, str] = {}
575
+ normalise_fn: Any | None = None
576
+ lookup_error_cls: type[Exception] | None = None
577
+ if isinstance(entries_payload, dict):
578
+ for key, value in entries_payload.items():
579
+ if not isinstance(key, str) or not isinstance(value, str):
580
+ continue
581
+ try:
582
+ if normalise_fn is None:
583
+ _, lookup_error_cls, normalise_fn = _ensure_doi_support()
584
+ normalised = normalise_fn(key)
585
+ except Exception as exc:
586
+ if lookup_error_cls is not None and isinstance(exc, lookup_error_cls):
587
+ continue
588
+ raise
589
+ entries[normalised] = value
590
+
591
+ return entries, cache_path
592
+
593
+
594
+ def _write_doi_cache(path: Path, entries: dict[str, str]) -> None:
595
+ document = {
596
+ "version": 1,
597
+ "entries": {key: entries[key] for key in sorted(entries)},
598
+ }
599
+ try:
600
+ path.parent.mkdir(parents=True, exist_ok=True)
601
+ path.write_text(
602
+ yaml.safe_dump(
603
+ document,
604
+ sort_keys=True,
605
+ default_flow_style=False,
606
+ encoding=None,
607
+ ),
608
+ encoding="utf-8",
609
+ )
610
+ except OSError:
611
+ return
612
+
613
+
614
+ def _ensure_doi_support() -> tuple[type[Any], type[Exception], Any]:
615
+ """Lazily import DOI helpers to avoid pulling in requests unless required."""
616
+ global _DOI_SUPPORT, DoiBibliographyFetcher
617
+
618
+ if _DOI_SUPPORT is None:
619
+ from ..bibliography.doi import DoiLookupError, normalise_doi
620
+
621
+ _DOI_SUPPORT = {
622
+ "lookup_error": DoiLookupError,
623
+ "normalise": normalise_doi,
624
+ }
625
+ if DoiBibliographyFetcher is None:
626
+ from ..bibliography.doi import DoiBibliographyFetcher as _Fetcher
627
+
628
+ DoiBibliographyFetcher = _Fetcher
629
+
630
+ assert _DOI_SUPPORT is not None
631
+ assert DoiBibliographyFetcher is not None
632
+ return (
633
+ DoiBibliographyFetcher,
634
+ _DOI_SUPPORT["lookup_error"],
635
+ _DOI_SUPPORT["normalise"],
636
+ )
637
+
638
+
639
+ __all__ = [
640
+ "SlotFragment",
641
+ "build_binder_context",
642
+ "collect_section_nodes",
643
+ "compute_heading_offset",
644
+ "extract_slot_fragments",
645
+ "heading_level_for",
646
+ ]