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,493 @@
1
+ """Script-aware helpers for wrapping LaTeX moving arguments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ import re
8
+ import unicodedata
9
+
10
+ from texsmith.adapters.latex.utils import escape_latex_chars
11
+ from texsmith.core.context import RenderContext
12
+ from texsmith.fonts.cache import FontCache
13
+ from texsmith.fonts.fallback import (
14
+ FallbackBuilder,
15
+ FallbackEntry,
16
+ FallbackLookup,
17
+ FallbackPlan,
18
+ FallbackRepository,
19
+ merge_fallback_summaries,
20
+ )
21
+ from texsmith.fonts.logging import FontPipelineLogger
22
+ from texsmith.fonts.pipeline import generate_noto_metadata, generate_ucharclasses_data
23
+
24
+
25
+ _SKIP_GROUPS = {"latin", "common", "punctuation", "other"}
26
+
27
+ # Prefer native math macros for single-letter Greek/hebrew math symbols to avoid
28
+ # relying on \textgreek wrappers when a math glyph already exists.
29
+ _MATH_LETTER_MAP = {
30
+ "\N{GREEK SMALL LETTER ALPHA}": r"\alpha",
31
+ "\N{GREEK SMALL LETTER BETA}": r"\beta",
32
+ "\N{GREEK SMALL LETTER GAMMA}": r"\gamma",
33
+ "\N{GREEK SMALL LETTER DELTA}": r"\delta",
34
+ "\N{GREEK SMALL LETTER EPSILON}": r"\epsilon",
35
+ "\N{GREEK SMALL LETTER ZETA}": r"\zeta",
36
+ "\N{GREEK SMALL LETTER ETA}": r"\eta",
37
+ "\N{GREEK SMALL LETTER THETA}": r"\theta",
38
+ "\N{GREEK SMALL LETTER IOTA}": r"\iota",
39
+ "\N{GREEK SMALL LETTER KAPPA}": r"\kappa",
40
+ "\N{GREEK SMALL LETTER LAMDA}": r"\lambda",
41
+ "\N{GREEK SMALL LETTER MU}": r"\mu",
42
+ "\N{GREEK SMALL LETTER NU}": r"\nu",
43
+ "\N{GREEK SMALL LETTER XI}": r"\xi",
44
+ "\N{GREEK SMALL LETTER PI}": r"\pi",
45
+ "\N{GREEK SMALL LETTER RHO}": r"\rho",
46
+ "\N{GREEK SMALL LETTER SIGMA}": r"\sigma",
47
+ "\N{GREEK SMALL LETTER FINAL SIGMA}": r"\varsigma",
48
+ "\N{GREEK SMALL LETTER TAU}": r"\tau",
49
+ "\N{GREEK SMALL LETTER UPSILON}": r"\upsilon",
50
+ "\N{GREEK SMALL LETTER PHI}": r"\phi",
51
+ "\N{GREEK SMALL LETTER CHI}": r"\chi",
52
+ "\N{GREEK SMALL LETTER PSI}": r"\psi",
53
+ "\N{GREEK SMALL LETTER OMEGA}": r"\omega",
54
+ "\N{GREEK THETA SYMBOL}": r"\vartheta",
55
+ "\N{GREEK PHI SYMBOL}": r"\varphi",
56
+ "\N{GREEK PI SYMBOL}": r"\varpi",
57
+ "\N{GREEK RHO SYMBOL}": r"\varrho",
58
+ "\N{GREEK KAPPA SYMBOL}": r"\varkappa",
59
+ "\N{GREEK LUNATE EPSILON SYMBOL}": r"\varepsilon",
60
+ "\N{GREEK CAPITAL LETTER GAMMA}": r"\Gamma",
61
+ "\N{GREEK CAPITAL LETTER DELTA}": r"\Delta",
62
+ "\N{GREEK CAPITAL LETTER THETA}": r"\Theta",
63
+ "\N{GREEK CAPITAL LETTER LAMDA}": r"\Lambda",
64
+ "\N{GREEK CAPITAL LETTER XI}": r"\Xi",
65
+ "\N{GREEK CAPITAL LETTER PI}": r"\Pi",
66
+ "\N{GREEK CAPITAL LETTER SIGMA}": r"\Sigma",
67
+ "\N{GREEK CAPITAL LETTER UPSILON}": r"\Upsilon",
68
+ "\N{GREEK CAPITAL LETTER PHI}": r"\Phi",
69
+ "\N{GREEK CAPITAL LETTER PSI}": r"\Psi",
70
+ "\N{GREEK CAPITAL LETTER OMEGA}": r"\Omega",
71
+ "\N{ALEF SYMBOL}": r"\aleph",
72
+ "\N{BET SYMBOL}": r"\beth",
73
+ "\N{GIMEL SYMBOL}": r"\gimel",
74
+ "\N{DALET SYMBOL}": r"\daleth",
75
+ }
76
+ _MATH_LETTER_GROUPS = {"greek", "symbols", "hebrew"}
77
+
78
+
79
+ def _slugify(value: str) -> str:
80
+ slug = re.sub(r"[^A-Za-z0-9]+", "", value)
81
+ if not slug:
82
+ return "script"
83
+ if slug[0].isdigit():
84
+ slug = f"s{slug}"
85
+ return slug.lower()
86
+
87
+
88
+ def _math_letter_override(chunk: str, group: str | None) -> str | None:
89
+ """Return a math-mode macro for lone Greek/Hebrew symbols when available."""
90
+ if not chunk:
91
+ return None
92
+ normalized_group = group.lower() if isinstance(group, str) else None
93
+ if normalized_group and normalized_group not in _MATH_LETTER_GROUPS:
94
+ return None
95
+ trimmed = chunk.strip()
96
+ if len(trimmed) != 1:
97
+ return None
98
+ command = _MATH_LETTER_MAP.get(trimmed)
99
+ if command is None:
100
+ return None
101
+ prefix_len = len(chunk) - len(chunk.lstrip())
102
+ suffix_len = len(chunk) - len(chunk.rstrip())
103
+ prefix = chunk[:prefix_len]
104
+ suffix = chunk[len(chunk) - suffix_len :] if suffix_len else ""
105
+ return f"{prefix}${command}${suffix}"
106
+
107
+
108
+ @dataclass(slots=True)
109
+ class ScriptSpec:
110
+ group: str
111
+ slug: str
112
+ font_name: str | None
113
+ font_command: str
114
+ text_command: str
115
+ count: int = 0
116
+
117
+ def to_mapping(self) -> dict[str, str | None]:
118
+ return {
119
+ "group": self.group,
120
+ "slug": self.slug,
121
+ "font_name": self.font_name,
122
+ "font_command": self.font_command,
123
+ "text_command": self.text_command,
124
+ "count": self.count,
125
+ }
126
+
127
+
128
+ class ScriptDetector:
129
+ """Detect script runs and wrap them with dedicated LaTeX macros."""
130
+
131
+ def __init__(
132
+ self,
133
+ *,
134
+ cache: FontCache | None = None,
135
+ logger: FontPipelineLogger | None = None,
136
+ skip_groups: Iterable[str] | None = None,
137
+ ) -> None:
138
+ self.cache = cache or FontCache()
139
+ self.logger = logger or FontPipelineLogger()
140
+ self.skip_groups = {entry.lower() for entry in (skip_groups or _SKIP_GROUPS)}
141
+ self._lookup: FallbackLookup | None = None
142
+ self._specs: dict[str, ScriptSpec] = {}
143
+
144
+ def _ensure_lookup(self) -> FallbackLookup:
145
+ if self._lookup is None:
146
+ repository = FallbackRepository(cache=self.cache, logger=self.logger)
147
+ had_cache = repository.cache_path.exists()
148
+ classes = generate_ucharclasses_data(cache=self.cache, logger=self.logger)
149
+ coverage = generate_noto_metadata(cache=self.cache, logger=self.logger)
150
+ announce = not had_cache
151
+ entries = FallbackBuilder(logger=self.logger).build(
152
+ classes, coverage, announce=announce
153
+ )
154
+ signature = repository._signature(entries) # noqa: SLF001
155
+ cached = repository.load(expected_signature=signature)
156
+ if cached is None:
157
+ cached = repository.load_or_build(entries)
158
+ self._lookup = FallbackLookup(cached)
159
+ return self._lookup
160
+
161
+ def _classify_char(self, char: str) -> FallbackEntry | None:
162
+ lookup = self._ensure_lookup()
163
+ candidates = lookup.index.ranges_for_codepoint(ord(char))
164
+ if not candidates:
165
+ return None
166
+ return candidates[0]
167
+
168
+ def _group_name(self, entry: FallbackEntry | None) -> str | None:
169
+ if entry is None:
170
+ return None
171
+ group = entry.group or entry.name
172
+ if not group:
173
+ return None
174
+ if group.lower() in self.skip_groups:
175
+ return None
176
+ return group
177
+
178
+ def _segment_text(
179
+ self,
180
+ text: str,
181
+ *,
182
+ include_whitespace: bool,
183
+ ) -> list[tuple[str | None, str, FallbackEntry | None]]:
184
+ override_group = self._resolve_cjk_override(text)
185
+ runs: list[tuple[str | None, str, FallbackEntry | None]] = []
186
+ current_group: str | None = None
187
+ current_entry: FallbackEntry | None = None
188
+ buffer: list[str] = []
189
+
190
+ for char in text:
191
+ entry = self._classify_char(char)
192
+ group = self._group_name(entry)
193
+ if (
194
+ override_group
195
+ and group
196
+ and group.lower() in {"chinese", "japanese", "korean", "cjk"}
197
+ ):
198
+ group = override_group
199
+ entry = entry
200
+ combining = bool(unicodedata.combining(char))
201
+ if current_group is not None and (
202
+ combining or (group and group.lower() == "diacritics")
203
+ ):
204
+ group = current_group
205
+ entry = current_entry
206
+ if include_whitespace and char.isspace() and current_group is not None:
207
+ group = current_group
208
+ entry = current_entry
209
+
210
+ if group != current_group and buffer:
211
+ runs.append((current_group, "".join(buffer), current_entry))
212
+ buffer.clear()
213
+
214
+ buffer.append(char)
215
+ current_group = group
216
+ current_entry = entry if group else None
217
+
218
+ if buffer:
219
+ runs.append((current_group, "".join(buffer), current_entry))
220
+
221
+ return runs
222
+
223
+ def _resolve_cjk_override(self, text: str) -> str | None:
224
+ try:
225
+ summary = self._ensure_lookup().summary(text)
226
+ except Exception:
227
+ return None
228
+
229
+ counts: dict[str, int] = {}
230
+ for entry in summary:
231
+ group = entry.get("group")
232
+ count = entry.get("count")
233
+ if not isinstance(group, str) or not isinstance(count, int):
234
+ continue
235
+ lowered = group.lower()
236
+ if lowered in {"chinese", "japanese", "korean", "cjk"}:
237
+ counts[lowered] = counts.get(lowered, 0) + count
238
+
239
+ if not counts:
240
+ return None
241
+
242
+ chinese_count = counts.get("chinese", 0) + counts.get("cjk", 0)
243
+ japanese_count = counts.get("japanese", 0)
244
+ korean_count = counts.get("korean", 0)
245
+
246
+ if japanese_count and japanese_count >= chinese_count * 0.5:
247
+ return "japanese"
248
+ if korean_count and korean_count >= chinese_count * 0.5:
249
+ return "korean"
250
+
251
+ dominant = max(counts.items(), key=lambda item: item[1])[0]
252
+ return dominant
253
+
254
+ def _record_spec(self, group: str, entry: FallbackEntry | None) -> ScriptSpec:
255
+ slug = _slugify(group)
256
+ font_name = None
257
+ if entry and entry.font:
258
+ font_name = entry.font.get("name")
259
+ spec = self._specs.get(slug)
260
+ if spec is None:
261
+ spec = ScriptSpec(
262
+ group=group,
263
+ slug=slug,
264
+ font_name=font_name,
265
+ font_command=f"{slug}font",
266
+ text_command=f"text{slug}",
267
+ )
268
+ self._specs[slug] = spec
269
+ elif font_name and not spec.font_name:
270
+ spec.font_name = font_name
271
+ return spec
272
+
273
+ def render(
274
+ self,
275
+ text: str,
276
+ *,
277
+ include_whitespace: bool = True,
278
+ legacy_accents: bool = False,
279
+ escape: bool = True,
280
+ wrap_scripts: bool = False,
281
+ ) -> tuple[str, list[dict[str, str | None]]]:
282
+ """Return LaTeX text with script-specific wrappers and usage metadata."""
283
+ if not text:
284
+ return "", []
285
+ segments = self._segment_text(text, include_whitespace=include_whitespace)
286
+ rendered: list[str] = []
287
+ for group, chunk, entry in segments:
288
+ math_override = _math_letter_override(chunk, group)
289
+ if math_override is not None:
290
+ rendered.append(math_override)
291
+ continue
292
+ escaped = escape_latex_chars(chunk, legacy_accents=legacy_accents) if escape else chunk
293
+ if group:
294
+ spec = self._record_spec(group, entry)
295
+ spec.count += len(chunk)
296
+ if wrap_scripts:
297
+ rendered.append(f"\\{spec.text_command}{{{escaped}}}")
298
+ continue
299
+ rendered.append(escaped)
300
+ usages = [spec.to_mapping() for spec in self._specs.values()]
301
+ return "".join(rendered), usages
302
+
303
+
304
+ def fallback_summary_to_usage(
305
+ summary: Sequence[Mapping[str, object]],
306
+ ) -> list[dict[str, str | None]]:
307
+ """Convert a fallback scan summary into script usage records."""
308
+ plan_fonts: dict[str, Mapping[str, object]] = {}
309
+ if isinstance(summary, FallbackPlan):
310
+ plan_fonts = summary.group_fonts or {}
311
+ summary = summary.summary
312
+
313
+ usages: list[dict[str, str | None]] = []
314
+ for entry in summary:
315
+ if not isinstance(entry, Mapping):
316
+ continue
317
+ group = entry.get("group") or entry.get("class") or entry.get("name")
318
+ if not isinstance(group, str):
319
+ continue
320
+ group_lower = group.lower()
321
+ if group_lower in _SKIP_GROUPS:
322
+ continue
323
+ slug = _slugify(group)
324
+ font_name = None
325
+ plan_font = plan_fonts.get(group) or plan_fonts.get(group_lower) if plan_fonts else None
326
+ if isinstance(plan_font, Mapping):
327
+ raw_name = plan_font.get("name")
328
+ font_name = str(raw_name) if isinstance(raw_name, str) else None
329
+ if font_name is None:
330
+ font_payload = entry.get("font")
331
+ if isinstance(font_payload, Mapping):
332
+ raw_name = font_payload.get("name")
333
+ font_name = str(raw_name) if isinstance(raw_name, str) else None
334
+ is_emoji = group_lower == "symbols" or (font_name and "emoji" in font_name.lower())
335
+ if is_emoji:
336
+ slug = "emoji"
337
+ count_value = entry.get("count")
338
+ count = int(count_value) if isinstance(count_value, (int, float)) else None
339
+ usages.append(
340
+ {
341
+ "group": group,
342
+ "slug": slug,
343
+ "font_name": font_name,
344
+ "font_command": "texsmithEmojiFont" if slug == "emoji" else f"{slug}font",
345
+ "text_command": "texsmithEmoji" if slug == "emoji" else f"text{slug}",
346
+ "count": count,
347
+ }
348
+ )
349
+ return usages
350
+
351
+
352
+ def merge_script_usage(
353
+ existing: Sequence[Mapping[str, str | None]],
354
+ updates: Sequence[Mapping[str, str | None]],
355
+ ) -> list[dict[str, str | None]]:
356
+ """Merge script usage lists keyed by slug."""
357
+ merged: dict[str, dict[str, str | None]] = {
358
+ str(entry.get("slug")): dict(entry) for entry in existing if entry.get("slug")
359
+ }
360
+ for entry in updates:
361
+ slug = entry.get("slug")
362
+ if not slug:
363
+ continue
364
+ if slug not in merged:
365
+ merged[slug] = dict(entry)
366
+ continue
367
+ existing_font = merged[slug].get("font_name")
368
+ update_font = entry.get("font_name")
369
+ existing_count = merged[slug].get("count")
370
+ update_count = entry.get("count")
371
+
372
+ # Prefer the font coming from the entry with the highest count (when available).
373
+ if update_font:
374
+ if not existing_font:
375
+ merged[slug]["font_name"] = update_font
376
+ else:
377
+ existing_value = (
378
+ int(existing_count) if isinstance(existing_count, (int, float)) else 0
379
+ )
380
+ update_value = int(update_count) if isinstance(update_count, (int, float)) else 0
381
+ if update_value > existing_value:
382
+ merged[slug]["font_name"] = update_font
383
+
384
+ if isinstance(existing_count, (int, float)) or isinstance(update_count, (int, float)):
385
+ existing_value = int(existing_count) if isinstance(existing_count, (int, float)) else 0
386
+ update_value = int(update_count) if isinstance(update_count, (int, float)) else 0
387
+ merged_count = existing_value + update_value
388
+ merged[slug]["count"] = merged_count if merged_count else None
389
+ return list(merged.values())
390
+
391
+
392
+ def record_script_usage_for_slug(
393
+ slug: str,
394
+ text: str,
395
+ context: RenderContext,
396
+ *,
397
+ detector: ScriptDetector | None = None,
398
+ ) -> dict[str, str | None]:
399
+ """Record usage/fallback metadata for a known script slug."""
400
+ if detector is None:
401
+ detector_key = "_texsmith_script_detector"
402
+ detector = context.runtime.get(detector_key)
403
+ if not isinstance(detector, ScriptDetector):
404
+ detector = ScriptDetector(cache=FontCache())
405
+ context.runtime[detector_key] = detector
406
+
407
+ group = slug
408
+ font_name = None
409
+ try:
410
+ summary = detector._ensure_lookup().summary(text) # noqa: SLF001
411
+ except Exception:
412
+ summary = []
413
+
414
+ if summary:
415
+ dominant = max(summary, key=lambda entry: entry.get("count", 0) or 0)
416
+ candidate_group = dominant.get("group") or dominant.get("class")
417
+ if isinstance(candidate_group, str) and candidate_group.strip():
418
+ group = candidate_group
419
+ font_meta = dominant.get("font")
420
+ if isinstance(font_meta, Mapping):
421
+ raw_name = font_meta.get("name")
422
+ if isinstance(raw_name, str):
423
+ font_name = raw_name
424
+
425
+ usage_entry = {
426
+ "group": group,
427
+ "slug": slug,
428
+ "font_name": font_name,
429
+ "font_command": f"{slug}font",
430
+ "text_command": f"text{slug}",
431
+ "count": len(text) if text else None,
432
+ }
433
+ state_usage = getattr(context.state, "script_usage", [])
434
+ context.state.script_usage = merge_script_usage(state_usage, [usage_entry])
435
+ if summary:
436
+ existing = getattr(context.state, "fallback_summary", [])
437
+ context.state.fallback_summary = merge_fallback_summaries(existing, summary)
438
+ return usage_entry
439
+
440
+
441
+ def render_moving_text(
442
+ text: str | None,
443
+ context: RenderContext,
444
+ *,
445
+ include_whitespace: bool = True,
446
+ legacy_accents: bool | None = None,
447
+ escape: bool = True,
448
+ wrap_scripts: bool = False,
449
+ ) -> str | None:
450
+ """Return LaTeX-safe text with script wrappers and record usage in state."""
451
+ if text is None:
452
+ return None
453
+ detector_key = "_texsmith_script_detector"
454
+ detector = context.runtime.get(detector_key)
455
+ if not isinstance(detector, ScriptDetector):
456
+ detector = ScriptDetector(cache=FontCache())
457
+ context.runtime[detector_key] = detector
458
+ rendered, usage = detector.render(
459
+ text,
460
+ include_whitespace=include_whitespace,
461
+ legacy_accents=bool(legacy_accents)
462
+ if legacy_accents is not None
463
+ else getattr(context.config, "legacy_latex_accents", False),
464
+ escape=escape,
465
+ wrap_scripts=wrap_scripts,
466
+ )
467
+ state_usage = getattr(context.state, "script_usage", [])
468
+ context.state.script_usage = merge_script_usage(state_usage, usage)
469
+ try:
470
+ summary = detector._ensure_lookup().summary(text) # noqa: SLF001
471
+ existing = getattr(context.state, "fallback_summary", [])
472
+ context.state.fallback_summary = merge_fallback_summaries(existing, summary)
473
+ except Exception:
474
+ pass
475
+ return rendered
476
+
477
+
478
+ def render_script_macros(usages: Iterable[Mapping[str, str | None]]) -> str:
479
+ """Render LaTeX macros declaring script-specific font commands."""
480
+ from texsmith.adapters.latex.formatter import LaTeXFormatter
481
+
482
+ scripts = sorted(
483
+ (dict(entry) for entry in usages if entry.get("slug")),
484
+ key=lambda entry: str(entry.get("slug")),
485
+ )
486
+ if not scripts:
487
+ return ""
488
+ formatter = LaTeXFormatter()
489
+ try:
490
+ return formatter.script_macros(scripts=scripts)
491
+ except AttributeError:
492
+ # When the script_macros partial is not available, skip emitting anything.
493
+ return ""
@@ -0,0 +1,164 @@
1
+ """Tools to fetch and parse ucharclasses definitions from CTAN."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ import shutil
10
+ import urllib.request
11
+ import zipfile
12
+
13
+ from texsmith.fonts.cache import FontCache
14
+ from texsmith.fonts.logging import FontPipelineLogger
15
+
16
+
17
+ CTAN_UCHARCLASSES_ZIP = "https://mirrors.ctan.org/macros/xetex/latex/ucharclasses.zip"
18
+
19
+ DO_PATTERN = re.compile(r"""\\do\{([^}]+)\}\{"?([0-9A-Fa-f]+)\}\{"?([0-9A-Fa-f]+)\}""")
20
+ GROUP_PATTERN = re.compile(r"""\\def\\([A-Za-z0-9]+)Classes\{""")
21
+ DO_NAME_PATTERN = re.compile(r"""\\do\{([^}]+)\}""")
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class UCharClass:
26
+ name: str
27
+ start: int
28
+ end: int
29
+ group: str | None = None
30
+ font: dict | None = None
31
+
32
+ def to_dict(self) -> dict:
33
+ payload = {
34
+ "name": self.name,
35
+ "start": self.start,
36
+ "end": self.end,
37
+ "start_hex": f"U+{self.start:04X}",
38
+ "end_hex": f"U+{self.end:04X}",
39
+ }
40
+ if self.group:
41
+ payload["group"] = self.group
42
+ if self.font:
43
+ payload["font"] = self.font
44
+ return payload
45
+
46
+
47
+ class UCharClassesBuilder:
48
+ """Download and parse ucharclasses.sty from CTAN."""
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ cache: FontCache | None = None,
54
+ logger: FontPipelineLogger | None = None,
55
+ source_url: str = CTAN_UCHARCLASSES_ZIP,
56
+ extra_sources: list[Path] | None = None,
57
+ ) -> None:
58
+ self.cache = cache or FontCache()
59
+ self.logger = logger or FontPipelineLogger()
60
+ self.source_url = source_url
61
+ self.extra_sources = extra_sources or []
62
+
63
+ def _cached_sty_path(self) -> Path:
64
+ return self.cache.path("ucharclasses", "ucharclasses.sty")
65
+
66
+ def _download_zip(self, target: Path) -> Path:
67
+ self.logger.info("Downloading ucharclasses from %s", self.source_url)
68
+ with urllib.request.urlopen(self.source_url) as response:
69
+ target.write_bytes(response.read())
70
+ return target
71
+
72
+ def _extract_sty(self, archive: Path, destination: Path) -> Path:
73
+ with zipfile.ZipFile(archive) as zf:
74
+ for info in zf.infolist():
75
+ if info.filename.lower().endswith("ucharclasses.sty"):
76
+ destination.parent.mkdir(parents=True, exist_ok=True)
77
+ with zf.open(info) as src, destination.open("wb") as dst:
78
+ shutil.copyfileobj(src, dst)
79
+ return destination
80
+ raise FileNotFoundError("ucharclasses.sty not found in downloaded archive.")
81
+
82
+ def _ensure_sty(self) -> Path:
83
+ cached = self._cached_sty_path()
84
+ candidates = [
85
+ cached,
86
+ *self.extra_sources,
87
+ ]
88
+ for candidate in candidates:
89
+ if candidate.exists():
90
+ if candidate != cached:
91
+ cached.parent.mkdir(parents=True, exist_ok=True)
92
+ shutil.copy(candidate, cached)
93
+ self.logger.debug("ucharclasses.sty loaded from %s", candidate)
94
+ return cached
95
+ with self.cache.tempdir() as tmp:
96
+ archive = tmp / "ucharclasses.zip"
97
+ self._download_zip(archive)
98
+ sty_path = self._extract_sty(archive, cached)
99
+ self.logger.info("ucharclasses.sty extracted to %s", sty_path)
100
+ return sty_path
101
+
102
+ def sty_path(self) -> Path:
103
+ """Return the local ucharclasses.sty path, downloading it if needed."""
104
+ return self._ensure_sty()
105
+
106
+ @staticmethod
107
+ def _iter_class_ranges(text: str):
108
+ for line in text.splitlines():
109
+ line = line.strip()
110
+ if not line or line.startswith("%"):
111
+ continue
112
+ for match in DO_PATTERN.finditer(line):
113
+ name, start_hex, end_hex = match.groups()
114
+ yield name, int(start_hex, 16), int(end_hex, 16)
115
+
116
+ @staticmethod
117
+ def _apply_grouping(raw: str, classes: dict[str, UCharClass]) -> None:
118
+ group_priority = {"Japanese": 3, "Korean": 3, "Chinese": 3, "CJK": 1}
119
+ priorities: dict[str, int] = {}
120
+ current_group = None
121
+ for line in raw.splitlines():
122
+ stripped = line.strip()
123
+ match = GROUP_PATTERN.match(stripped)
124
+ if match:
125
+ current_group = match.group(1)
126
+ continue
127
+ if current_group and stripped.startswith("}"):
128
+ current_group = None
129
+ continue
130
+ if current_group and current_group not in ("All", "Other"):
131
+ priority = group_priority.get(current_group, 2)
132
+ for match in DO_NAME_PATTERN.finditer(stripped):
133
+ cls_name = match.group(1)
134
+ if cls_name not in classes:
135
+ continue
136
+ previous_priority = priorities.get(cls_name, 0)
137
+ if previous_priority >= priority:
138
+ continue
139
+ classes[cls_name].group = current_group
140
+ priorities[cls_name] = priority
141
+
142
+ def build(self) -> list[UCharClass]:
143
+ """Return parsed ucharclasses definitions, downloading assets if needed."""
144
+ had_cached_sty = self._cached_sty_path().exists()
145
+ sty_path = self._ensure_sty()
146
+ raw = sty_path.read_text(encoding="utf-8")
147
+ seen: dict[str, UCharClass] = {}
148
+ for name, start, end in self._iter_class_ranges(raw):
149
+ seen.setdefault(name, UCharClass(name=name, start=start, end=end))
150
+ self._apply_grouping(raw, seen)
151
+ ordered = sorted(seen.values(), key=lambda c: c.start)
152
+ log_fn = self.logger.info if not had_cached_sty else self.logger.debug
153
+ log_fn(f"{len(ordered)} Unicode classes detected.")
154
+ return ordered
155
+
156
+ def export_json(self, target: Path) -> None:
157
+ """Persist classes to JSON; useful for debugging or downstream tools."""
158
+ data = [cls.to_dict() for cls in self.build()]
159
+ target.parent.mkdir(parents=True, exist_ok=True)
160
+ target.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
161
+ self.logger.info("Writing Unicode classes to %s", target)
162
+
163
+
164
+ __all__ = ["CTAN_UCHARCLASSES_ZIP", "UCharClass", "UCharClassesBuilder"]
@@ -0,0 +1,11 @@
1
+ """Legacy fragment namespace.
2
+
3
+ Fragments now register via ``fragment.toml`` or entry points returning
4
+ ``Fragment``/``FragmentDefinition`` objects. This module is kept only to avoid
5
+ import errors for legacy paths; no registration occurs here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ __all__ = []