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,526 @@
1
+ """Geometry helpers scoped to the ts-geometry fragment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal
8
+
9
+ from pint import DimensionalityError, UndefinedUnitError, UnitRegistry
10
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
11
+
12
+ from texsmith.core.templates.manifest import TemplateError
13
+
14
+
15
+ DEFAULT_MARGIN: str | None = None
16
+ _FORMAT_SUFFIX = "paper"
17
+ _KNOWN_FORMATS = {f"{family}{index}" for family in ("a", "b", "c") for index in range(0, 7)} | {
18
+ "letter",
19
+ "legal",
20
+ "executive",
21
+ }
22
+ _UNIT_REGISTRY = UnitRegistry()
23
+ _LENGTH_DIMENSION = _UNIT_REGISTRY.mm.dimensionality
24
+
25
+
26
+ def _format_magnitude(value: float) -> str:
27
+ if value.is_integer():
28
+ return str(int(value))
29
+ return f"{value:.6g}"
30
+
31
+
32
+ def _normalise_dimension(value: Any) -> str:
33
+ """Return a LaTeX dimension string, converting bare numbers to millimetres."""
34
+ if isinstance(value, (int, float)):
35
+ return f"{value}mm"
36
+ if isinstance(value, str):
37
+ stripped = value.strip()
38
+ if not stripped:
39
+ return ""
40
+ try:
41
+ numeric_value = float(stripped)
42
+ except ValueError:
43
+ numeric_value = None
44
+ else:
45
+ return f"{_format_magnitude(numeric_value)}mm"
46
+
47
+ try:
48
+ quantity = _UNIT_REGISTRY(stripped)
49
+ except (UndefinedUnitError, DimensionalityError):
50
+ quantity = None
51
+ else:
52
+ if quantity.check(_LENGTH_DIMENSION):
53
+ converted = quantity.to(_UNIT_REGISTRY.mm).magnitude
54
+ return f"{_format_magnitude(float(converted))}mm"
55
+ try:
56
+ float(stripped)
57
+ except ValueError:
58
+ raise TemplateError(f"Unsupported dimension value '{value}'.") from None
59
+ return f"{stripped}mm"
60
+ return str(value)
61
+
62
+
63
+ def _normalise_margin(value: Any) -> str | dict[str, str] | None:
64
+ """Return a margin specification string or per-side mapping."""
65
+ if value is None:
66
+ return None
67
+ if isinstance(value, (int, float)):
68
+ return f"{value}mm"
69
+ if isinstance(value, str):
70
+ cleaned = value.strip()
71
+ if not cleaned:
72
+ return None
73
+ lowered = cleaned.lower()
74
+ if lowered == "narrow":
75
+ return _normalise_dimension("1.5cm")
76
+ if lowered == "wide":
77
+ return _normalise_dimension("3.0cm")
78
+ dim = _normalise_dimension(cleaned)
79
+ return dim or None
80
+ if isinstance(value, dict):
81
+ formatted: dict[str, str] = {}
82
+ for key, raw in value.items():
83
+ key_str = str(key).strip()
84
+ if not key_str:
85
+ continue
86
+ dim = _normalise_dimension(raw)
87
+ if dim:
88
+ formatted[key_str] = dim
89
+ return formatted or None
90
+ return None
91
+
92
+
93
+ class PaperSpec(BaseModel):
94
+ """Validated paper settings parsed from front matter."""
95
+
96
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
97
+
98
+ format: str | None = None
99
+ width: str | None = None
100
+ height: str | None = None
101
+ orientation: Literal["portrait", "landscape"] = "portrait"
102
+ margin: str | dict[str, str] | None = None
103
+ marks: bool = False
104
+ binding_offset: str | None = Field(default=None, alias="binding")
105
+ duplex: bool = True
106
+ watermark: str | None = None
107
+ extra: dict[str, Any] = Field(default_factory=dict)
108
+
109
+ @model_validator(mode="before")
110
+ @classmethod
111
+ def _coerce_payload(cls, data: Any) -> Any:
112
+ if isinstance(data, str):
113
+ return {"format": data}
114
+ if not isinstance(data, dict):
115
+ return {}
116
+ payload = dict(data)
117
+ if "paper" in payload and "format" not in payload:
118
+ payload["format"] = payload.pop("paper")
119
+ if "binding" in payload and "binding_offset" not in payload:
120
+ payload["binding_offset"] = payload.pop("binding")
121
+ if "frame" in payload and "marks" not in payload:
122
+ payload["marks"] = payload.pop("frame")
123
+ if "duplex" in payload:
124
+ payload["duplex"] = bool(payload["duplex"])
125
+ return payload
126
+
127
+ @field_validator("format")
128
+ @classmethod
129
+ def _normalise_format(cls, value: str | None) -> str | None:
130
+ if value is None:
131
+ return None
132
+ lowered = value.strip().lower()
133
+ if not lowered:
134
+ return None
135
+ if lowered.endswith(_FORMAT_SUFFIX):
136
+ lowered = lowered[: -len(_FORMAT_SUFFIX)]
137
+ if lowered and lowered not in _KNOWN_FORMATS:
138
+ raise TemplateError(
139
+ f"Unsupported paper format '{value}'. Expected one of: "
140
+ f"{', '.join(sorted(_KNOWN_FORMATS))}."
141
+ )
142
+ return lowered
143
+
144
+ @field_validator("width", "height")
145
+ @classmethod
146
+ def _format_dimensions(cls, value: Any) -> str | None:
147
+ if value is None:
148
+ return None
149
+ dim = _normalise_dimension(value)
150
+ return dim or None
151
+
152
+ @field_validator("orientation", mode="before")
153
+ @classmethod
154
+ def _format_orientation(cls, value: Any) -> Any:
155
+ if value is None:
156
+ return value
157
+ if isinstance(value, str):
158
+ candidate = value.strip().lower()
159
+ if candidate in {"vertical", "portrait"}:
160
+ return "portrait"
161
+ if candidate in {"horizontal", "landscape"}:
162
+ return "landscape"
163
+ return value
164
+
165
+ @field_validator("binding_offset", mode="before")
166
+ @classmethod
167
+ def _format_binding(cls, value: Any) -> str | None:
168
+ if value is None:
169
+ return None
170
+ dim = _normalise_dimension(value)
171
+ return dim or None
172
+
173
+ @field_validator("margin", mode="before")
174
+ @classmethod
175
+ def _format_margin(cls, value: Any) -> Any:
176
+ return _normalise_margin(value)
177
+
178
+ def to_documentclass_options(self) -> tuple[str | None, str | None]:
179
+ paper_option = f"{self.format}{_FORMAT_SUFFIX}" if self.format else None
180
+ orientation_option = "landscape" if self.orientation == "landscape" else None
181
+ return paper_option, orientation_option
182
+
183
+ def geometry_options(self, *, default_margin: str | None = DEFAULT_MARGIN) -> list[str]:
184
+ options: list[str] = []
185
+ margin_value = self.margin or _normalise_margin(default_margin)
186
+ if margin_value:
187
+ if isinstance(margin_value, str):
188
+ options.append(f"margin={margin_value}")
189
+ elif isinstance(margin_value, dict):
190
+ for key, dim in margin_value.items():
191
+ if dim:
192
+ options.append(f"{key}={dim}")
193
+
194
+ paper_option, orientation_option = self.to_documentclass_options()
195
+ if paper_option:
196
+ options.append(paper_option)
197
+ if orientation_option:
198
+ options.append(orientation_option)
199
+ if self.width:
200
+ options.append(f"paperwidth={self.width}")
201
+ if self.height:
202
+ options.append(f"paperheight={self.height}")
203
+
204
+ if self.marks:
205
+ options.append("showframe")
206
+ if self.binding_offset:
207
+ options.append(f"bindingoffset={self.binding_offset}")
208
+
209
+ for key, value in self.extra.items():
210
+ key_str = str(key).strip()
211
+ if not key_str:
212
+ continue
213
+ if isinstance(value, bool):
214
+ if value:
215
+ options.append(key_str)
216
+ continue
217
+ val_str = _normalise_dimension(value)
218
+ if val_str:
219
+ options.append(f"{key_str}={val_str}")
220
+
221
+ return options
222
+
223
+ def extra_options(self) -> list[str]:
224
+ """Return only explicitly provided extra geometry options."""
225
+ options: list[str] = []
226
+ for key, value in self.extra.items():
227
+ key_str = str(key).strip()
228
+ if not key_str:
229
+ continue
230
+ if isinstance(value, bool):
231
+ if value:
232
+ options.append(key_str)
233
+ continue
234
+ val_str = _normalise_dimension(value)
235
+ if val_str:
236
+ options.append(f"{key_str}={val_str}")
237
+ return options
238
+
239
+
240
+ @dataclass(slots=True)
241
+ class GeometryResolution:
242
+ """Resolved geometry options injected into templates and fragments."""
243
+
244
+ documentclass_options: str
245
+ paper_option: str | None
246
+ orientation_option: str | None
247
+ geometry_options: str
248
+ geometry_options_list: list[str]
249
+ geometry_extra_options: str
250
+ watermark: str | None
251
+ duplex: bool
252
+ page_width: str | None
253
+ page_height: str | None
254
+ margin_all: str | None
255
+ margin_left: str | None
256
+ margin_right: str | None
257
+ margin_top: str | None
258
+ margin_bottom: str | None
259
+ margin_inner: str | None
260
+ margin_outer: str | None
261
+ binding_offset: str | None
262
+ spec: PaperSpec
263
+
264
+
265
+ def resolve_geometry_settings(
266
+ context: Mapping[str, Any], overrides: Mapping[str, Any] | None = None
267
+ ) -> GeometryResolution:
268
+ """Resolve geometry settings from template context and overrides."""
269
+ press_section = overrides.get("press") if isinstance(overrides, Mapping) else {}
270
+ documentclass_override = (
271
+ press_section.get("documentclass") if isinstance(press_section, Mapping) else None
272
+ )
273
+ documentclass_context = context.get("documentclass")
274
+ documentclass = documentclass_override or documentclass_context
275
+ is_memoir = str(documentclass).strip().lower() == "memoir" if documentclass else False
276
+ paper_raw = press_section.get("paper") if isinstance(press_section, Mapping) else None
277
+ geometry_extra = press_section.get("geometry") if isinstance(press_section, Mapping) else None
278
+ margin_raw = press_section.get("margin") if isinstance(press_section, Mapping) else None
279
+ orientation_raw = (
280
+ press_section.get("orientation") if isinstance(press_section, Mapping) else None
281
+ )
282
+ watermark_raw = press_section.get("watermark") if isinstance(press_section, Mapping) else None
283
+ duplex_raw = press_section.get("duplex") if isinstance(press_section, Mapping) else None
284
+ binding_raw = press_section.get("binding") if isinstance(press_section, Mapping) else None
285
+ marks_raw = press_section.get("marks") if isinstance(press_section, Mapping) else None
286
+
287
+ if paper_raw is None:
288
+ paper_raw = context.get("paper")
289
+ if geometry_extra is None:
290
+ geometry_extra = context.get("geometry")
291
+ if margin_raw is None:
292
+ margin_raw = context.get("margin")
293
+ if orientation_raw is None:
294
+ orientation_raw = context.get("orientation")
295
+ if watermark_raw is None:
296
+ watermark_raw = context.get("watermark")
297
+ if duplex_raw is None:
298
+ duplex_raw = context.get("duplex")
299
+ if binding_raw is None:
300
+ binding_raw = context.get("binding")
301
+ if marks_raw is None:
302
+ marks_raw = context.get("marks")
303
+ marks_flag = _coerce_marks_flag(marks_raw)
304
+
305
+ payload: dict[str, Any] = {}
306
+ if isinstance(paper_raw, Mapping):
307
+ payload.update(paper_raw)
308
+ elif paper_raw is not None:
309
+ payload["format"] = paper_raw
310
+
311
+ if "margin" not in payload and margin_raw is not None:
312
+ payload["margin"] = margin_raw
313
+ if "orientation" not in payload and orientation_raw is not None:
314
+ payload["orientation"] = orientation_raw
315
+ if "watermark" not in payload and watermark_raw is not None:
316
+ payload["watermark"] = watermark_raw
317
+ if "duplex" not in payload and duplex_raw is not None:
318
+ payload["duplex"] = duplex_raw
319
+ if "binding" not in payload and binding_raw is not None:
320
+ payload["binding"] = binding_raw
321
+ if "marks" not in payload and marks_flag is not None:
322
+ payload["marks"] = marks_flag
323
+
324
+ if isinstance(geometry_extra, Mapping):
325
+ formatted_extra: dict[str, Any] = {}
326
+ for key, raw in geometry_extra.items():
327
+ key_str = str(key).strip()
328
+ if not key_str:
329
+ continue
330
+ if isinstance(raw, bool):
331
+ if raw:
332
+ formatted_extra[key_str] = True # type: ignore[assignment]
333
+ continue
334
+ val = _normalise_dimension(raw)
335
+ if val:
336
+ formatted_extra[key_str] = val
337
+ existing_extra = payload.get("extra") if isinstance(payload.get("extra"), Mapping) else {}
338
+ merged_extra = dict(existing_extra)
339
+ merged_extra.update(formatted_extra)
340
+ if merged_extra:
341
+ payload["extra"] = merged_extra
342
+
343
+ try:
344
+ spec = PaperSpec.model_validate(payload)
345
+ except ValidationError as exc:
346
+ raise TemplateError(f"Invalid paper settings: {exc}") from exc
347
+ geometry_options_list = spec.geometry_options() if not is_memoir else []
348
+ geometry_options = ",".join(geometry_options_list)
349
+ geometry_extra_options = ",".join(spec.extra_options()) if not is_memoir else ""
350
+ paper_option, orientation_option = spec.to_documentclass_options()
351
+ options: list[str] = []
352
+ if not is_memoir:
353
+ options = [opt for opt in (paper_option, orientation_option) if opt]
354
+ if spec.marks and "showframe" not in options:
355
+ options.append("showframe")
356
+ if spec.duplex and "twoside" not in options:
357
+ options.append("twoside")
358
+ elif not spec.duplex and "twoside" in options:
359
+ pass
360
+ documentclass_options = f"[{','.join(options)}]" if options else ""
361
+
362
+ if is_memoir:
363
+ paper_option = None
364
+ orientation_option = None
365
+
366
+ page_width, page_height = _resolve_page_dimensions(spec)
367
+ margin_all, margin_left, margin_right, margin_top, margin_bottom = _resolve_margins(spec)
368
+ if all(
369
+ value is None
370
+ for value in (margin_all, margin_left, margin_right, margin_top, margin_bottom)
371
+ ):
372
+ margin_all = "2cm"
373
+ margin_inner = margin_left
374
+ margin_outer = margin_right
375
+ if spec.binding_offset:
376
+ if margin_inner:
377
+ margin_inner = f"\\dimexpr {margin_inner} + {spec.binding_offset}\\relax"
378
+ else:
379
+ margin_inner = spec.binding_offset
380
+
381
+ return GeometryResolution(
382
+ documentclass_options=documentclass_options,
383
+ paper_option=paper_option,
384
+ orientation_option=orientation_option,
385
+ geometry_options=geometry_options,
386
+ geometry_options_list=geometry_options_list,
387
+ geometry_extra_options=geometry_extra_options,
388
+ watermark=spec.watermark,
389
+ duplex=bool(spec.duplex),
390
+ page_width=page_width,
391
+ page_height=page_height,
392
+ margin_all=margin_all,
393
+ margin_left=margin_left,
394
+ margin_right=margin_right,
395
+ margin_top=margin_top,
396
+ margin_bottom=margin_bottom,
397
+ margin_inner=margin_inner,
398
+ margin_outer=margin_outer,
399
+ binding_offset=spec.binding_offset,
400
+ spec=spec,
401
+ )
402
+
403
+
404
+ def inject_geometry_context(
405
+ context: dict[str, Any], overrides: Mapping[str, Any] | None = None
406
+ ) -> GeometryResolution:
407
+ """Populate a context dictionary with resolved geometry settings."""
408
+ resolution = resolve_geometry_settings(context, overrides)
409
+ context["documentclass_options"] = resolution.documentclass_options
410
+ context["paper_option"] = resolution.paper_option
411
+ context["orientation_option"] = resolution.orientation_option
412
+ context["geometry_options"] = resolution.geometry_options
413
+ context["geometry_options_list"] = resolution.geometry_options_list
414
+ context["geometry_extra_options"] = resolution.geometry_extra_options
415
+ context["geometry_page_width"] = resolution.page_width
416
+ context["geometry_page_height"] = resolution.page_height
417
+ context["geometry_margin_all"] = resolution.margin_all
418
+ context["geometry_margin_left"] = resolution.margin_left
419
+ context["geometry_margin_right"] = resolution.margin_right
420
+ context["geometry_margin_top"] = resolution.margin_top
421
+ context["geometry_margin_bottom"] = resolution.margin_bottom
422
+ context["geometry_margin_inner"] = resolution.margin_inner
423
+ context["geometry_margin_outer"] = resolution.margin_outer
424
+ context["geometry_binding_offset"] = resolution.binding_offset
425
+ context["geometry_duplex"] = resolution.duplex
426
+ if resolution.watermark:
427
+ context["geometry_watermark"] = resolution.watermark
428
+ else:
429
+ context.pop("geometry_watermark", None)
430
+ return resolution
431
+
432
+
433
+ def _resolve_page_dimensions(spec: PaperSpec) -> tuple[str | None, str | None]:
434
+ """Return paper width/height strings with orientation applied."""
435
+ width = spec.width
436
+ height = spec.height
437
+ if not width or not height:
438
+ format_dims = _format_dimensions(spec.format)
439
+ if format_dims is not None:
440
+ width, height = format_dims
441
+ if spec.orientation == "landscape" and width and height:
442
+ width, height = height, width
443
+ return width, height
444
+
445
+
446
+ def _format_dimensions(format_name: str | None) -> tuple[str, str] | None:
447
+ if not format_name:
448
+ return None
449
+ lookup = format_name.lower()
450
+ sizes_mm: dict[str, tuple[float, float]] = {
451
+ "a0": (841, 1189),
452
+ "a1": (594, 841),
453
+ "a2": (420, 594),
454
+ "a3": (297, 420),
455
+ "a4": (210, 297),
456
+ "a5": (148, 210),
457
+ "a6": (105, 148),
458
+ "b0": (1000, 1414),
459
+ "b1": (707, 1000),
460
+ "b2": (500, 707),
461
+ "b3": (353, 500),
462
+ "b4": (250, 353),
463
+ "b5": (176, 250),
464
+ "b6": (125, 176),
465
+ "c0": (917, 1297),
466
+ "c1": (648, 917),
467
+ "c2": (458, 648),
468
+ "c3": (324, 458),
469
+ "c4": (229, 324),
470
+ "c5": (162, 229),
471
+ "c6": (114, 162),
472
+ "letter": (215.9, 279.4),
473
+ "legal": (215.9, 355.6),
474
+ "executive": (184.15, 266.7),
475
+ }
476
+ dims = sizes_mm.get(lookup)
477
+ if dims is None:
478
+ return None
479
+ width, height = dims
480
+ return (f"{width}mm", f"{height}mm")
481
+
482
+
483
+ def _resolve_margins(
484
+ spec: PaperSpec,
485
+ ) -> tuple[str | None, str | None, str | None, str | None, str | None]:
486
+ margin_all: str | None = None
487
+ margin_left: str | None = None
488
+ margin_right: str | None = None
489
+ margin_top: str | None = None
490
+ margin_bottom: str | None = None
491
+ if isinstance(spec.margin, str):
492
+ margin_all = spec.margin
493
+ elif isinstance(spec.margin, dict):
494
+ margin_left = spec.margin.get("left") or spec.margin.get("l")
495
+ margin_right = spec.margin.get("right") or spec.margin.get("r")
496
+ margin_top = spec.margin.get("top") or spec.margin.get("t")
497
+ margin_bottom = spec.margin.get("bottom") or spec.margin.get("b")
498
+ margin_inner = spec.margin.get("inner")
499
+ margin_outer = spec.margin.get("outer")
500
+ margin_left = margin_inner or margin_left
501
+ margin_right = margin_outer or margin_right
502
+ return margin_all, margin_left, margin_right, margin_top, margin_bottom
503
+
504
+
505
+ def _coerce_marks_flag(value: Any) -> bool | None:
506
+ """Return a boolean marks flag when the payload resembles one."""
507
+ if isinstance(value, bool):
508
+ return value
509
+ if isinstance(value, (int, float)):
510
+ return bool(value)
511
+ if isinstance(value, str):
512
+ token = value.strip().lower()
513
+ if not token or token in {"false", "no", "off", "0", "none"}:
514
+ return False
515
+ if token in {"true", "yes", "on", "1", "showframe"}:
516
+ return True
517
+ return None
518
+
519
+
520
+ __all__ = [
521
+ "DEFAULT_MARGIN",
522
+ "GeometryResolution",
523
+ "PaperSpec",
524
+ "inject_geometry_context",
525
+ "resolve_geometry_settings",
526
+ ]
@@ -0,0 +1,53 @@
1
+ \BLOCK{ set is_memoir = (documentclass|default('')|lower == 'memoir') }
2
+ \BLOCK{ set duplex = geometry_duplex|default(false) }
3
+ \BLOCK{ set options_list = geometry_options_list|default([]) }
4
+ \BLOCK{ set fallback = options_list|join(',') }
5
+ \BLOCK{ set options = geometry_options|default(fallback)|trim }
6
+ \BLOCK{ if is_memoir }
7
+ \makeatletter
8
+ \BLOCK{ if duplex }
9
+ \@twosidetrue
10
+ \@mparswitchtrue
11
+ \BLOCK{ else }
12
+ \@twosidefalse
13
+ \@mparswitchfalse
14
+ \BLOCK{ endif }
15
+ \makeatother
16
+ \BLOCK{ if geometry_page_height and geometry_page_width }
17
+ \setstocksize{\VAR{geometry_page_height}}{\VAR{geometry_page_width}}
18
+ \settrimmedsize{\VAR{geometry_page_height}}{\VAR{geometry_page_width}}{*}
19
+ \BLOCK{ endif }
20
+ \BLOCK{ if geometry_margin_all|default('', true)|trim }
21
+ \setlrmarginsandblock{\VAR{geometry_margin_all}}{\VAR{geometry_margin_all}}{*}
22
+ \setulmarginsandblock{\VAR{geometry_margin_all}}{\VAR{geometry_margin_all}}{*}
23
+ \BLOCK{ else }
24
+ \BLOCK{ set left_margin = geometry_margin_inner or geometry_margin_left }
25
+ \BLOCK{ set right_margin = geometry_margin_outer or geometry_margin_right or left_margin }
26
+ \BLOCK{ set top_margin = geometry_margin_top }
27
+ \BLOCK{ set bottom_margin = geometry_margin_bottom or top_margin }
28
+ \BLOCK{ if left_margin or right_margin or top_margin or bottom_margin }
29
+ \setlrmarginsandblock{\VAR{left_margin|default('2cm', true)}}{\VAR{right_margin|default('2cm', true)}}{*}
30
+ \setulmarginsandblock{\VAR{top_margin|default('2cm', true)}}{\VAR{bottom_margin|default('2cm', true)}}{*}
31
+ \BLOCK{ endif }
32
+ \BLOCK{ endif }
33
+ \BLOCK{ if geometry_binding_offset|default('', true)|trim }
34
+ \setbinding{\VAR{geometry_binding_offset}}
35
+ \BLOCK{ endif }
36
+ \checkandfixthelayout
37
+ \BLOCK{ else }
38
+ \BLOCK{ if options }
39
+ \usepackage[\VAR{options}]{geometry}
40
+ \BLOCK{ else }
41
+ \usepackage{geometry}
42
+ \BLOCK{ endif }
43
+ \BLOCK{ endif }
44
+ \BLOCK{ if geometry_watermark|default('')|trim }
45
+ \usepackage{tikz}
46
+ % Note: xwatermark behaves poorly with LuaLaTeX/XeLaTeX and draftwatermark can
47
+ % conflict with dynamic geometry changes; implement watermark with TikZ instead.
48
+ \AddToHook{shipout/background}{%
49
+ \begin{tikzpicture}[remember picture,overlay]
50
+ \node[rotate=45,scale=7,text opacity=0.06] at (current page.center) {\textbf{\textsf{\VAR{geometry_watermark}}}};
51
+ \end{tikzpicture}%
52
+ }
53
+ \BLOCK{ endif }
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, ClassVar
7
+
8
+ from texsmith.core.fragments.base import BaseFragment, FragmentPiece
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class GlossaryConfig:
13
+ has_entries: bool
14
+
15
+ @classmethod
16
+ def from_context(cls, context: Mapping[str, Any]) -> GlossaryConfig:
17
+ glossary = context.get("glossary")
18
+ acronyms = context.get("acronyms")
19
+ has_entries = bool(glossary) or bool(acronyms)
20
+ return cls(has_entries=has_entries)
21
+
22
+ def inject_into(self, context: dict[str, Any]) -> None:
23
+ context["ts_glossary_enabled"] = self.has_entries
24
+
25
+
26
+ class GlossaryFragment(BaseFragment[GlossaryConfig]):
27
+ name: ClassVar[str] = "ts-glossary"
28
+ description: ClassVar[str] = "Glossary and acronym helpers."
29
+ pieces: ClassVar[list[FragmentPiece]] = [
30
+ FragmentPiece(
31
+ template_path=Path(__file__).with_name("ts-glossary.jinja.sty"),
32
+ kind="package",
33
+ slot="extra_packages",
34
+ ),
35
+ FragmentPiece(
36
+ template_path=Path(__file__).with_name("ts-glossary-backmatter.jinja.tex"),
37
+ kind="inline",
38
+ slot="fragment_backmatter",
39
+ ),
40
+ ]
41
+ attributes: ClassVar[dict[str, Any]] = {}
42
+ config_cls: ClassVar[type[GlossaryConfig]] = GlossaryConfig
43
+ source: ClassVar[Path] = Path(__file__).with_name("ts-glossary.jinja.sty")
44
+ context_defaults: ClassVar[dict[str, Any]] = {"extra_packages": "", "fragment_backmatter": ""}
45
+
46
+ def build_config(
47
+ self, context: Mapping[str, Any], overrides: Mapping[str, Any] | None = None
48
+ ) -> GlossaryConfig:
49
+ _ = overrides
50
+ return self.config_cls.from_context(context)
51
+
52
+ def inject(
53
+ self,
54
+ config: GlossaryConfig,
55
+ context: dict[str, Any],
56
+ overrides: Mapping[str, Any] | None = None,
57
+ ) -> None:
58
+ _ = overrides
59
+ config.inject_into(context)
60
+
61
+ def should_render(self, config: GlossaryConfig) -> bool:
62
+ return config.has_entries
63
+
64
+
65
+ fragment = GlossaryFragment()
66
+
67
+ __all__ = ["GlossaryConfig", "GlossaryFragment", "fragment"]
@@ -0,0 +1,3 @@
1
+ name = "ts-glossary"
2
+ description = "Glossary and acronym helpers."
3
+ entrypoint = "texsmith.fragments.glossary:fragment"
@@ -0,0 +1,5 @@
1
+ \BLOCK{ set raw_glossary = glossary|default(false) }
2
+ \BLOCK{ if acronyms or raw_glossary }
3
+ \BLOCK{ if acronyms }\printglossary[type=\acronymtype, title=List of Acronyms]\BLOCK{ endif }
4
+ \BLOCK{ if raw_glossary }\printglossary\BLOCK{ endif }
5
+ \BLOCK{ endif }
@@ -0,0 +1,28 @@
1
+ \ProvidesPackage{ts-glossary}[2025/11/21 TeXSmith glossary helpers (generated)]
2
+
3
+ \BLOCK{ set resolved_index_engine = index_engine|default("makeindex")|lower }
4
+ \RequirePackage{makeidx}
5
+ \RequirePackage[acronym,nopostdot\BLOCK{ if resolved_index_engine in ["texindy", "pyxindy"] },xindy\BLOCK{ endif }]{glossaries}
6
+
7
+ % Resolve glossary style (supports overriding via press.glossary.style or glossary_style).
8
+ \BLOCK{ set default_style = default_glossary_style|default("list") }
9
+ \BLOCK{ set raw_style = glossary_style|default("") }
10
+ \BLOCK{ set raw_glossary = glossary|default(false) }
11
+ \BLOCK{ set acronyms_map = acronyms|default({}) }
12
+ \BLOCK{ set resolved_style = raw_style if raw_style else (raw_glossary if raw_glossary is string else default_style) }
13
+ \BLOCK{ set should_activate = raw_glossary or acronyms_map }
14
+
15
+ \BLOCK{ if should_activate }
16
+ \makeglossaries
17
+ \setglossarystyle{\VAR{resolved_style}}
18
+ \BLOCK{ endif }
19
+
20
+ % Acronym definitions
21
+ \BLOCK{ if acronyms_map }
22
+ \BLOCK{ for key, entry in acronyms_map.items() }
23
+ \BLOCK{ set pair = entry if (entry is sequence and not entry is string) else [key, entry] }
24
+ \newacronym{\VAR{key}}{\VAR{pair[0]|latex_escape}}{\VAR{pair[1]|latex_escape}}
25
+ \BLOCK{ endfor }
26
+ \BLOCK{ endif }
27
+
28
+ \endinput