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,99 @@
1
+ """Built-in callout definitions and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+
9
+ CalloutConfig = dict[str, Any]
10
+
11
+ DEFAULT_CALLOUTS: dict[str, CalloutConfig] = {
12
+ "note": {"background_color": "ecf3ff", "border_color": "448aff", "icon": "📝"},
13
+ "abstract": {"background_color": "e5f7ff", "border_color": "00b0ff", "icon": "📄"},
14
+ "summary": {"background_color": "e5f8fb", "border_color": "00b8d4", "icon": "📋"},
15
+ "info": {"background_color": "e5f8fb", "border_color": "00b8d4", "icon": "ℹ"}, # noqa: RUF001
16
+ "tip": {"background_color": "e5f8f6", "border_color": "00bfa5", "icon": "⭐"},
17
+ "success": {"background_color": "e5f9ed", "border_color": "00c853", "icon": "✅"},
18
+ "question": {"background_color": "effce7", "border_color": "64dd17", "icon": "❓"},
19
+ "warning": {"background_color": "fff1d6", "border_color": "ffb200", "icon": "⚠"},
20
+ "caution": {"background_color": "fff8e1", "border_color": "ff9100", "icon": "🚧"},
21
+ "failure": {"background_color": "ffeded", "border_color": "ff5252", "icon": "❗"},
22
+ "danger": {"background_color": "ffe2e7", "border_color": "c62828", "icon": "🔥"},
23
+ "important": {"background_color": "ffe0f0", "border_color": "d81b60", "icon": "❗"},
24
+ "bug": {"background_color": "fee5ee", "border_color": "f50057", "icon": "🐞"},
25
+ "example": {"background_color": "f2edff", "border_color": "7c4dff", "icon": "🧪"},
26
+ "quote": {"background_color": "f5f5f5", "border_color": "9e9e9e", "icon": "✒️"},
27
+ "hint": {"background_color": "e5f8f6", "border_color": "00bfa5", "icon": "💡"},
28
+ "default": {"background_color": "f0f0f0", "border_color": "808080", "icon": "🎤"},
29
+ }
30
+
31
+
32
+ def _flatten_callouts(definitions: Mapping[str, Any]) -> dict[str, CalloutConfig]:
33
+ """Flatten nested callout definitions declared under arbitrary keys."""
34
+ flat: dict[str, CalloutConfig] = {}
35
+ for name, cfg in definitions.items():
36
+ if not isinstance(cfg, Mapping):
37
+ continue
38
+ if {"icon", "background_color", "border_color", "title_color"} & set(cfg):
39
+ flat[name] = dict(cfg)
40
+ continue
41
+ nested = _flatten_callouts(cfg)
42
+ for child_name, child_cfg in nested.items():
43
+ flat[child_name] = child_cfg
44
+ return flat
45
+
46
+
47
+ def merge_callouts(
48
+ base: Mapping[str, CalloutConfig],
49
+ overrides: Mapping[str, Any] | None = None,
50
+ ) -> dict[str, CalloutConfig]:
51
+ """Merge callout definitions with user-provided overrides."""
52
+ combined: dict[str, CalloutConfig] = {
53
+ key: dict(value) for key, value in _flatten_callouts(base).items()
54
+ }
55
+ if not overrides:
56
+ return combined
57
+ for key, cfg in _flatten_callouts(overrides).items():
58
+ merged = dict(combined.get(key, {}))
59
+ for name, value in cfg.items():
60
+ if value is None:
61
+ continue
62
+ merged[name] = value
63
+ combined[key] = merged
64
+ return combined
65
+
66
+
67
+ def _normalise_color(value: Any) -> str | None:
68
+ if value is None:
69
+ return None
70
+ if isinstance(value, int):
71
+ return f"{value:06X}"
72
+ if isinstance(value, str):
73
+ stripped = value.strip().lstrip("#").lstrip("0x").lstrip("0X")
74
+ if not stripped:
75
+ return None
76
+ try:
77
+ parsed = int(stripped, 16)
78
+ except ValueError:
79
+ return None
80
+ return f"{parsed:06X}"
81
+ return None
82
+
83
+
84
+ def normalise_callouts(definitions: Mapping[str, CalloutConfig]) -> dict[str, CalloutConfig]:
85
+ """Return a copy of callouts with hex color strings normalised."""
86
+ normalised: dict[str, CalloutConfig] = {}
87
+ for name, cfg in definitions.items():
88
+ if not isinstance(cfg, Mapping):
89
+ continue
90
+ updated: CalloutConfig = dict(cfg)
91
+ for key in ("background_color", "border_color", "title_color"):
92
+ colour = _normalise_color(updated.get(key))
93
+ if colour:
94
+ updated[key] = colour
95
+ normalised[name] = updated
96
+ return normalised
97
+
98
+
99
+ __all__ = ["DEFAULT_CALLOUTS", "CalloutConfig", "merge_callouts"]
@@ -0,0 +1,191 @@
1
+ """Configuration models used by the LaTeX renderer.
2
+
3
+ CommonConfig
4
+
5
+ `build_dir` (`Path | None`)
6
+ : Base directory for LaTeX artifacts. Provide an absolute or project-relative
7
+ path to override the default export root that books inherit when they do not
8
+ specify one.
9
+
10
+ `save_html` (`bool`)
11
+ : Persist the intermediate HTML render next to the PDF to aid troubleshooting
12
+ before LaTeX compilation.
13
+
14
+ `mermaid_config` (`Path | None`)
15
+ : Path to a Mermaid configuration file. Point to a `.json` or `.mermaid`
16
+ document to customise diagram rendering.
17
+
18
+ `project_dir` (`Path | None`)
19
+ : MkDocs project root used to resolve relative paths when copying additional
20
+ assets.
21
+
22
+ : BCP 47 language code forwarded to LaTeX for hyphenation, translations, and
23
+ : metadata localisation.
24
+
25
+ `legacy_latex_accents` (`bool`)
26
+ : When `True`, escape accented characters, ligatures, and typographic punctuation
27
+ using legacy LaTeX macros. When `False`, keep Unicode glyphs compatible with
28
+ LuaLaTeX/XeLaTeX (default).
29
+
30
+ `language` (`str | None`)
31
+ : BCP 47 language code forwarded to LaTeX for hyphenation, translations, and
32
+ metadata localisation.
33
+
34
+ CoverConfig
35
+
36
+ `name` (`str`)
37
+ : Identifier of the cover template to apply. The value must match a template
38
+ declared in the cover bundle.
39
+
40
+ `color` (`str | None`)
41
+ : Primary colour override applied by the cover template.
42
+
43
+ `logo` (`str | None`)
44
+ : Project-relative path to a logo asset displayed on the cover.
45
+
46
+ BookConfig
47
+
48
+ `root` (`str | None`)
49
+ : Navigation entry treated as the starting point for the book. Use it when the
50
+ root differs from the first MkDocs page.
51
+
52
+ `title` (`str | None`)
53
+ : Title displayed on the cover and in output metadata. Falls back to `site_name`
54
+ when omitted.
55
+
56
+ `subtitle` (`str | None`)
57
+ : Optional subtitle appended to the cover and metadata.
58
+
59
+ `author` (`str | None`)
60
+ : Primary author string rendered in the book metadata.
61
+
62
+ `year` (`int | None`)
63
+ : Publication year to freeze in the output when `site_date` is not supplied.
64
+
65
+ `email` (`str | None`)
66
+ : Contact address printed in the credits.
67
+
68
+ `folder` (`Path | None`)
69
+ : Output directory for the rendered book. Defaults to a slug of the title when
70
+ not provided.
71
+
72
+ `frontmatter` (`list[str]`)
73
+ : MkDocs page titles moved before the main matter.
74
+
75
+ `backmatter` (`list[str]`)
76
+ : MkDocs page titles grouped into the appendices.
77
+
78
+ `base_level` (`int`)
79
+ : Heading offset applied to align section numbering with the template
80
+ expectations.
81
+
82
+ `copy_files` (`dict[str, str]`)
83
+ : Mapping of glob patterns to destination paths for copying additional assets
84
+ alongside the book.
85
+
86
+ `index_is_foreword` (`bool`)
87
+ : Treat the `index` page as a foreword, typically removing numbering.
88
+
89
+ `drop_title_index` (`bool`)
90
+ : Suppress the `index` page heading when it acts as a foreword.
91
+
92
+ `cover` (`CoverConfig`)
93
+ : Nested configuration controlling the book cover.
94
+
95
+ LaTeXConfig
96
+
97
+ `enabled` (`bool`)
98
+ : Toggle LaTeX generation without discarding configuration.
99
+
100
+ `books` (`list[BookConfig]`)
101
+ : Collection of books to produce, inheriting defaults from `CommonConfig`.
102
+
103
+ `clean_assets` (`bool`)
104
+ : Remove stale assets from `build_dir` to avoid accumulating unused files.
105
+ """
106
+
107
+ from __future__ import annotations
108
+
109
+ from pathlib import Path
110
+ from typing import Any
111
+
112
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
113
+ from slugify import slugify
114
+
115
+
116
+ class CommonConfig(BaseModel):
117
+ """Common configuration propagated to each book."""
118
+
119
+ model_config = ConfigDict(extra="forbid")
120
+
121
+ build_dir: Path | None = None
122
+ save_html: bool = False
123
+ mermaid_config: Path | None = None
124
+ project_dir: Path | None = None
125
+ language: str | None = None
126
+ legacy_latex_accents: bool = False
127
+
128
+
129
+ class CoverConfig(BaseModel):
130
+ """Metadata used to render book covers."""
131
+
132
+ model_config = ConfigDict(extra="forbid")
133
+
134
+ name: str = Field(default="circles", description="Cover template name")
135
+ color: str | None = Field(default="black", description="Primary color")
136
+ logo: str | None = Field(default=None, description="Logo path")
137
+
138
+
139
+ class BookConfig(CommonConfig):
140
+ """Configuration for an individual book."""
141
+
142
+ root: str | None = None
143
+ title: str | None = None
144
+ subtitle: str | None = None
145
+ author: str | None = None
146
+ year: int | None = None
147
+ email: str | None = None
148
+ folder: Path | None = None
149
+ frontmatter: list[str] = Field(default_factory=list)
150
+ backmatter: list[str] = Field(default_factory=list)
151
+ base_level: int = -2
152
+ copy_files: dict[str, str] = Field(default_factory=dict)
153
+ index_is_foreword: bool = False
154
+ drop_title_index: bool = False
155
+ cover: CoverConfig = Field(default_factory=CoverConfig)
156
+
157
+ @model_validator(mode="after")
158
+ def set_folder(self) -> BookConfig:
159
+ """Populate the output folder from the book title when missing."""
160
+ if self.folder is None and self.title:
161
+ self.folder = Path(slugify(self.title, separator="-"))
162
+ return self
163
+
164
+
165
+ class LaTeXConfig(CommonConfig):
166
+ """Configuration for LaTeX taken from ``mkdocs.yml``."""
167
+
168
+ enabled: bool = True
169
+ books: list[BookConfig] = Field(default_factory=lambda: [BookConfig()])
170
+ clean_assets: bool = True
171
+
172
+ @model_validator(mode="after")
173
+ def propagate(self) -> LaTeXConfig:
174
+ """Propagate common values to nested book configurations."""
175
+ to_propagate = (
176
+ "build_dir",
177
+ "mermaid_config",
178
+ "save_html",
179
+ "project_dir",
180
+ "language",
181
+ )
182
+ for book in self.books:
183
+ for key in to_propagate:
184
+ if getattr(book, key) is None:
185
+ setattr(book, key, getattr(self, key))
186
+ return self
187
+
188
+ def add_extra(self, **extra_data: Any) -> None:
189
+ """Allow consumers to attach additional attributes at runtime."""
190
+ for key, value in extra_data.items():
191
+ object.__setattr__(self, key, value)
@@ -0,0 +1,242 @@
1
+ """Rendering context primitives shared across the LaTeX pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Iterable, MutableMapping
7
+ from dataclasses import dataclass, field
8
+ import os
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+ import warnings
12
+
13
+ from slugify import slugify
14
+
15
+ from .exceptions import AssetMissingError
16
+
17
+
18
+ if TYPE_CHECKING: # pragma: no cover - typing only
19
+ from texsmith.adapters.latex.formatter import LaTeXFormatter
20
+
21
+ from .config import BookConfig
22
+ from .rules import RenderPhase
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class DocumentState:
27
+ """In-memory state accumulated while rendering a document."""
28
+
29
+ abbreviations: dict[str, str] = field(default_factory=dict)
30
+ acronym_keys: dict[str, str] = field(default_factory=dict)
31
+ acronyms: dict[str, tuple[str, str]] = field(default_factory=dict)
32
+ glossary: dict[str, dict[str, Any]] = field(default_factory=dict)
33
+ snippets: dict[str, dict[str, Any]] = field(default_factory=dict)
34
+ solutions: list[dict[str, Any]] = field(default_factory=list)
35
+ headings: list[dict[str, Any]] = field(default_factory=list)
36
+ exercise_counter: int = 0
37
+ has_index_entries: bool = False
38
+ requires_shell_escape: bool = False
39
+ counters: dict[str, int] = field(default_factory=dict)
40
+ bibliography: dict[str, dict[str, Any]] = field(default_factory=dict)
41
+ citations: list[str] = field(default_factory=list)
42
+ _citation_index: set[str] = field(default_factory=set, init=False, repr=False)
43
+ footnotes: dict[str, str] = field(default_factory=dict)
44
+ index_entries: list[tuple[str, ...]] = field(default_factory=list)
45
+ pygments_styles: dict[str, str] = field(default_factory=dict)
46
+ script_usage: list[dict[str, Any]] = field(default_factory=list)
47
+ fallback_summary: list[dict[str, Any]] = field(default_factory=list)
48
+ callouts_used: bool = False
49
+
50
+ def remember_acronym(self, term: str, description: str) -> str:
51
+ """Register an acronym definition keyed by a normalised identifier."""
52
+ return self.remember_abbreviation(term=term, description=description)
53
+
54
+ def remember_abbreviation(self, term: str, description: str) -> str:
55
+ """Track abbreviation definitions while ensuring consistency."""
56
+ normalised_term = term.strip()
57
+ normalised_description = description.strip()
58
+ if not normalised_term or not normalised_description:
59
+ return ""
60
+
61
+ existing_description = self.abbreviations.get(normalised_term)
62
+ if existing_description is not None:
63
+ if existing_description != normalised_description:
64
+ warnings.warn(
65
+ (
66
+ f"Inconsistent acronym definition for '{normalised_term}': "
67
+ f"'{existing_description}' vs '{normalised_description}'"
68
+ ),
69
+ stacklevel=2,
70
+ )
71
+ return self.acronym_keys.get(normalised_term, "")
72
+
73
+ key = self._generate_acronym_key(normalised_term)
74
+ self.abbreviations[normalised_term] = normalised_description
75
+ self.acronym_keys[normalised_term] = key
76
+ self.acronyms[key] = (normalised_term, normalised_description)
77
+ return key
78
+
79
+ def _generate_acronym_key(self, term: str) -> str:
80
+ """Produce a unique key suitable for the glossaries package."""
81
+ slug = slugify(term, separator="", lowercase=False)
82
+ if not slug:
83
+ slug = "acronym"
84
+ candidate = slug
85
+ suffix = 2
86
+ while candidate in self.acronyms:
87
+ candidate = f"{slug}{suffix}"
88
+ suffix += 1
89
+ return candidate
90
+
91
+ def remember_glossary(self, key: str, entry: dict[str, Any]) -> None:
92
+ """Record a glossary entry keyed by its identifier."""
93
+ self.glossary[key] = entry
94
+
95
+ def register_snippet(self, key: str, payload: dict[str, Any]) -> None:
96
+ """Cache snippet metadata to render later in the pipeline."""
97
+ self.snippets[key] = payload
98
+
99
+ def add_solution(self, solution: dict[str, Any]) -> None:
100
+ """Append a solution block encountered during parsing."""
101
+ self.solutions.append(solution)
102
+
103
+ def add_heading(self, *, level: int, text: str, ref: str | None = None) -> None:
104
+ """Track heading metadata to power table-of-contents generation."""
105
+ self.headings.append({"level": level, "text": text, "ref": ref})
106
+
107
+ def next_exercise(self) -> int:
108
+ """Increment and return the exercise counter."""
109
+ counter = self.next_counter("exercise")
110
+ self.exercise_counter = counter
111
+ return counter
112
+
113
+ def next_counter(self, key: str = "default") -> int:
114
+ """Increment and return the named counter."""
115
+ value = self.counters.get(key, 0) + 1
116
+ self.counters[key] = value
117
+ return value
118
+
119
+ def peek_counter(self, key: str = "default") -> int:
120
+ """Return the current value of the named counter without modifying it."""
121
+ return self.counters.get(key, 0)
122
+
123
+ def reset_counter(self, key: str) -> None:
124
+ """Clear the named counter if it has been tracked."""
125
+ self.counters.pop(key, None)
126
+
127
+ def record_citation(self, key: str) -> None:
128
+ """Track citation keys used throughout the document."""
129
+ if key in self._citation_index:
130
+ return
131
+ self._citation_index.add(key)
132
+ self.citations.append(key)
133
+
134
+
135
+ @dataclass(slots=True)
136
+ class AssetRegistry:
137
+ """Centralised registry for rendered assets."""
138
+
139
+ output_root: Path
140
+ assets_map: MutableMapping[str, Path] = field(default_factory=dict)
141
+ copy_assets: bool = True
142
+
143
+ def register(self, key: str, artefact: Path | str) -> Path:
144
+ """Register a generated artefact and return its resolved path."""
145
+ path = Path(artefact)
146
+ if not path.is_absolute():
147
+ path = (self.output_root / path).resolve() if self.copy_assets else Path(path)
148
+ self.assets_map[key] = path
149
+ return path
150
+
151
+ def lookup(self, key: str) -> Path | None:
152
+ """Return a previously registered artefact when available."""
153
+ stored = self.assets_map.get(key)
154
+ return Path(stored) if stored is not None else None
155
+
156
+ def get(self, key: str) -> Path:
157
+ """Retrieve a previously registered artefact."""
158
+ try:
159
+ return Path(self.assets_map[key])
160
+ except KeyError as exc:
161
+ raise AssetMissingError(f"Missing asset '{key}'") from exc
162
+
163
+ def items(self) -> Iterable[tuple[str, Path]]:
164
+ """Iterate over registered assets yielding key/path pairs."""
165
+ return ((k, Path(v)) for k, v in self.assets_map.items())
166
+
167
+ def latex_path(self, path: Path | str) -> str:
168
+ """Return a LaTeX-friendly path for an artefact."""
169
+ candidate = Path(path)
170
+ if not candidate.is_absolute():
171
+ return candidate.as_posix()
172
+
173
+ output_dir = self.output_root.parent
174
+ try:
175
+ reference = candidate.relative_to(output_dir)
176
+ except ValueError:
177
+ try:
178
+ reference = Path(os.path.relpath(candidate, output_dir))
179
+ except ValueError:
180
+ reference = candidate
181
+
182
+ return reference.as_posix()
183
+
184
+
185
+ @dataclass
186
+ class RenderContext:
187
+ """Shared context passed to every handler during rendering."""
188
+
189
+ config: BookConfig
190
+ formatter: LaTeXFormatter
191
+ document: Any
192
+ assets: AssetRegistry
193
+ state: DocumentState = field(default_factory=DocumentState)
194
+ runtime: dict[str, Any] = field(default_factory=dict)
195
+ phase: RenderPhase | None = None
196
+
197
+ _processed_nodes: defaultdict[int, set[int]] = field(
198
+ default_factory=lambda: defaultdict(set), init=False
199
+ )
200
+ _skip_children: defaultdict[int, set[int]] = field(
201
+ default_factory=lambda: defaultdict(set), init=False
202
+ )
203
+ _persistent_runtime: dict[str, Any] = field(default_factory=dict, init=False)
204
+
205
+ def enter_phase(self, phase: RenderPhase) -> None:
206
+ """Mark the current phase and reset transient runtime data."""
207
+ self.phase = phase
208
+ self.runtime = dict(self._persistent_runtime)
209
+ self._skip_children[phase.value].clear()
210
+
211
+ def attach_runtime(self, **runtime: Any) -> None:
212
+ """Attach ad-hoc data visible to handlers for the running phase."""
213
+ self._persistent_runtime.update(runtime)
214
+ self.runtime.update(runtime)
215
+
216
+ def mark_processed(self, node: Any, *, phase: RenderPhase | None = None) -> None:
217
+ """Flag a node as already transformed for the selected phase."""
218
+ label = phase or self.phase
219
+ if label is None:
220
+ return
221
+ self._processed_nodes[label.value].add(id(node))
222
+
223
+ def is_processed(self, node: Any, *, phase: RenderPhase | None = None) -> bool:
224
+ """Check whether a node has been processed in the given phase."""
225
+ label = phase or self.phase
226
+ if label is None:
227
+ return False
228
+ return id(node) in self._processed_nodes[label.value]
229
+
230
+ def suppress_children(self, node: Any, *, phase: RenderPhase | None = None) -> None:
231
+ """Prevent traversal of node children for the active phase."""
232
+ label = phase or self.phase
233
+ if label is None:
234
+ return
235
+ self._skip_children[label.value].add(id(node))
236
+
237
+ def should_skip_children(self, node: Any, *, phase: RenderPhase | None = None) -> bool:
238
+ """Check whether children should be skipped during traversal."""
239
+ label = phase or self.phase
240
+ if label is None:
241
+ return False
242
+ return id(node) in self._skip_children[label.value]
@@ -0,0 +1,103 @@
1
+ """Public gateway into the low-level conversion engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from texsmith.core.conversion_contexts import (
6
+ BinderContext,
7
+ DocumentContext,
8
+ GenerationStrategy,
9
+ SegmentContext,
10
+ )
11
+ from texsmith.core.templates import (
12
+ DEFAULT_TEMPLATE_LANGUAGE,
13
+ TemplateBinding,
14
+ TemplateRuntime,
15
+ build_template_overrides,
16
+ load_template_runtime,
17
+ resolve_template_language,
18
+ )
19
+
20
+ from .core import (
21
+ ConversionResult,
22
+ attempt_transformer_fallback,
23
+ convert_document,
24
+ copy_document_state,
25
+ ensure_fallback_converters,
26
+ render_with_fallback,
27
+ )
28
+ from .debug import (
29
+ ConversionError,
30
+ DiagnosticEmitter,
31
+ LoggingEmitter,
32
+ NullEmitter,
33
+ debug_enabled,
34
+ ensure_emitter,
35
+ format_rendering_error,
36
+ persist_debug_artifacts,
37
+ raise_conversion_error,
38
+ record_event,
39
+ )
40
+ from .inputs import (
41
+ DOCUMENT_SELECTOR_SENTINEL,
42
+ InputKind,
43
+ UnsupportedInputError,
44
+ build_document_context,
45
+ coerce_slot_selector,
46
+ extract_content,
47
+ extract_front_matter_bibliography,
48
+ extract_front_matter_slots,
49
+ parse_slot_mapping,
50
+ )
51
+ from .renderer import (
52
+ FragmentOverrideError,
53
+ TemplateFragment,
54
+ TemplateRenderer,
55
+ TemplateRendererResult,
56
+ )
57
+ from .templates import build_binder_context, extract_slot_fragments, heading_level_for
58
+
59
+
60
+ __all__ = [
61
+ "DEFAULT_TEMPLATE_LANGUAGE",
62
+ "DOCUMENT_SELECTOR_SENTINEL",
63
+ "BinderContext",
64
+ "ConversionError",
65
+ "ConversionResult",
66
+ "DiagnosticEmitter",
67
+ "DocumentContext",
68
+ "FragmentOverrideError",
69
+ "GenerationStrategy",
70
+ "InputKind",
71
+ "LoggingEmitter",
72
+ "NullEmitter",
73
+ "SegmentContext",
74
+ "TemplateBinding",
75
+ "TemplateFragment",
76
+ "TemplateRenderer",
77
+ "TemplateRendererResult",
78
+ "TemplateRuntime",
79
+ "UnsupportedInputError",
80
+ "attempt_transformer_fallback",
81
+ "build_binder_context",
82
+ "build_document_context",
83
+ "build_template_overrides",
84
+ "coerce_slot_selector",
85
+ "convert_document",
86
+ "copy_document_state",
87
+ "debug_enabled",
88
+ "ensure_emitter",
89
+ "ensure_fallback_converters",
90
+ "extract_content",
91
+ "extract_front_matter_bibliography",
92
+ "extract_front_matter_slots",
93
+ "extract_slot_fragments",
94
+ "format_rendering_error",
95
+ "heading_level_for",
96
+ "load_template_runtime",
97
+ "parse_slot_mapping",
98
+ "persist_debug_artifacts",
99
+ "raise_conversion_error",
100
+ "record_event",
101
+ "render_with_fallback",
102
+ "resolve_template_language",
103
+ ]