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,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"]
|