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,645 @@
1
+ """Document abstractions consumed by the TeXSmith public API.
2
+
3
+ Architecture
4
+ : `Document` models inputs alongside slot overrides and rendering toggles.
5
+ Instances are intentionally lightweight so they can be duplicated when
6
+ entering template sessions without costly reparsing.
7
+ : `DocumentRenderOptions` captures heading offsets, numbering, and other knobs
8
+ that influence the LaTeX output. Keeping these options separate from the core
9
+ document data allows caller-specific tweaks while preserving the original
10
+ source.
11
+
12
+ Implementation Rationale
13
+ : Conversions often need multiple passes over the same document, such as preview
14
+ and templated export. By storing canonicalised HTML and front-matter snapshots
15
+ we avoid repeated Markdown or HTML parsing.
16
+ : A dedicated abstraction makes it easy to inspect or mutate front matter in
17
+ higher layers without leaking the underlying `DocumentContext` type used
18
+ deeper inside the conversion engine.
19
+
20
+ Usage Example
21
+ :
22
+ >>> from pathlib import Path
23
+ >>> from tempfile import TemporaryDirectory
24
+ >>> from texsmith.api.document import Document
25
+ >>> with TemporaryDirectory() as tmpdir:
26
+ ... source = Path(tmpdir) / "chapter.md"
27
+ ... _ = source.write_text("# Chapter\\nBody")
28
+ ... doc = Document.from_markdown(source, base_level="section")
29
+ ... context = doc.to_context()
30
+ ... context.name
31
+ 'chapter'
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from collections.abc import Iterable, Mapping
37
+ import contextlib
38
+ import copy
39
+ from dataclasses import dataclass, field
40
+ from enum import Enum
41
+ from html.parser import HTMLParser
42
+ from pathlib import Path
43
+ from typing import Any, ClassVar
44
+
45
+ from ..adapters.markdown import (
46
+ DEFAULT_MARKDOWN_EXTENSIONS,
47
+ MarkdownConversionError,
48
+ render_markdown,
49
+ )
50
+ from ..core.conversion.debug import ConversionError, debug_enabled
51
+ from ..core.conversion.inputs import (
52
+ DOCUMENT_SELECTOR_SENTINEL,
53
+ InputKind,
54
+ build_document_context,
55
+ extract_content,
56
+ extract_front_matter_slots,
57
+ )
58
+ from ..core.conversion_contexts import DocumentContext
59
+ from ..core.diagnostics import DiagnosticEmitter, NullEmitter
60
+ from ..core.metadata import PressMetadataError, normalise_press_metadata
61
+ from ..core.templates.runtime import coerce_base_level
62
+
63
+
64
+ __all__ = [
65
+ "Document",
66
+ "DocumentRenderOptions",
67
+ "DocumentSlots",
68
+ "TitleStrategy",
69
+ "front_matter_has_title",
70
+ ]
71
+
72
+
73
+ class TitleStrategy(str, Enum):
74
+ """Strategy describing how the first document heading should be handled."""
75
+
76
+ KEEP = "keep"
77
+ DROP = "drop"
78
+ PROMOTE_METADATA = "promote_metadata"
79
+
80
+
81
+ def _resolve_title_strategy(
82
+ *,
83
+ explicit: TitleStrategy | None,
84
+ promote_title: bool,
85
+ strip_heading: bool,
86
+ has_declared_title: bool,
87
+ ) -> TitleStrategy:
88
+ """Determine the effective title strategy from caller preferences."""
89
+ if explicit is not None:
90
+ return explicit
91
+ if strip_heading:
92
+ return TitleStrategy.DROP
93
+ if promote_title and not has_declared_title:
94
+ return TitleStrategy.PROMOTE_METADATA
95
+ return TitleStrategy.KEEP
96
+
97
+
98
+ def front_matter_has_title(metadata: Mapping[str, Any] | None) -> bool:
99
+ """Return ``True`` when the mapping declares a title."""
100
+ if not isinstance(metadata, Mapping):
101
+ return False
102
+
103
+ payload = dict(metadata)
104
+ with contextlib.suppress(PressMetadataError):
105
+ normalise_press_metadata(payload)
106
+
107
+ title = payload.get("title")
108
+ return bool(isinstance(title, str) and title.strip())
109
+
110
+
111
+ def _coerce_bool(value: Any) -> bool | None:
112
+ """Coerce loose truthy/falsey values from front matter."""
113
+ if isinstance(value, bool):
114
+ return value
115
+ if isinstance(value, (int, float)):
116
+ return bool(value)
117
+ if isinstance(value, str):
118
+ candidate = value.strip().lower()
119
+ if candidate in {"true", "yes", "on", "1"}:
120
+ return True
121
+ if candidate in {"false", "no", "off", "0"}:
122
+ return False
123
+ return None
124
+
125
+
126
+ def _front_matter_numbered(metadata: Mapping[str, Any] | None) -> bool | None:
127
+ """Extract a numbered flag from normalised front matter."""
128
+ if not isinstance(metadata, Mapping):
129
+ return None
130
+
131
+ payload = dict(metadata)
132
+ with contextlib.suppress(PressMetadataError):
133
+ normalise_press_metadata(payload)
134
+ return _coerce_bool(payload.get("numbered"))
135
+
136
+
137
+ @dataclass(slots=True)
138
+ class DocumentRenderOptions:
139
+ """Rendering options applied when preparing a document context."""
140
+
141
+ base_level: int = 0
142
+ title_strategy: TitleStrategy = TitleStrategy.KEEP
143
+ numbered: bool = True
144
+ suppress_title_metadata: bool = False
145
+
146
+ def copy(self) -> DocumentRenderOptions:
147
+ """Return a deep copy of the options so mutations never leak across documents."""
148
+ return DocumentRenderOptions(
149
+ base_level=self.base_level,
150
+ title_strategy=self.title_strategy,
151
+ numbered=self.numbered,
152
+ suppress_title_metadata=self.suppress_title_metadata,
153
+ )
154
+
155
+
156
+ class DocumentSlots:
157
+ """Container tracking slot selectors and inclusion directives."""
158
+
159
+ __slots__ = ("_inclusions", "_selectors")
160
+
161
+ _WILDCARDS: ClassVar[set[str]] = {
162
+ DOCUMENT_SELECTOR_SENTINEL,
163
+ DOCUMENT_SELECTOR_SENTINEL.lower(),
164
+ "*",
165
+ }
166
+
167
+ def __init__(
168
+ self,
169
+ selectors: Mapping[str, str] | None = None,
170
+ inclusions: Iterable[str] | None = None,
171
+ ) -> None:
172
+ self._selectors: dict[str, str] = dict(selectors or {})
173
+ self._inclusions: set[str] = {slot.strip() for slot in inclusions or [] if slot}
174
+
175
+ def copy(self) -> DocumentSlots:
176
+ """Return a detached clone so callers can mutate slots without side effects."""
177
+ return DocumentSlots(self._selectors, self._inclusions)
178
+
179
+ @classmethod
180
+ def from_mapping(cls, mapping: Mapping[str, str] | None) -> DocumentSlots:
181
+ """Build slots from stored metadata, normalising empty mappings to defaults."""
182
+ slots = cls()
183
+ if not mapping:
184
+ return slots
185
+ for slot, selector in mapping.items():
186
+ slots.add(slot, selector=selector)
187
+ return slots
188
+
189
+ def merge(self, other: DocumentSlots) -> DocumentSlots:
190
+ """Combine selectors and inclusions to preserve caller overrides."""
191
+ if other is self:
192
+ return self
193
+ self._selectors.update(other._selectors)
194
+ self._inclusions.update(other._inclusions)
195
+ return self
196
+
197
+ def add(
198
+ self,
199
+ slot: str,
200
+ *,
201
+ selector: str | None = None,
202
+ include_document: bool | None = None,
203
+ ) -> DocumentSlots:
204
+ """Register a slot directive, preserving explicit inclusion intent."""
205
+ slot_name = slot.strip()
206
+ if not slot_name:
207
+ return self
208
+
209
+ token = selector.strip() if isinstance(selector, str) else None
210
+ include_flag = include_document
211
+
212
+ if token:
213
+ if token.lower() in self._WILDCARDS:
214
+ include_flag = True if include_document is not False else include_document
215
+ token = None
216
+ else:
217
+ self._selectors[slot_name] = token
218
+ elif selector is None and include_flag is None:
219
+ include_flag = True
220
+
221
+ if include_flag is True:
222
+ self._inclusions.add(slot_name)
223
+ elif include_flag is False:
224
+ self._inclusions.discard(slot_name)
225
+
226
+ if selector is None and include_flag is False:
227
+ self._selectors.pop(slot_name, None)
228
+
229
+ return self
230
+
231
+ def includes(self) -> set[str]:
232
+ """Return slot names flagged for whole-document inclusion."""
233
+ return set(self._inclusions)
234
+
235
+ def selectors(self) -> dict[str, str]:
236
+ """Return selectors mapped to slots, leaving the original untouched."""
237
+ return dict(self._selectors)
238
+
239
+ def to_request_mapping(self) -> dict[str, str]:
240
+ """Render selectors into the request mapping expected by the engine."""
241
+ mapping = dict(self._selectors)
242
+ for slot in self._inclusions:
243
+ mapping.setdefault(slot, DOCUMENT_SELECTOR_SENTINEL)
244
+ return mapping
245
+
246
+ def __bool__(self) -> bool: # pragma: no cover - trivial
247
+ return bool(self._selectors or self._inclusions)
248
+
249
+
250
+ @dataclass(slots=True)
251
+ class Document:
252
+ """Renderable document used by the high-level API."""
253
+
254
+ source_path: Path
255
+ kind: InputKind
256
+ _html: str
257
+ _front_matter: Mapping[str, Any]
258
+ options: DocumentRenderOptions = field(default_factory=DocumentRenderOptions)
259
+ slots: DocumentSlots = field(default_factory=DocumentSlots)
260
+
261
+ @classmethod
262
+ def from_markdown(
263
+ cls,
264
+ path: Path,
265
+ *,
266
+ extensions: Iterable[str] | None = None,
267
+ promote_title: bool = False,
268
+ strip_heading: bool = False,
269
+ suppress_title: bool = False,
270
+ base_level: int | str = 0,
271
+ title_strategy: TitleStrategy | None = None,
272
+ numbered: bool = True,
273
+ emitter: DiagnosticEmitter | None = None,
274
+ ) -> Document:
275
+ """Create a document from a Markdown file while caching HTML for reuse."""
276
+ active_emitter = emitter or NullEmitter()
277
+
278
+ try:
279
+ rendered = render_markdown(
280
+ path.read_text(encoding="utf-8"),
281
+ list(extensions or DEFAULT_MARKDOWN_EXTENSIONS),
282
+ base_path=path.parent,
283
+ )
284
+ except (OSError, MarkdownConversionError) as exc:
285
+ message = f"Failed to convert Markdown source '{path}': {exc}"
286
+ active_emitter.error(message, exc if isinstance(exc, Exception) else None)
287
+ raise ConversionError(message) from (
288
+ exc if isinstance(exc, Exception) else ConversionError(message)
289
+ )
290
+
291
+ try:
292
+ resolved_base_level = coerce_base_level(base_level, allow_none=False)
293
+ except Exception as exc: # pragma: no cover - defensive
294
+ message = f"Invalid base level '{base_level}': {exc}"
295
+ active_emitter.error(message, exc if isinstance(exc, Exception) else None)
296
+ raise ConversionError(message) from (
297
+ exc if isinstance(exc, Exception) else ConversionError(message)
298
+ )
299
+
300
+ declared_title = front_matter_has_title(rendered.front_matter)
301
+ strategy = _resolve_title_strategy(
302
+ explicit=title_strategy,
303
+ promote_title=promote_title,
304
+ strip_heading=strip_heading,
305
+ has_declared_title=declared_title,
306
+ )
307
+ front_numbered = _front_matter_numbered(rendered.front_matter)
308
+ numbered_flag = numbered if front_numbered is None else front_numbered
309
+ options = DocumentRenderOptions(
310
+ base_level=resolved_base_level,
311
+ title_strategy=strategy,
312
+ numbered=numbered_flag,
313
+ suppress_title_metadata=suppress_title,
314
+ )
315
+ document = cls(
316
+ source_path=path,
317
+ kind=InputKind.MARKDOWN,
318
+ _html=rendered.html,
319
+ _front_matter=rendered.front_matter,
320
+ options=options,
321
+ slots=DocumentSlots(),
322
+ )
323
+ document._initialise_slots_from_front_matter()
324
+ return document
325
+
326
+ @classmethod
327
+ def from_html(
328
+ cls,
329
+ path: Path,
330
+ *,
331
+ selector: str = "article.md-content__inner",
332
+ promote_title: bool = False,
333
+ strip_heading: bool = False,
334
+ suppress_title: bool = False,
335
+ base_level: int | str = 0,
336
+ title_strategy: TitleStrategy | None = None,
337
+ numbered: bool = True,
338
+ full_document: bool = False,
339
+ emitter: DiagnosticEmitter | None = None,
340
+ ) -> Document:
341
+ """Create a document from an HTML file, extracting only the renderable region."""
342
+ active_emitter = emitter or NullEmitter()
343
+
344
+ try:
345
+ payload = path.read_text(encoding="utf-8")
346
+ except OSError as exc:
347
+ message = f"Failed to read HTML document '{path}': {exc}"
348
+ active_emitter.error(message, exc)
349
+ raise ConversionError(message) from exc
350
+
351
+ html = payload
352
+ if not full_document:
353
+ try:
354
+ html = extract_content(payload, selector)
355
+ except ValueError as exc:
356
+ if debug_enabled(active_emitter):
357
+ message = (
358
+ f"CSS selector '{selector}' was not found in '{path.name}'. "
359
+ "Falling back to the full document."
360
+ )
361
+ active_emitter.warning(message, exc)
362
+
363
+ try:
364
+ resolved_base_level = coerce_base_level(base_level, allow_none=False)
365
+ except Exception as exc: # pragma: no cover - defensive
366
+ message = f"Invalid base level '{base_level}': {exc}"
367
+ active_emitter.error(message, exc if isinstance(exc, Exception) else None)
368
+ raise ConversionError(message) from (
369
+ exc if isinstance(exc, Exception) else ConversionError(message)
370
+ )
371
+
372
+ strategy = _resolve_title_strategy(
373
+ explicit=title_strategy,
374
+ promote_title=promote_title,
375
+ strip_heading=strip_heading,
376
+ has_declared_title=False,
377
+ )
378
+ options = DocumentRenderOptions(
379
+ base_level=resolved_base_level,
380
+ title_strategy=strategy,
381
+ numbered=numbered,
382
+ suppress_title_metadata=suppress_title,
383
+ )
384
+ document = cls(
385
+ source_path=path,
386
+ kind=InputKind.HTML,
387
+ _html=html,
388
+ _front_matter={},
389
+ options=options,
390
+ slots=DocumentSlots(),
391
+ )
392
+ document._initialise_slots_from_front_matter()
393
+ return document
394
+
395
+ def copy(self) -> Document:
396
+ """Return a deep copy of the document to isolate later slot or metadata changes."""
397
+ return Document(
398
+ source_path=self.source_path,
399
+ kind=self.kind,
400
+ _html=self._html,
401
+ _front_matter=copy.deepcopy(self._front_matter),
402
+ options=self.options.copy(),
403
+ slots=self.slots.copy(),
404
+ )
405
+
406
+ @property
407
+ def html(self) -> str:
408
+ """Return the intermediate HTML content."""
409
+ return self._html
410
+
411
+ @property
412
+ def front_matter(self) -> Mapping[str, Any]:
413
+ """Return a deep copy of the front-matter mapping to keep stored state immutable."""
414
+ return copy.deepcopy(self._front_matter)
415
+
416
+ def set_front_matter(self, values: Mapping[str, Any]) -> None:
417
+ """Replace the stored front matter with a deep copy to guard against caller mutation."""
418
+ self._front_matter = copy.deepcopy(values)
419
+
420
+ @property
421
+ def drop_title(self) -> bool:
422
+ """Indicate whether the document title should be dropped."""
423
+ if self.options.title_strategy is TitleStrategy.DROP:
424
+ return True
425
+ if self.options.title_strategy is TitleStrategy.PROMOTE_METADATA:
426
+ title, should_drop = self._extract_promoted_title()
427
+ if self.options.suppress_title_metadata:
428
+ return False
429
+ return bool(title and should_drop)
430
+ return False
431
+
432
+ @drop_title.setter
433
+ def drop_title(self, value: bool) -> None:
434
+ """Set whether the document title should be dropped."""
435
+ if value:
436
+ if self.options.title_strategy is TitleStrategy.PROMOTE_METADATA:
437
+ return
438
+ self.options.title_strategy = TitleStrategy.DROP
439
+ else:
440
+ if self.options.title_strategy is TitleStrategy.DROP:
441
+ self.options.title_strategy = TitleStrategy.KEEP
442
+
443
+ @property
444
+ def title_from_heading(self) -> bool:
445
+ """Indicate whether the title should be extracted from the first heading."""
446
+ return self.options.title_strategy is TitleStrategy.PROMOTE_METADATA
447
+
448
+ @title_from_heading.setter
449
+ def title_from_heading(self, value: bool) -> None:
450
+ """Set whether the title should be extracted from the first heading."""
451
+ if value:
452
+ self.options.title_strategy = TitleStrategy.PROMOTE_METADATA
453
+ elif self.options.title_strategy is TitleStrategy.PROMOTE_METADATA:
454
+ self.options.title_strategy = TitleStrategy.KEEP
455
+
456
+ @property
457
+ def numbered(self) -> bool:
458
+ """Indicate whether the document is numbered."""
459
+ return self.options.numbered
460
+
461
+ @numbered.setter
462
+ def numbered(self, value: bool) -> None:
463
+ """Set whether the document is numbered."""
464
+ self.options.numbered = bool(value)
465
+
466
+ def assign_slot(
467
+ self,
468
+ slot: str,
469
+ selector: str | None = None,
470
+ *,
471
+ include_document: bool | None = None,
472
+ ) -> None:
473
+ """Map the document or a selector subset into a template slot."""
474
+ self.slots.add(slot, selector=selector, include_document=include_document)
475
+
476
+ def _initialise_slots_from_front_matter(self) -> None:
477
+ if isinstance(self._front_matter, Mapping):
478
+ payload = dict(self._front_matter)
479
+ with contextlib.suppress(PressMetadataError):
480
+ normalise_press_metadata(payload)
481
+ base_mapping = extract_front_matter_slots(payload)
482
+ if base_mapping:
483
+ self.slots.merge(DocumentSlots.from_mapping(base_mapping))
484
+
485
+ _HEADING_TAGS: ClassVar[set[str]] = {"h1", "h2", "h3", "h4", "h5", "h6"}
486
+
487
+ class _HeadingLevelScanner(HTMLParser):
488
+ __slots__ = ("minimum_level",)
489
+
490
+ def __init__(self) -> None:
491
+ super().__init__()
492
+ self.minimum_level: int | None = None
493
+
494
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
495
+ if not tag:
496
+ return
497
+ name = tag.lower()
498
+ if not name.startswith("h") or len(name) < 2 or not name[1].isdigit():
499
+ return
500
+ try:
501
+ level = int(name[1])
502
+ except ValueError:
503
+ return
504
+ if not 1 <= level <= 6:
505
+ return
506
+ if self.minimum_level is None or level < self.minimum_level:
507
+ self.minimum_level = level
508
+
509
+ @classmethod
510
+ def _resolve_heading_alignment(cls, html: str, strategy: TitleStrategy) -> int:
511
+ if strategy is not TitleStrategy.KEEP:
512
+ return 0
513
+
514
+ scanner = cls._HeadingLevelScanner()
515
+ try:
516
+ scanner.feed(html)
517
+ finally:
518
+ scanner.close()
519
+
520
+ minimum = scanner.minimum_level
521
+ if minimum is None or minimum <= 1:
522
+ return 0
523
+ return minimum - 1
524
+
525
+ class _HeadingInspector(HTMLParser):
526
+ __slots__ = ("_depth", "_resolved", "first_level", "level_counts", "parts")
527
+
528
+ def __init__(self) -> None:
529
+ super().__init__()
530
+ self._depth = 0
531
+ self._resolved = False
532
+ self.first_level: int | None = None
533
+ self.level_counts: dict[int, int] = {}
534
+ self.parts: list[str] = []
535
+
536
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
537
+ name = tag.lower()
538
+ if not name.startswith("h") or len(name) < 2 or not name[1].isdigit():
539
+ if self._depth:
540
+ self._depth += 1
541
+ return
542
+
543
+ try:
544
+ level = int(name[1])
545
+ except ValueError:
546
+ if self._depth:
547
+ self._depth += 1
548
+ return
549
+
550
+ if not 1 <= level <= 6:
551
+ if self._depth:
552
+ self._depth += 1
553
+ return
554
+
555
+ self.level_counts[level] = self.level_counts.get(level, 0) + 1
556
+ if self._resolved:
557
+ return
558
+
559
+ if self.first_level is None:
560
+ self.first_level = level
561
+ self._depth = 1
562
+ return
563
+
564
+ if self._depth:
565
+ self._depth += 1
566
+
567
+ def handle_endtag(self, tag: str) -> None:
568
+ if not self._depth:
569
+ return
570
+ self._depth -= 1
571
+ if self._depth == 0:
572
+ self._resolved = True
573
+
574
+ def handle_data(self, data: str) -> None:
575
+ if self._depth and not self._resolved:
576
+ self.parts.append(data)
577
+
578
+ def _extract_promoted_title(self) -> tuple[str | None, bool]:
579
+ """Return the promoted title and whether the heading should be dropped."""
580
+ inspector = self._HeadingInspector()
581
+ try:
582
+ inspector.feed(self._html)
583
+ finally:
584
+ inspector.close()
585
+
586
+ if inspector.first_level is None:
587
+ return None, False
588
+
589
+ level_count = inspector.level_counts.get(inspector.first_level, 0)
590
+ if level_count != 1:
591
+ return None, False
592
+
593
+ text = "".join(inspector.parts).strip()
594
+ return (text or None, bool(text))
595
+
596
+ def _first_heading_level(self) -> int | None:
597
+ """Return the level of the first heading in the document, if any."""
598
+ inspector = self._HeadingInspector()
599
+ try:
600
+ inspector.feed(self._html)
601
+ finally:
602
+ inspector.close()
603
+ return inspector.first_level
604
+
605
+ def first_heading_level(self) -> int | None:
606
+ """Public accessor for the first heading level in the document."""
607
+ return self._first_heading_level()
608
+
609
+ def to_context(self) -> DocumentContext:
610
+ """Build a fresh DocumentContext for conversion, aligning slots with engine expectations."""
611
+ strategy = self.options.title_strategy
612
+ suppress_title = self.options.suppress_title_metadata
613
+ promote_to_metadata = strategy is TitleStrategy.PROMOTE_METADATA and not suppress_title
614
+ extracted_title = None
615
+ drop_title_flag = strategy is TitleStrategy.DROP
616
+
617
+ if promote_to_metadata:
618
+ extracted_title, drop_title_flag = self._extract_promoted_title()
619
+
620
+ base_level = self.options.base_level
621
+ front_matter = copy.deepcopy(self._front_matter)
622
+ if suppress_title and isinstance(front_matter, dict):
623
+ front_matter.pop("title", None)
624
+ front_matter.pop("press.title", None)
625
+ press_section = front_matter.get("press")
626
+ if isinstance(press_section, dict):
627
+ press_section.pop("title", None)
628
+
629
+ context = build_document_context(
630
+ name=self.source_path.stem,
631
+ source_path=self.source_path,
632
+ html=self._html,
633
+ front_matter=front_matter,
634
+ base_level=base_level,
635
+ drop_title=drop_title_flag,
636
+ numbered=self.options.numbered,
637
+ title_from_heading=bool(extracted_title),
638
+ extracted_title=extracted_title,
639
+ )
640
+ base_slots = DocumentSlots.from_mapping(context.slot_requests)
641
+ combined_slots = base_slots.copy().merge(self.slots)
642
+ self.slots = combined_slots.copy()
643
+ context.slot_requests = combined_slots.to_request_mapping()
644
+ context.slot_inclusions = combined_slots.includes()
645
+ return context