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,438 @@
1
+ """Fallback selection and fast lookup helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bisect import bisect_left
6
+ from collections.abc import Iterable, Mapping
7
+ from dataclasses import dataclass
8
+ import hashlib
9
+ import json
10
+ import pickle
11
+ from typing import Any
12
+
13
+ from texsmith.fonts.cache import FontCache
14
+ from texsmith.fonts.coverage import NotoCoverage
15
+ from texsmith.fonts.logging import FontPipelineLogger
16
+ from texsmith.fonts.ucharclasses import UCharClass
17
+
18
+
19
+ CACHE_VERSION = 1
20
+ BLOCK_SHIFT = 8 # 256-codepoint buckets
21
+
22
+
23
+ def _sanitize_family(name: str) -> str:
24
+ return "".join(ch for ch in name if ch.isalnum())
25
+
26
+
27
+ def _merge_ranges(ranges: Iterable[tuple[int, int]]) -> tuple[tuple[int, int], ...]:
28
+ """Merge overlapping/contiguous ranges and return a sorted tuple."""
29
+ merged: list[list[int]] = []
30
+ for start, end in sorted(ranges):
31
+ if not merged or start > merged[-1][1] + 1:
32
+ merged.append([start, end])
33
+ else:
34
+ merged[-1][1] = max(merged[-1][1], end)
35
+ return tuple((start, end) for start, end in merged)
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class _CoverageView:
40
+ """Preprocessed coverage entry with merged ranges and fast-starts."""
41
+
42
+ meta: NotoCoverage
43
+ ranges: tuple[tuple[int, int], ...]
44
+ starts: tuple[int, ...]
45
+ total_span: int
46
+ family_lower: str
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class FallbackPlan:
51
+ """Structured result of a fallback scan."""
52
+
53
+ summary: list[dict]
54
+ fonts: list[dict]
55
+ group_fonts: dict[str, dict]
56
+ uncovered: list[int]
57
+ strategy: str = "by_class"
58
+
59
+ def __iter__(self) -> Any:
60
+ return iter(self.summary)
61
+
62
+ def __len__(self) -> int: # pragma: no cover - trivial
63
+ return len(self.summary)
64
+
65
+ def __bool__(self) -> bool: # pragma: no cover - trivial
66
+ return bool(self.summary)
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class FallbackEntry:
71
+ name: str
72
+ start: int
73
+ end: int
74
+ group: str | None
75
+ font: dict
76
+
77
+ def to_dict(self) -> dict:
78
+ payload = {
79
+ "name": self.name,
80
+ "start": self.start,
81
+ "end": self.end,
82
+ "group": self.group,
83
+ "font": self.font,
84
+ }
85
+ return payload
86
+
87
+
88
+ class FallbackBuilder:
89
+ """Associate ucharclasses with the best matching Noto font."""
90
+
91
+ def __init__(self, *, logger: FontPipelineLogger | None = None) -> None:
92
+ self.logger = logger or FontPipelineLogger()
93
+
94
+ def _prepare_coverage(self, coverage: Iterable[NotoCoverage]) -> list[_CoverageView]:
95
+ views: list[_CoverageView] = []
96
+ for entry in coverage:
97
+ merged = _merge_ranges(entry.ranges)
98
+ starts = tuple(start for start, _ in merged)
99
+ total_span = sum(end - start + 1 for start, end in merged)
100
+ views.append(
101
+ _CoverageView(
102
+ meta=entry,
103
+ ranges=merged,
104
+ starts=starts,
105
+ total_span=total_span,
106
+ family_lower=entry.family.lower(),
107
+ )
108
+ )
109
+ return views
110
+
111
+ @staticmethod
112
+ def _range_overlap(
113
+ class_start: int,
114
+ class_end: int,
115
+ ranges: tuple[tuple[int, int], ...],
116
+ starts: tuple[int, ...],
117
+ ) -> int:
118
+ """Compute overlap using sorted ranges and early exit."""
119
+ if not ranges:
120
+ return 0
121
+ idx = bisect_left(starts, class_start)
122
+ if idx:
123
+ idx -= 1 # include the range that could start before the class
124
+ total = 0
125
+ for r_start, r_end in ranges[idx:]:
126
+ if r_start > class_end:
127
+ break
128
+ if r_end < class_start:
129
+ continue
130
+ lo = class_start if class_start > r_start else r_start
131
+ hi = class_end if class_end < r_end else r_end
132
+ total += hi - lo + 1
133
+ return total
134
+
135
+ def _pick_font(self, cls: UCharClass, coverage: Iterable[_CoverageView]) -> dict | None:
136
+ """Pick the most specific font for a class.
137
+
138
+ Scoring favours the largest overlap, then smaller coverage span (script-specific
139
+ fonts), then a small bonus when the family name mentions the class name.
140
+ """
141
+ class_range = (cls.start, cls.end)
142
+ class_name = cls.name.lower()
143
+ best: _CoverageView | None = None
144
+ best_score: tuple[int, int, int, int, int] = (0, 0, 0, 0, 0)
145
+ group_token = (cls.group or cls.name or "").lower()
146
+ for view in coverage:
147
+ overlap = self._range_overlap(class_range[0], class_range[1], view.ranges, view.starts)
148
+ if overlap == 0:
149
+ continue
150
+ # Prefer families that declare usable styles so we avoid display-only sets with missing OTFs.
151
+ style_bonus = len(view.meta.styles) if view.meta.styles else 0
152
+ # Prefer non-display families when all else is equal so we select workhorse sets over
153
+ # print/display cuts that may be narrower or less available.
154
+ display_bonus = 0 if "display" in view.family_lower else 1
155
+ name_bonus = (
156
+ 1 if (class_name in view.family_lower or group_token in view.family_lower) else 0
157
+ )
158
+ score = (overlap, name_bonus, style_bonus, display_bonus, -view.total_span)
159
+ if score > best_score:
160
+ best_score = score
161
+ best = view
162
+ if best is None:
163
+ return None
164
+ styles = list(best.meta.styles or ["regular", "bold"])
165
+ return {
166
+ "name": best.meta.file_base or _sanitize_family(best.meta.family),
167
+ "extension": ".otf",
168
+ "styles": styles,
169
+ "dir": best.meta.dir_base,
170
+ }
171
+
172
+ def build(
173
+ self,
174
+ classes: Iterable[UCharClass],
175
+ coverage: Iterable[NotoCoverage],
176
+ *,
177
+ announce: bool | None = None,
178
+ ) -> list[FallbackEntry]:
179
+ coverage_views = self._prepare_coverage(coverage)
180
+ entries: list[FallbackEntry] = []
181
+ for cls in classes:
182
+ font = cls.font or self._pick_font(cls, coverage_views)
183
+ if font is None:
184
+ fallback_name = _sanitize_family(f"NotoSans{cls.name}")
185
+ font = {
186
+ "name": fallback_name,
187
+ "extension": ".otf",
188
+ "styles": ["regular", "bold"],
189
+ }
190
+ entries.append(
191
+ FallbackEntry(
192
+ name=cls.name,
193
+ start=cls.start,
194
+ end=cls.end,
195
+ group=cls.group,
196
+ font=font,
197
+ ),
198
+ )
199
+ log_fn = self.logger.info if announce else self.logger.debug
200
+ log_fn(f"Fallback fonts generated for {len(entries)} classes.")
201
+ return entries
202
+
203
+
204
+ class FallbackIndex:
205
+ """Bucketed interval index for O(1) fallback lookups."""
206
+
207
+ def __init__(self, entries: Iterable[FallbackEntry]) -> None:
208
+ self.entries = tuple(entries)
209
+ self._buckets: dict[int, list[int]] = {}
210
+ for idx, entry in enumerate(self.entries):
211
+ start_block = entry.start >> BLOCK_SHIFT
212
+ end_block = entry.end >> BLOCK_SHIFT
213
+ for block in range(start_block, end_block + 1):
214
+ self._buckets.setdefault(block, []).append(idx)
215
+
216
+ def ranges_for_codepoint(self, codepoint: int) -> list[FallbackEntry]:
217
+ bucket = self._buckets.get(codepoint >> BLOCK_SHIFT)
218
+ if not bucket:
219
+ return []
220
+ hits: list[FallbackEntry] = []
221
+ for idx in bucket:
222
+ entry = self.entries[idx]
223
+ if entry.start <= codepoint <= entry.end:
224
+ hits.append(entry)
225
+ return hits
226
+
227
+ def serialize(self) -> dict:
228
+ return {
229
+ "version": CACHE_VERSION,
230
+ "block_shift": BLOCK_SHIFT,
231
+ "entries": [entry.to_dict() for entry in self.entries],
232
+ "buckets": self._buckets,
233
+ }
234
+
235
+ @classmethod
236
+ def from_serialized(cls, payload: dict) -> FallbackIndex | None:
237
+ if payload.get("version") != CACHE_VERSION or payload.get("block_shift") != BLOCK_SHIFT:
238
+ return None
239
+ entries = [
240
+ FallbackEntry(
241
+ name=entry["name"],
242
+ start=int(entry["start"]),
243
+ end=int(entry["end"]),
244
+ group=entry.get("group"),
245
+ font=entry.get("font", {}),
246
+ )
247
+ for entry in payload.get("entries", [])
248
+ ]
249
+ index = cls(entries)
250
+ index._buckets = {int(k): list(v) for k, v in payload.get("buckets", {}).items()} # type: ignore[assignment]
251
+ return index
252
+
253
+
254
+ class FallbackRepository:
255
+ """Load or build a cached fallback index."""
256
+
257
+ def __init__(
258
+ self,
259
+ *,
260
+ cache: FontCache | None = None,
261
+ logger: FontPipelineLogger | None = None,
262
+ ) -> None:
263
+ self.cache = cache or FontCache()
264
+ self.logger = logger or FontPipelineLogger()
265
+ self.cache_path = self.cache.path("fallback_index.pkl")
266
+
267
+ def _signature(self, entries: list[FallbackEntry]) -> str:
268
+ data = [e.to_dict() for e in entries]
269
+ return hashlib.sha256(json.dumps(data, sort_keys=True).encode("utf-8")).hexdigest()
270
+
271
+ def load(self, expected_signature: str | None = None) -> FallbackIndex | None:
272
+ if not self.cache_path.exists():
273
+ return None
274
+ try:
275
+ raw = pickle.loads(self.cache_path.read_bytes())
276
+ except Exception:
277
+ return None
278
+ if raw.get("version") != CACHE_VERSION:
279
+ return None
280
+ if expected_signature and raw.get("signature") != expected_signature:
281
+ return None
282
+ return FallbackIndex.from_serialized(raw.get("index", {}))
283
+
284
+ def save(self, index: FallbackIndex, signature: str) -> None:
285
+ payload = {
286
+ "version": CACHE_VERSION,
287
+ "signature": signature,
288
+ "index": index.serialize(),
289
+ }
290
+ try:
291
+ self.cache_path.write_bytes(pickle.dumps(payload))
292
+ except Exception:
293
+ self.logger.warning("Impossible d'écrire le cache des fallbacks.")
294
+
295
+ def load_or_build(self, entries: list[FallbackEntry]) -> FallbackIndex:
296
+ signature = self._signature(entries)
297
+ cached = self.load(expected_signature=signature)
298
+ if cached:
299
+ self.logger.notice("Index fallback chargé depuis %s", self.cache_path)
300
+ return cached
301
+ index = FallbackIndex(entries)
302
+ self.save(index, signature)
303
+ return index
304
+
305
+
306
+ class FallbackLookup:
307
+ """Lookup helper exposing a get_classes-like summary."""
308
+
309
+ def __init__(self, index: FallbackIndex) -> None:
310
+ self.index = index
311
+
312
+ def lookup(self, text: str) -> dict[str, dict]:
313
+ classes: dict[str, dict] = {}
314
+ for ch in text:
315
+ codepoint = ord(ch)
316
+ hits = self.index.ranges_for_codepoint(codepoint)
317
+ if not hits:
318
+ if codepoint <= 0x7F:
319
+ continue
320
+ hits = [
321
+ FallbackEntry(
322
+ name="Unknown",
323
+ start=codepoint,
324
+ end=codepoint,
325
+ group=None,
326
+ font={},
327
+ )
328
+ ]
329
+ for hit in hits:
330
+ entry = classes.setdefault(
331
+ hit.name,
332
+ {
333
+ "fonts": set(),
334
+ "ranges": [],
335
+ "group": hit.group or hit.name,
336
+ "font": hit.font,
337
+ },
338
+ )
339
+ entry["fonts"].add(hit.font.get("name") if hit.font else None)
340
+ entry["ranges"].append(codepoint)
341
+ return classes
342
+
343
+ @staticmethod
344
+ def _merge_ranges(codes: list[int]) -> list[str]:
345
+ if not codes:
346
+ return []
347
+ codes = sorted(set(codes))
348
+ merged: list[str] = []
349
+ start = end = codes[0]
350
+ for value in codes[1:]:
351
+ if value == end + 1:
352
+ end = value
353
+ else:
354
+ merged.append(f"U+{start:04X}" if start == end else f"U+{start:04X}-U+{end:04X}")
355
+ start = end = value
356
+ merged.append(f"U+{start:04X}" if start == end else f"U+{start:04X}-U+{end:04X}")
357
+ return merged
358
+
359
+ def summary(self, text: str) -> list[dict]:
360
+ raw = self.lookup(text)
361
+ output: list[dict] = []
362
+ for cls, data in raw.items():
363
+ ranges = self._merge_ranges(data["ranges"])
364
+ count = len(set(data["ranges"]))
365
+ fonts = sorted(f for f in data["fonts"] if f)
366
+ output.append(
367
+ {
368
+ "class": cls,
369
+ "group": data.get("group", cls),
370
+ "fonts": fonts,
371
+ "font": data.get("font", {}),
372
+ "ranges": ranges,
373
+ "count": count,
374
+ }
375
+ )
376
+ return sorted(output, key=lambda entry: entry["class"])
377
+
378
+
379
+ def merge_fallback_summaries(
380
+ existing: Iterable[Mapping[str, Any]], updates: Iterable[Mapping[str, Any]]
381
+ ) -> list[dict[str, Any]]:
382
+ """Merge fallback summaries keyed by class/group, accumulating counts and ranges."""
383
+ merged: dict[str, dict[str, Any]] = {}
384
+
385
+ def _key(entry: Mapping[str, Any]) -> str | None:
386
+ cls = entry.get("class")
387
+ group = entry.get("group")
388
+ if isinstance(cls, str) and cls.strip():
389
+ return cls
390
+ if isinstance(group, str) and group.strip():
391
+ return group
392
+ return None
393
+
394
+ def _consume(entry: Mapping[str, Any]) -> None:
395
+ key = _key(entry)
396
+ if key is None:
397
+ return
398
+ target = merged.setdefault(key, {"class": entry.get("class"), "group": entry.get("group")})
399
+ font_meta = entry.get("font")
400
+ if isinstance(font_meta, Mapping) and font_meta:
401
+ target.setdefault("font", dict(font_meta))
402
+ fonts = entry.get("fonts")
403
+ if isinstance(fonts, Iterable) and not isinstance(fonts, (str, bytes)):
404
+ font_set = set(target.get("fonts", []))
405
+ font_set.update(str(f) for f in fonts if f)
406
+ if font_set:
407
+ target["fonts"] = sorted(font_set)
408
+ ranges = entry.get("ranges")
409
+ if isinstance(ranges, Iterable) and not isinstance(ranges, (str, bytes)):
410
+ range_set = set(target.get("ranges", []))
411
+ range_set.update(str(r) for r in ranges if r)
412
+ target["ranges"] = sorted(range_set)
413
+ count = entry.get("count")
414
+ if isinstance(count, (int, float)):
415
+ target["count"] = int(target.get("count", 0) or 0) + int(count)
416
+
417
+ for payload in existing:
418
+ if isinstance(payload, Mapping):
419
+ _consume(payload)
420
+ for payload in updates:
421
+ if isinstance(payload, Mapping):
422
+ _consume(payload)
423
+
424
+ return sorted(
425
+ merged.values(),
426
+ key=lambda entry: entry.get("class") or entry.get("group") or "",
427
+ )
428
+
429
+
430
+ __all__ = [
431
+ "FallbackBuilder",
432
+ "FallbackEntry",
433
+ "FallbackIndex",
434
+ "FallbackLookup",
435
+ "FallbackPlan",
436
+ "FallbackRepository",
437
+ "merge_fallback_summaries",
438
+ ]
@@ -0,0 +1,168 @@
1
+ """HTML utilities for wrapping foreign script runs with data attributes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ import unicodedata
7
+
8
+ from bs4 import BeautifulSoup
9
+ from bs4.element import Comment, NavigableString, Tag
10
+
11
+ from texsmith.fonts.scripts import ScriptDetector
12
+
13
+
14
+ _DEFAULT_BLOCK_TAGS = {"p"}
15
+ _SKIP_TAGS = {"script", "style", "code", "pre"}
16
+
17
+
18
+ def _replace_with_nodes(
19
+ target: NavigableString, replacements: list[object], soup: BeautifulSoup
20
+ ) -> None:
21
+ if not replacements:
22
+ target.extract()
23
+ return
24
+
25
+ def _coerce(value: object) -> NavigableString | Tag:
26
+ if isinstance(value, (NavigableString, Tag)):
27
+ return value
28
+ return soup.new_string(str(value))
29
+
30
+ nodes = [_coerce(entry) for entry in replacements]
31
+ first = nodes[0]
32
+ target.replace_with(first)
33
+ cursor = first
34
+ for node in nodes[1:]:
35
+ cursor.insert_after(node)
36
+ cursor = node
37
+
38
+
39
+ def _iter_text(node: Tag, *, skip_names: set[str]) -> str:
40
+ parts: list[str] = []
41
+ for descendant in node.descendants:
42
+ if isinstance(descendant, Comment):
43
+ continue
44
+ if isinstance(descendant, NavigableString):
45
+ if any(
46
+ getattr(parent, "name", "").lower() in skip_names
47
+ for parent in getattr(descendant, "parents", [])
48
+ ):
49
+ continue
50
+ parts.append(str(descendant))
51
+ return "".join(parts)
52
+
53
+
54
+ def _promote_block_script(
55
+ tag: Tag,
56
+ detector: ScriptDetector,
57
+ *,
58
+ soup: BeautifulSoup, # noqa: ARG001
59
+ segments: list[tuple[str | None, str, object]],
60
+ ) -> None:
61
+ groups = {group for group, _, _ in segments if group}
62
+ if len(groups) != 1:
63
+ return
64
+
65
+ group = groups.pop()
66
+ slug = detector._record_spec(group, None).slug # noqa: SLF001
67
+ tag.attrs.setdefault("data-script", slug)
68
+
69
+ for span in list(tag.find_all("span", attrs={"data-script": slug})):
70
+ span.unwrap()
71
+
72
+
73
+ def wrap_scripts_in_html(
74
+ html: str,
75
+ *,
76
+ block_tags: Iterable[str] | None = None,
77
+ include_whitespace: bool = True,
78
+ ) -> tuple[str, list[dict[str, str | None]], list[dict[str, object]]]:
79
+ """Annotate script runs in ``html`` and return the transformed payload."""
80
+ soup = BeautifulSoup(html, "html.parser")
81
+ detector = ScriptDetector()
82
+
83
+ skip_names = {name.lower() for name in _SKIP_TAGS}
84
+ block_names = {name.lower() for name in (block_tags or _DEFAULT_BLOCK_TAGS)}
85
+
86
+ def _is_punctuation(chunk: str) -> bool:
87
+ if not chunk:
88
+ return False
89
+ has_non_ascii = any(ord(char) > 127 for char in chunk if not char.isspace())
90
+ return has_non_ascii and all(
91
+ char.isspace() or unicodedata.category(char).startswith("P") for char in chunk
92
+ )
93
+
94
+ def _resolve_segments(
95
+ segments: list[tuple[str | None, str, object]],
96
+ ) -> list[tuple[str | None, str, object]]:
97
+ resolved: list[tuple[str | None, str, object]] = []
98
+ last_group: str | None = None
99
+ last_entry = None
100
+ for group, chunk, entry in segments:
101
+ if not chunk:
102
+ continue
103
+ resolved_group = group
104
+ resolved_entry = entry
105
+ if _is_punctuation(chunk) and (
106
+ (resolved_group is None and last_group is not None)
107
+ or (resolved_group != last_group and last_group is not None)
108
+ ):
109
+ resolved_group = last_group
110
+ resolved_entry = last_entry
111
+ resolved.append((resolved_group, chunk, resolved_entry))
112
+ if resolved_group:
113
+ last_group = resolved_group
114
+ last_entry = resolved_entry or entry
115
+ return resolved
116
+
117
+ def _walk(node: Tag) -> None:
118
+ for child in list(node.children):
119
+ if isinstance(child, Comment):
120
+ continue
121
+ if isinstance(child, NavigableString):
122
+ segments = _resolve_segments(
123
+ detector._segment_text( # noqa: SLF001
124
+ str(child), include_whitespace=include_whitespace
125
+ )
126
+ )
127
+ if not segments or all(group is None for group, _, _ in segments):
128
+ continue
129
+
130
+ replacements: list[object] = []
131
+ for group, chunk, entry in segments:
132
+ if group:
133
+ spec = detector._record_spec(group, entry) # noqa: SLF001
134
+ spec.count += len(chunk)
135
+ span = soup.new_tag("span")
136
+ span.attrs["data-script"] = spec.slug
137
+ span.append(chunk)
138
+ replacements.append(span)
139
+ else:
140
+ replacements.append(chunk)
141
+ _replace_with_nodes(child, replacements, soup)
142
+ continue
143
+
144
+ if isinstance(child, Tag):
145
+ name = (child.name or "").lower()
146
+ if name in skip_names:
147
+ continue
148
+ _walk(child)
149
+
150
+ _walk(soup)
151
+
152
+ for tag_name in block_names:
153
+ for tag in soup.find_all(tag_name):
154
+ raw_text = _iter_text(tag, skip_names=skip_names)
155
+ segments = _resolve_segments(
156
+ detector._segment_text(raw_text, include_whitespace=include_whitespace) # noqa: SLF001
157
+ )
158
+ _promote_block_script(tag, detector, soup=soup, segments=segments)
159
+
160
+ usage = [spec.to_mapping() for spec in detector._specs.values()] # noqa: SLF001
161
+ try:
162
+ summary = detector._ensure_lookup().summary(soup.get_text()) # noqa: SLF001
163
+ except Exception:
164
+ summary = []
165
+ return str(soup), usage, summary
166
+
167
+
168
+ __all__ = ["wrap_scripts_in_html"]