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,127 @@
1
+ """Small logging helpers that integrate with the TeXSmith CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import typer
11
+
12
+
13
+ def _resolve_state() -> object | None:
14
+ try:
15
+ from texsmith.ui.cli.state import get_cli_state
16
+ except Exception: # pragma: no cover - fallback when CLI is unavailable
17
+ return None
18
+ try:
19
+ return get_cli_state(create=False)
20
+ except Exception:
21
+ return None
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class FontPipelineLogger:
26
+ """Light wrapper around the CLI state with graceful degradation."""
27
+
28
+ verbose: bool = False
29
+ _state: object | None = None
30
+
31
+ def __post_init__(self) -> None:
32
+ self._state = _resolve_state()
33
+
34
+ def _render_message(self, message: str, args: tuple[Any, ...]) -> str:
35
+ if args:
36
+ try:
37
+ message = message % args
38
+ except Exception:
39
+ message = " ".join([message, *(str(arg) for arg in args)])
40
+ return message
41
+
42
+ def info(self, message: str, *args: Any) -> None:
43
+ message = self._render_message(message, args)
44
+ if self._state is not None:
45
+ try:
46
+ console = self._state.console
47
+ console.log(message)
48
+ except Exception: # pragma: no cover - defensive fallback
49
+ pass
50
+ else:
51
+ return
52
+ typer.echo(message)
53
+
54
+ def warning(self, message: str, *args: Any) -> None:
55
+ message = self._render_message(message, args)
56
+ if self._state is not None:
57
+ try:
58
+ from texsmith.ui.cli.state import emit_warning
59
+
60
+ emit_warning(message)
61
+ except Exception: # pragma: no cover - defensive fallback
62
+ pass
63
+ else:
64
+ return
65
+ typer.secho(message, fg="yellow")
66
+
67
+ def notice(self, message: str, *args: Any) -> None:
68
+ """Alias for info to mirror the CLI vocabulary."""
69
+ self.info(message, *args)
70
+
71
+ def debug(self, message: str, *args: Any) -> None:
72
+ """Emit a debug/verbose message when verbose mode is enabled."""
73
+ if not self.verbose:
74
+ return
75
+ self.info(message, *args)
76
+
77
+ @contextmanager
78
+ def progress(self, task: str, total: int | None = None) -> Iterator[callable]:
79
+ """Yield a progress updater. Falls back to a no-op when Rich is absent."""
80
+ try:
81
+ from rich.progress import (
82
+ BarColumn,
83
+ Progress,
84
+ SpinnerColumn,
85
+ TaskID,
86
+ TextColumn,
87
+ TimeElapsedColumn,
88
+ )
89
+ except Exception: # pragma: no cover - Rich not available
90
+ count = 0
91
+
92
+ def _advance(step: int = 1) -> None:
93
+ nonlocal count
94
+ count += step
95
+ if total:
96
+ typer.echo(f"{task}: {count}/{total}", err=True)
97
+ elif self.verbose:
98
+ typer.echo(f"{task}: {count}", err=True)
99
+
100
+ yield _advance
101
+ return
102
+
103
+ if self._state is not None:
104
+ console = self._state.console
105
+ else:
106
+ from rich.console import Console
107
+
108
+ console = Console()
109
+
110
+ with Progress(
111
+ SpinnerColumn(),
112
+ TextColumn(f"[bold cyan]{task}"),
113
+ BarColumn(),
114
+ TextColumn("{task.completed}/{task.total}" if total else "{task.completed}"),
115
+ TimeElapsedColumn(),
116
+ console=console,
117
+ transient=not self.verbose,
118
+ ) as progress:
119
+ task_id: TaskID = progress.add_task(task, total=total)
120
+
121
+ def _advance(step: int = 1) -> None:
122
+ progress.update(task_id, advance=step)
123
+
124
+ yield _advance
125
+
126
+
127
+ __all__ = ["FontPipelineLogger"]
@@ -0,0 +1,338 @@
1
+ """High-level orchestration helpers for the font toolchain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import Literal
8
+
9
+ from texsmith.fonts.cache import FontCache
10
+ from texsmith.fonts.coverage import NotoCoverage, NotoCoverageBuilder
11
+ from texsmith.fonts.fallback import (
12
+ FallbackBuilder,
13
+ FallbackLookup,
14
+ FallbackPlan,
15
+ FallbackRepository,
16
+ )
17
+ from texsmith.fonts.logging import FontPipelineLogger
18
+ from texsmith.fonts.ucharclasses import UCharClass, UCharClassesBuilder
19
+
20
+
21
+ _UCHAR_DATA_CACHE: dict[str, list[UCharClass]] = {}
22
+ _NOTO_DATA_CACHE: dict[str, list[NotoCoverage]] = {}
23
+ _LOOKUP_CACHE: dict[str, FallbackLookup] = {}
24
+
25
+
26
+ def _cache_key(cache: FontCache | None) -> str | None:
27
+ try:
28
+ root = cache.root if cache is not None else FontCache().root
29
+ return str(root.resolve())
30
+ except Exception:
31
+ return None
32
+
33
+
34
+ def generate_ucharclasses_data(
35
+ *, cache: FontCache | None = None, logger: FontPipelineLogger | None = None
36
+ ):
37
+ """Fetch and parse ucharclasses definitions, downloading assets if missing."""
38
+ key = _cache_key(cache)
39
+ if key and key in _UCHAR_DATA_CACHE:
40
+ return _UCHAR_DATA_CACHE[key]
41
+ builder = UCharClassesBuilder(cache=cache, logger=logger)
42
+ data = builder.build()
43
+ if key:
44
+ _UCHAR_DATA_CACHE[key] = data
45
+ return data
46
+
47
+
48
+ def generate_noto_metadata(
49
+ *, cache: FontCache | None = None, logger: FontPipelineLogger | None = None
50
+ ):
51
+ """Build the Noto coverage dataset (equivalent to sandbox/build.py)."""
52
+ key = _cache_key(cache)
53
+ if key and key in _NOTO_DATA_CACHE:
54
+ return _NOTO_DATA_CACHE[key]
55
+ builder = NotoCoverageBuilder(cache=cache, logger=logger)
56
+ data = builder.build()
57
+ if key:
58
+ _NOTO_DATA_CACHE[key] = data
59
+ return data
60
+
61
+
62
+ def generate_fallback_entries(
63
+ *,
64
+ cache: FontCache | None = None,
65
+ logger: FontPipelineLogger | None = None,
66
+ ):
67
+ """Compute fallback associations, reusing cached inputs when available."""
68
+ cache = cache or FontCache()
69
+ logger = logger or FontPipelineLogger()
70
+ classes = generate_ucharclasses_data(cache=cache, logger=logger)
71
+ coverage = generate_noto_metadata(cache=cache, logger=logger)
72
+ return FallbackBuilder(logger=logger).build(classes, coverage)
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class FallbackManager:
77
+ """Facade that caches the fallback index and exposes fast lookups."""
78
+
79
+ cache: FontCache = field(default_factory=FontCache)
80
+ logger: FontPipelineLogger = field(default_factory=FontPipelineLogger)
81
+ _lookup: FallbackLookup | None = field(default=None, init=False, repr=False)
82
+ _coverage: list[NotoCoverage] | None = field(default=None, init=False, repr=False)
83
+
84
+ def _ensure_lookup(self) -> FallbackLookup:
85
+ if self._lookup is not None:
86
+ return self._lookup
87
+ repository = FallbackRepository(cache=self.cache, logger=self.logger)
88
+ had_cache = repository.cache_path.exists()
89
+
90
+ cache_key = _cache_key(self.cache)
91
+ cached_any = repository.load()
92
+ if cache_key and cache_key in _LOOKUP_CACHE and cached_any is None:
93
+ self._lookup = _LOOKUP_CACHE[cache_key]
94
+ return self._lookup
95
+
96
+ classes = generate_ucharclasses_data(cache=self.cache, logger=self.logger)
97
+ coverage = self._ensure_coverage()
98
+ announce = not had_cache
99
+ entries = FallbackBuilder(logger=self.logger).build(classes, coverage, announce=announce)
100
+ signature = repository._signature(entries) # noqa: SLF001
101
+
102
+ cached = repository.load(expected_signature=signature)
103
+ if cached is None:
104
+ cached = repository.load_or_build(entries)
105
+
106
+ self._lookup = FallbackLookup(cached)
107
+ if cache_key:
108
+ _LOOKUP_CACHE[cache_key] = self._lookup
109
+ return self._lookup
110
+
111
+ def _ensure_coverage(self) -> list[NotoCoverage]:
112
+ if self._coverage is None:
113
+ self._coverage = generate_noto_metadata(cache=self.cache, logger=self.logger)
114
+ return self._coverage
115
+
116
+ def _fonts_from_summary(self, summary: list[dict]) -> list[dict]:
117
+ fonts: dict[str, dict] = {}
118
+ for entry in summary:
119
+ if not isinstance(entry, dict):
120
+ continue
121
+ font_meta = entry.get("font") if isinstance(entry.get("font"), dict) else {}
122
+ name = font_meta.get("name")
123
+ if not name:
124
+ continue
125
+ groups = set(fonts.get(name, {}).get("groups", []))
126
+ group = entry.get("group") or entry.get("class")
127
+ if isinstance(group, str) and group.strip():
128
+ groups.add(group)
129
+ ranges = set(fonts.get(name, {}).get("ranges", []))
130
+ entry_ranges = entry.get("ranges")
131
+ if isinstance(entry_ranges, Iterable) and not isinstance(entry_ranges, (str, bytes)):
132
+ ranges.update(str(r) for r in entry_ranges)
133
+ count = fonts.get(name, {}).get("count", 0) or 0
134
+ entry_count = entry.get("count")
135
+ if isinstance(entry_count, (int, float)):
136
+ count += int(entry_count)
137
+ fonts[name] = {
138
+ "name": name,
139
+ "extension": font_meta.get("extension", ".otf"),
140
+ "styles": font_meta.get("styles", []),
141
+ "dir": font_meta.get("dir"),
142
+ "groups": sorted(groups),
143
+ "ranges": sorted(ranges),
144
+ "count": count if count else None,
145
+ }
146
+ return sorted(fonts.values(), key=lambda entry: entry["name"])
147
+
148
+ def _merge_range_strings(self, codes: Iterable[int]) -> list[str]:
149
+ from texsmith.fonts.fallback import FallbackLookup as _Lookup
150
+
151
+ return _Lookup._merge_ranges(list(codes)) # noqa: SLF001
152
+
153
+ def _plan_minimal_fonts(
154
+ self,
155
+ text: str,
156
+ lookup: FallbackLookup,
157
+ *,
158
+ prefer_fonts: Sequence[str] = (),
159
+ allow_cross_group: bool = True,
160
+ ) -> tuple[list[dict], dict[str, dict], list[int]]:
161
+ raw_lookup = lookup.lookup(text)
162
+ coverage_entries = list(self._ensure_coverage())
163
+
164
+ prefer_order: dict[str, int] = {}
165
+ for idx, name in enumerate(prefer_fonts):
166
+ prefer_order[name] = idx
167
+
168
+ group_codepoints: dict[str, set[int]] = {}
169
+ for data in raw_lookup.values():
170
+ if not isinstance(data, dict):
171
+ continue
172
+ group = data.get("group") or data.get("class")
173
+ if not isinstance(group, str) or not group.strip():
174
+ continue
175
+ target = group_codepoints.setdefault(group, set())
176
+ for cp in data.get("ranges", []):
177
+ try:
178
+ target.add(int(cp))
179
+ except Exception:
180
+ continue
181
+
182
+ fonts_plan: dict[str, dict] = {}
183
+ group_primary: dict[str, str] = {}
184
+ uncovered_total: set[int] = set()
185
+
186
+ for group, codepoints in group_codepoints.items():
187
+ if not codepoints:
188
+ continue
189
+ uncovered = set(codepoints)
190
+ if allow_cross_group:
191
+ for font_data in fonts_plan.values():
192
+ covered = font_data.get("codepoints", set())
193
+ overlap = uncovered & covered
194
+ if overlap and group not in font_data.get("groups", set()):
195
+ font_data.setdefault("groups", set()).add(group)
196
+ uncovered -= overlap
197
+ if overlap and group not in group_primary:
198
+ group_primary[group] = font_data["name"]
199
+ if not uncovered:
200
+ continue
201
+
202
+ while uncovered:
203
+ best_meta = None
204
+ best_hits: set[int] = set()
205
+ best_score: tuple[int, int, int] = (0, 0, 0)
206
+
207
+ for meta in coverage_entries:
208
+ hits: set[int] = set()
209
+ for cp in uncovered:
210
+ for start, end in meta.ranges:
211
+ if start <= cp <= end:
212
+ hits.add(cp)
213
+ break
214
+ if not hits:
215
+ continue
216
+ prefer_value = prefer_order.get(meta.file_base, len(prefer_order) + 1)
217
+ total_span = sum(end - start + 1 for start, end in meta.ranges)
218
+ score = (len(hits), -prefer_value, -total_span)
219
+ if score > best_score:
220
+ best_score = score
221
+ best_hits = hits
222
+ best_meta = meta
223
+
224
+ if not best_meta or not best_hits:
225
+ uncovered_total.update(uncovered)
226
+ break
227
+
228
+ name = best_meta.file_base
229
+ plan_entry = fonts_plan.setdefault(
230
+ name,
231
+ {
232
+ "name": name,
233
+ "extension": ".otf",
234
+ "styles": list(best_meta.styles or ["regular", "bold"]),
235
+ "dir": best_meta.dir_base,
236
+ "groups": set(),
237
+ "codepoints": set(),
238
+ },
239
+ )
240
+ plan_entry["groups"].add(group)
241
+ plan_entry["codepoints"].update(best_hits)
242
+ if group not in group_primary:
243
+ group_primary[group] = name
244
+ uncovered -= best_hits
245
+
246
+ fonts_output: list[dict] = []
247
+ for data in sorted(fonts_plan.values(), key=lambda entry: entry["name"]):
248
+ codepoints = data.get("codepoints", set()) or set()
249
+ fonts_output.append(
250
+ {
251
+ "name": data["name"],
252
+ "extension": data.get("extension", ".otf"),
253
+ "styles": list(data.get("styles") or []),
254
+ "dir": data.get("dir"),
255
+ "groups": sorted(data.get("groups", [])),
256
+ "ranges": self._merge_range_strings(codepoints),
257
+ "count": len(codepoints) if codepoints else None,
258
+ }
259
+ )
260
+
261
+ group_fonts: dict[str, dict] = {}
262
+ fonts_by_name = {font["name"]: font for font in fonts_output}
263
+ for group, name in group_primary.items():
264
+ font = fonts_by_name.get(name)
265
+ if font:
266
+ group_fonts[group] = font
267
+
268
+ return fonts_output, group_fonts, sorted(uncovered_total)
269
+
270
+ def scan_text(
271
+ self,
272
+ text: str,
273
+ *,
274
+ strategy: Literal["by_class", "minimal_fonts"] = "by_class",
275
+ prefer_fonts: Sequence[str] = (),
276
+ allow_cross_group: bool = True,
277
+ ) -> FallbackPlan:
278
+ lookup = self._ensure_lookup()
279
+ summary = lookup.summary(text)
280
+ inferred_preference = list(prefer_fonts) or [
281
+ entry["name"]
282
+ for entry in self._fonts_from_summary(summary)
283
+ if isinstance(entry, dict) and entry.get("name")
284
+ ]
285
+ if strategy == "minimal_fonts":
286
+ fonts, group_fonts, uncovered = self._plan_minimal_fonts(
287
+ text,
288
+ lookup,
289
+ prefer_fonts=inferred_preference,
290
+ allow_cross_group=allow_cross_group,
291
+ )
292
+ patched_summary: list[dict] = []
293
+ for entry in summary:
294
+ if not isinstance(entry, dict):
295
+ continue
296
+ group = entry.get("group") or entry.get("class")
297
+ chosen = group_fonts.get(group) if group else None
298
+ if chosen:
299
+ entry = dict(entry)
300
+ entry["font"] = {
301
+ "name": chosen.get("name"),
302
+ "extension": chosen.get("extension", ".otf"),
303
+ "styles": chosen.get("styles") or [],
304
+ "dir": chosen.get("dir"),
305
+ }
306
+ entry["fonts"] = [chosen.get("name")]
307
+ patched_summary.append(entry)
308
+ return FallbackPlan(
309
+ summary=patched_summary,
310
+ fonts=fonts,
311
+ group_fonts=group_fonts,
312
+ uncovered=uncovered,
313
+ strategy=strategy,
314
+ )
315
+
316
+ fonts = self._fonts_from_summary(summary)
317
+ group_fonts: dict[str, dict] = {}
318
+ for entry in fonts:
319
+ groups = entry.get("groups") or []
320
+ if isinstance(groups, (list, tuple, set)):
321
+ for group in groups:
322
+ if isinstance(group, str) and group:
323
+ group_fonts.setdefault(group, entry)
324
+ return FallbackPlan(
325
+ summary=summary,
326
+ fonts=fonts,
327
+ group_fonts=group_fonts,
328
+ uncovered=[],
329
+ strategy="by_class",
330
+ )
331
+
332
+
333
+ __all__ = [
334
+ "FallbackManager",
335
+ "generate_fallback_entries",
336
+ "generate_noto_metadata",
337
+ "generate_ucharclasses_data",
338
+ ]