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.
- texsmith/__init__.py +107 -0
- texsmith/_alias.py +59 -0
- texsmith/adapters/__init__.py +6 -0
- texsmith/adapters/docker.py +258 -0
- texsmith/adapters/handlers/__init__.py +3 -0
- texsmith/adapters/handlers/_assets.py +363 -0
- texsmith/adapters/handlers/_helpers.py +62 -0
- texsmith/adapters/handlers/_mermaid.py +122 -0
- texsmith/adapters/handlers/admonitions.py +267 -0
- texsmith/adapters/handlers/basic.py +228 -0
- texsmith/adapters/handlers/blocks.py +851 -0
- texsmith/adapters/handlers/code.py +309 -0
- texsmith/adapters/handlers/inline.py +937 -0
- texsmith/adapters/handlers/links.py +239 -0
- texsmith/adapters/handlers/media.py +380 -0
- texsmith/adapters/latex/__init__.py +10 -0
- texsmith/adapters/latex/engines/__init__.py +726 -0
- texsmith/adapters/latex/engines/latex/__init__.py +26 -0
- texsmith/adapters/latex/engines/latex/log.py +720 -0
- texsmith/adapters/latex/engines/latex/runner.py +28 -0
- texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
- texsmith/adapters/latex/formatter.py +297 -0
- texsmith/adapters/latex/latexmk.py +148 -0
- texsmith/adapters/latex/partials/acronym.tex +1 -0
- texsmith/adapters/latex/partials/add.tex +1 -0
- texsmith/adapters/latex/partials/addition.tex +1 -0
- texsmith/adapters/latex/partials/blockquote.tex +3 -0
- texsmith/adapters/latex/partials/callout.tex +3 -0
- texsmith/adapters/latex/partials/choices.tex +5 -0
- texsmith/adapters/latex/partials/citation.tex +1 -0
- texsmith/adapters/latex/partials/codeblock.tex +7 -0
- texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
- texsmith/adapters/latex/partials/codeinline.tex +1 -0
- texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
- texsmith/adapters/latex/partials/comment.tex +1 -0
- texsmith/adapters/latex/partials/del.tex +1 -0
- texsmith/adapters/latex/partials/deletion.tex +1 -0
- texsmith/adapters/latex/partials/description_list.tex +5 -0
- texsmith/adapters/latex/partials/enquote.tex +1 -0
- texsmith/adapters/latex/partials/epigraph.tex +1 -0
- texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
- texsmith/adapters/latex/partials/figure.tex +33 -0
- texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
- texsmith/adapters/latex/partials/footnote.tex +3 -0
- texsmith/adapters/latex/partials/glossary.tex +1 -0
- texsmith/adapters/latex/partials/heading.tex +14 -0
- texsmith/adapters/latex/partials/highlight.tex +25 -0
- texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
- texsmith/adapters/latex/partials/href.tex +1 -0
- texsmith/adapters/latex/partials/icon.tex +1 -0
- texsmith/adapters/latex/partials/include.tex +1 -0
- texsmith/adapters/latex/partials/index.tex +1 -0
- texsmith/adapters/latex/partials/italic.tex +1 -0
- texsmith/adapters/latex/partials/keystroke.tex +46 -0
- texsmith/adapters/latex/partials/label.tex +1 -0
- texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
- texsmith/adapters/latex/partials/list_glossary.tex +8 -0
- texsmith/adapters/latex/partials/multicolumn.tex +3 -0
- texsmith/adapters/latex/partials/ordered_list.tex +5 -0
- texsmith/adapters/latex/partials/pagestyle.tex +1 -0
- texsmith/adapters/latex/partials/ref.tex +1 -0
- texsmith/adapters/latex/partials/regex.tex +1 -0
- texsmith/adapters/latex/partials/smallcaps.tex +1 -0
- texsmith/adapters/latex/partials/strikethrough.tex +1 -0
- texsmith/adapters/latex/partials/strong.tex +1 -0
- texsmith/adapters/latex/partials/subscript.tex +1 -0
- texsmith/adapters/latex/partials/substitution.tex +1 -0
- texsmith/adapters/latex/partials/superscript.tex +1 -0
- texsmith/adapters/latex/partials/tabbed.tex +3 -0
- texsmith/adapters/latex/partials/table.tex +48 -0
- texsmith/adapters/latex/partials/underline.tex +1 -0
- texsmith/adapters/latex/partials/unordered_list.tex +5 -0
- texsmith/adapters/latex/partials/url.tex +1 -0
- texsmith/adapters/latex/pygments.py +98 -0
- texsmith/adapters/latex/pyxindy.py +64 -0
- texsmith/adapters/latex/renderer.py +220 -0
- texsmith/adapters/latex/tectonic.py +366 -0
- texsmith/adapters/latex/utils.py +86 -0
- texsmith/adapters/markdown/__init__.py +342 -0
- texsmith/adapters/plugins/__init__.py +8 -0
- texsmith/adapters/plugins/material.py +174 -0
- texsmith/adapters/plugins/snippet.py +1734 -0
- texsmith/adapters/transformers/__init__.py +122 -0
- texsmith/adapters/transformers/base.py +140 -0
- texsmith/adapters/transformers/strategies.py +1456 -0
- texsmith/adapters/transformers/utils.py +59 -0
- texsmith/api/__init__.py +80 -0
- texsmith/api/_utils.py +56 -0
- texsmith/api/document.py +645 -0
- texsmith/api/pipeline.py +229 -0
- texsmith/api/service.py +648 -0
- texsmith/api/templates.py +287 -0
- texsmith/core/__init__.py +6 -0
- texsmith/core/bibliography/__init__.py +57 -0
- texsmith/core/bibliography/collection.py +354 -0
- texsmith/core/bibliography/doi.py +194 -0
- texsmith/core/bibliography/issues.py +15 -0
- texsmith/core/bibliography/parsing.py +45 -0
- texsmith/core/callouts.py +99 -0
- texsmith/core/config.py +191 -0
- texsmith/core/context.py +242 -0
- texsmith/core/conversion/__init__.py +103 -0
- texsmith/core/conversion/core.py +780 -0
- texsmith/core/conversion/debug.py +87 -0
- texsmith/core/conversion/inputs.py +474 -0
- texsmith/core/conversion/renderer.py +578 -0
- texsmith/core/conversion/templates.py +646 -0
- texsmith/core/conversion_contexts.py +95 -0
- texsmith/core/diagnostics.py +118 -0
- texsmith/core/exceptions.py +41 -0
- texsmith/core/fonts/__init__.py +3 -0
- texsmith/core/fragments/__init__.py +716 -0
- texsmith/core/fragments/base.py +117 -0
- texsmith/core/metadata.py +280 -0
- texsmith/core/mustache.py +86 -0
- texsmith/core/partials.py +20 -0
- texsmith/core/rules.py +394 -0
- texsmith/core/templates/__init__.py +58 -0
- texsmith/core/templates/base.py +343 -0
- texsmith/core/templates/builtins.py +68 -0
- texsmith/core/templates/context_usage.py +137 -0
- texsmith/core/templates/loader.py +303 -0
- texsmith/core/templates/manifest.py +861 -0
- texsmith/core/templates/runtime.py +347 -0
- texsmith/core/templates/text.py +14 -0
- texsmith/core/templates/wrapper.py +340 -0
- texsmith/core/user_dir.py +179 -0
- texsmith/devtools.py +28 -0
- texsmith/extensions/__init__.py +162 -0
- texsmith/extensions/index/__init__.py +21 -0
- texsmith/extensions/index/markdown.py +94 -0
- texsmith/extensions/index/mkdocs_plugin.py +136 -0
- texsmith/extensions/index/registry.py +57 -0
- texsmith/extensions/index/renderer.py +183 -0
- texsmith/extensions/index/templates/index.tex +1 -0
- texsmith/extensions/latex_raw.py +115 -0
- texsmith/extensions/latex_text.py +117 -0
- texsmith/extensions/mermaid.py +267 -0
- texsmith/extensions/missing_footnotes.py +125 -0
- texsmith/extensions/multi_citations.py +54 -0
- texsmith/extensions/progressbar/__init__.py +9 -0
- texsmith/extensions/progressbar/markdown.py +212 -0
- texsmith/extensions/progressbar/renderer.py +117 -0
- texsmith/extensions/smallcaps.py +48 -0
- texsmith/extensions/texlogos/__init__.py +10 -0
- texsmith/extensions/texlogos/markdown.py +236 -0
- texsmith/extensions/texlogos/renderer.py +81 -0
- texsmith/extensions/texlogos/specs.py +66 -0
- texsmith/fonts/__init__.py +57 -0
- texsmith/fonts/cache.py +46 -0
- texsmith/fonts/constants.py +60 -0
- texsmith/fonts/coverage.py +275 -0
- texsmith/fonts/downloader.py +103 -0
- texsmith/fonts/fallback.py +438 -0
- texsmith/fonts/html_scripts.py +168 -0
- texsmith/fonts/logging.py +127 -0
- texsmith/fonts/pipeline.py +338 -0
- texsmith/fonts/scripts.py +493 -0
- texsmith/fonts/ucharclasses.py +164 -0
- texsmith/fragments/__init__.py +11 -0
- texsmith/fragments/bibliography/__init__.py +65 -0
- texsmith/fragments/bibliography/fragment.toml +3 -0
- texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
- texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
- texsmith/fragments/callouts/__init__.py +88 -0
- texsmith/fragments/callouts/fragment.toml +3 -0
- texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
- texsmith/fragments/code/__init__.py +82 -0
- texsmith/fragments/code/fragment.toml +3 -0
- texsmith/fragments/code/ts-code.jinja.sty +140 -0
- texsmith/fragments/extra/__init__.py +180 -0
- texsmith/fragments/extra/fragment.toml +3 -0
- texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
- texsmith/fragments/fonts/__init__.py +880 -0
- texsmith/fragments/fonts/fragment.toml +3 -0
- texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
- texsmith/fragments/frame/__init__.py +170 -0
- texsmith/fragments/frame/fragment.toml +3 -0
- texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
- texsmith/fragments/geometry/__init__.py +169 -0
- texsmith/fragments/geometry/fragment.toml +3 -0
- texsmith/fragments/geometry/paper.py +526 -0
- texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
- texsmith/fragments/glossary/__init__.py +67 -0
- texsmith/fragments/glossary/fragment.toml +3 -0
- texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
- texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
- texsmith/fragments/index/__init__.py +66 -0
- texsmith/fragments/index/fragment.toml +3 -0
- texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
- texsmith/fragments/index/ts-index.jinja.sty +12 -0
- texsmith/fragments/keystrokes/__init__.py +69 -0
- texsmith/fragments/keystrokes/fragment.toml +3 -0
- texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
- texsmith/fragments/todolist/__init__.py +71 -0
- texsmith/fragments/todolist/fragment.toml +3 -0
- texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
- texsmith/fragments/typesetting/__init__.py +214 -0
- texsmith/fragments/typesetting/fragment.toml +3 -0
- texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
- texsmith/index.py +26 -0
- texsmith/plugins/__init__.py +14 -0
- texsmith/progressbar.py +8 -0
- texsmith/quotes.py +40 -0
- texsmith/smart_dashes.py +66 -0
- texsmith/templates/__init__.py +3 -0
- texsmith/templates/article/README.md +33 -0
- texsmith/templates/article/__init__.py +281 -0
- texsmith/templates/article/template/manifest.toml +125 -0
- texsmith/templates/article/template/mermaid-config.json +18 -0
- texsmith/templates/article/template/template.tex +74 -0
- texsmith/templates/book/README.md +26 -0
- texsmith/templates/book/__init__.py +75 -0
- texsmith/templates/book/overrides/codeblock.tex +7 -0
- texsmith/templates/book/overrides/codeinline.tex +1 -0
- texsmith/templates/book/template/fixtoc.sty +81 -0
- texsmith/templates/book/template/manifest.toml +198 -0
- texsmith/templates/book/template/template.tex +314 -0
- texsmith/templates/common/__init__.py +1 -0
- texsmith/templates/common/latexmkrc +46 -0
- texsmith/templates/letter/README.md +59 -0
- texsmith/templates/letter/__init__.py +495 -0
- texsmith/templates/letter/demo.md +31 -0
- texsmith/templates/letter/fonts/modernline bold.otf +0 -0
- texsmith/templates/letter/fonts/modernline.otf +0 -0
- texsmith/templates/letter/manifest.toml +197 -0
- texsmith/templates/letter/template/callouts.jinja.sty +290 -0
- texsmith/templates/letter/template/template.tex +123 -0
- texsmith/templates/snippet/README.md +21 -0
- texsmith/templates/snippet/__init__.py +80 -0
- texsmith/templates/snippet/template/manifest.toml +66 -0
- texsmith/templates/snippet/template/template.tex +47 -0
- texsmith/texlogos.py +15 -0
- texsmith/ui/__init__.py +6 -0
- texsmith/ui/cli/__init__.py +22 -0
- texsmith/ui/cli/_options.py +338 -0
- texsmith/ui/cli/app.py +65 -0
- texsmith/ui/cli/bibliography.py +300 -0
- texsmith/ui/cli/commands/__init__.py +14 -0
- texsmith/ui/cli/commands/render.py +1128 -0
- texsmith/ui/cli/commands/templates.py +397 -0
- texsmith/ui/cli/diagnostics.py +36 -0
- texsmith/ui/cli/presenter.py +663 -0
- texsmith/ui/cli/state.py +263 -0
- texsmith/ui/cli/utils.py +235 -0
- texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
- texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
- texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
- texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
- 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
|
+
]
|