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,275 @@
|
|
|
1
|
+
"""Build a coverage dataset for Noto fonts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import pickle
|
|
9
|
+
import re
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from texsmith.fonts.cache import FontCache
|
|
14
|
+
from texsmith.fonts.logging import FontPipelineLogger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts"
|
|
18
|
+
NOTOFONTS_STATE_URL = (
|
|
19
|
+
"https://raw.githubusercontent.com/notofonts/notofonts.github.io/main/state.json"
|
|
20
|
+
)
|
|
21
|
+
GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2?family={}&display=swap"
|
|
22
|
+
USER_AGENT = (
|
|
23
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
24
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
COVERAGE_CACHE_VERSION = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _http_get(url: str) -> str:
|
|
31
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
32
|
+
with urllib.request.urlopen(req) as response:
|
|
33
|
+
return response.read().decode("utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class NotoCoverage:
|
|
38
|
+
"""A Noto font family and its Unicode coverage ranges."""
|
|
39
|
+
|
|
40
|
+
family: str
|
|
41
|
+
ranges: tuple[tuple[int, int], ...]
|
|
42
|
+
file_base: str
|
|
43
|
+
dir_base: str
|
|
44
|
+
styles: tuple[str, ...]
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_mapping(cls, data: dict) -> NotoCoverage:
|
|
48
|
+
"""Create a NotoCoverage from a mapping."""
|
|
49
|
+
return cls(
|
|
50
|
+
family=data["family"],
|
|
51
|
+
ranges=tuple((int(r[0]), int(r[1])) for r in data.get("ranges", [])),
|
|
52
|
+
file_base=data.get("file_base") or "".join(ch for ch in data["family"] if ch.isalnum()),
|
|
53
|
+
dir_base=data.get("dir_base")
|
|
54
|
+
or data.get("file_base")
|
|
55
|
+
or "".join(ch for ch in data["family"] if ch.isalnum()),
|
|
56
|
+
styles=tuple(data.get("otf_styles", []) or ()),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def to_mapping(self) -> dict:
|
|
60
|
+
"""Convert the NotoCoverage to a mapping."""
|
|
61
|
+
return {
|
|
62
|
+
"family": self.family,
|
|
63
|
+
"ranges": [list(r) for r in self.ranges],
|
|
64
|
+
"file_base": self.file_base,
|
|
65
|
+
"dir_base": self.dir_base,
|
|
66
|
+
"otf_styles": list(self.styles),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NotoCoverageBuilder:
|
|
71
|
+
"""Rebuild the Noto coverage dataset or reuse a cached copy."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
cache: FontCache | None = None,
|
|
77
|
+
logger: FontPipelineLogger | None = None,
|
|
78
|
+
seed_paths: list[Path] | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.cache = cache or FontCache()
|
|
81
|
+
self.logger = logger or FontPipelineLogger()
|
|
82
|
+
self.seed_paths = seed_paths or []
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def cache_path(self) -> Path:
|
|
86
|
+
"""Return the path to the coverage cache file."""
|
|
87
|
+
return self.cache.path("noto_coverage_db.pkl")
|
|
88
|
+
|
|
89
|
+
def _candidate_paths(self) -> list[Path]:
|
|
90
|
+
candidates = [
|
|
91
|
+
self.cache_path,
|
|
92
|
+
*self.seed_paths,
|
|
93
|
+
]
|
|
94
|
+
unique: list[Path] = []
|
|
95
|
+
seen: set[Path] = set()
|
|
96
|
+
for candidate in candidates:
|
|
97
|
+
if candidate in seen:
|
|
98
|
+
continue
|
|
99
|
+
seen.add(candidate)
|
|
100
|
+
unique.append(candidate)
|
|
101
|
+
return unique
|
|
102
|
+
|
|
103
|
+
def _load_dataset(self, candidate: Path) -> list[NotoCoverage] | None:
|
|
104
|
+
if not candidate.exists():
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
raw = pickle.loads(candidate.read_bytes())
|
|
108
|
+
except Exception:
|
|
109
|
+
raw = None
|
|
110
|
+
if (
|
|
111
|
+
isinstance(raw, dict)
|
|
112
|
+
and raw.get("version") == COVERAGE_CACHE_VERSION
|
|
113
|
+
and isinstance(raw.get("data"), list)
|
|
114
|
+
):
|
|
115
|
+
try:
|
|
116
|
+
return [NotoCoverage.from_mapping(entry) for entry in raw["data"]]
|
|
117
|
+
except Exception:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
payload = json.loads(candidate.read_text(encoding="utf-8"))
|
|
121
|
+
except Exception:
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(payload, list):
|
|
124
|
+
try:
|
|
125
|
+
return [NotoCoverage.from_mapping(entry) for entry in payload]
|
|
126
|
+
except Exception:
|
|
127
|
+
return None
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def load_cached(self) -> tuple[list[NotoCoverage], Path] | None:
|
|
131
|
+
for candidate in self._candidate_paths():
|
|
132
|
+
data = self._load_dataset(candidate)
|
|
133
|
+
if data is None:
|
|
134
|
+
continue
|
|
135
|
+
if candidate != self.cache_path:
|
|
136
|
+
self._save_cache(data, quiet=True)
|
|
137
|
+
return data, candidate
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def _fetch_family_list(self) -> list[str]:
|
|
141
|
+
self.logger.info("Fetching the complete Noto family list from Google Fonts...")
|
|
142
|
+
raw = _http_get(GOOGLE_FONTS_METADATA_URL)
|
|
143
|
+
if raw.startswith(")]}'"):
|
|
144
|
+
raw = raw.split("\n", 1)[1]
|
|
145
|
+
payload = json.loads(raw)
|
|
146
|
+
families = [
|
|
147
|
+
entry["family"]
|
|
148
|
+
for entry in payload.get("familyMetadataList", [])
|
|
149
|
+
if entry["family"].startswith("Noto ")
|
|
150
|
+
]
|
|
151
|
+
families.sort()
|
|
152
|
+
self.logger.notice(f"{len(families)} families detected.")
|
|
153
|
+
return families
|
|
154
|
+
|
|
155
|
+
def _normalize_style(self, style: str) -> str | None:
|
|
156
|
+
cleaned = re.sub(r"[^a-z]", "", style.lower())
|
|
157
|
+
mapping = {
|
|
158
|
+
"regular": "regular",
|
|
159
|
+
"italic": "italic",
|
|
160
|
+
"bold": "bold",
|
|
161
|
+
"bolditalic": "bolditalic",
|
|
162
|
+
}
|
|
163
|
+
return mapping.get(cleaned)
|
|
164
|
+
|
|
165
|
+
def _fetch_otf_styles(self) -> dict[str, dict]:
|
|
166
|
+
try:
|
|
167
|
+
state = json.loads(_http_get(NOTOFONTS_STATE_URL))
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
self.logger.warning("Unable to fetch OTF styles (%s); continuing without styles.", exc)
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
index: dict[str, dict] = {}
|
|
173
|
+
for entry in state.values():
|
|
174
|
+
if not isinstance(entry, dict):
|
|
175
|
+
continue
|
|
176
|
+
for family, meta in entry.get("families", {}).items():
|
|
177
|
+
styles: set[str] = set()
|
|
178
|
+
file_base = None
|
|
179
|
+
dir_base = None
|
|
180
|
+
for path in meta.get("files", []):
|
|
181
|
+
if "/otf/" not in path:
|
|
182
|
+
continue
|
|
183
|
+
filename = path.split("/")[-1]
|
|
184
|
+
if not filename.lower().endswith(".otf"):
|
|
185
|
+
continue
|
|
186
|
+
dir_base = path.split("/")[1] if len(path.split("/")) > 1 else None
|
|
187
|
+
base_part, sep, style_part = filename.rpartition("-")
|
|
188
|
+
if not sep:
|
|
189
|
+
continue
|
|
190
|
+
style_name = style_part.rsplit(".", 1)[0]
|
|
191
|
+
normalized = self._normalize_style(style_name)
|
|
192
|
+
if normalized:
|
|
193
|
+
styles.add(normalized)
|
|
194
|
+
if not file_base:
|
|
195
|
+
file_base = base_part
|
|
196
|
+
family_key = "".join(ch for ch in family if ch.isalnum())
|
|
197
|
+
if styles:
|
|
198
|
+
index[family] = {
|
|
199
|
+
"styles": sorted(styles),
|
|
200
|
+
"file_base": file_base or family_key,
|
|
201
|
+
"dir_base": dir_base or family_key,
|
|
202
|
+
}
|
|
203
|
+
index[family_key] = index[family]
|
|
204
|
+
return index
|
|
205
|
+
|
|
206
|
+
def _fetch_ranges(self, family: str) -> list[tuple[int, int]] | None:
|
|
207
|
+
safe_name = urllib.parse.quote(family)
|
|
208
|
+
url = GOOGLE_FONTS_CSS.format(safe_name)
|
|
209
|
+
try:
|
|
210
|
+
css = _http_get(url)
|
|
211
|
+
except Exception:
|
|
212
|
+
return None
|
|
213
|
+
matches = re.findall(r"unicode-range:\s*([^;]+);", css)
|
|
214
|
+
ranges: list[tuple[int, int]] = []
|
|
215
|
+
for match in matches:
|
|
216
|
+
parts = match.replace("unicode-range:", "").strip().split(",")
|
|
217
|
+
for part in parts:
|
|
218
|
+
part = part.strip().replace("U+", "")
|
|
219
|
+
if "-" in part:
|
|
220
|
+
start_str, end_str = part.split("-", 1)
|
|
221
|
+
ranges.append((int(start_str, 16), int(end_str, 16)))
|
|
222
|
+
elif part and "?" not in part:
|
|
223
|
+
val = int(part, 16)
|
|
224
|
+
ranges.append((val, val))
|
|
225
|
+
return ranges or None
|
|
226
|
+
|
|
227
|
+
def _save_cache(self, data: list[NotoCoverage], *, quiet: bool = False) -> None:
|
|
228
|
+
payload = {
|
|
229
|
+
"version": COVERAGE_CACHE_VERSION,
|
|
230
|
+
"data": [entry.to_mapping() for entry in data],
|
|
231
|
+
}
|
|
232
|
+
try:
|
|
233
|
+
self.cache_path.write_bytes(pickle.dumps(payload))
|
|
234
|
+
if not quiet:
|
|
235
|
+
self.logger.info("Noto coverage cache written to %s", self.cache_path)
|
|
236
|
+
except Exception:
|
|
237
|
+
self.logger.warning("Unable to write the Noto coverage cache.")
|
|
238
|
+
|
|
239
|
+
def build(self) -> list[NotoCoverage]:
|
|
240
|
+
cached = self.load_cached()
|
|
241
|
+
if cached:
|
|
242
|
+
data, source = cached
|
|
243
|
+
self.logger.notice("Noto coverage loaded from %s", source)
|
|
244
|
+
return data
|
|
245
|
+
|
|
246
|
+
families = self._fetch_family_list()
|
|
247
|
+
styles_index = self._fetch_otf_styles()
|
|
248
|
+
dataset: list[NotoCoverage] = []
|
|
249
|
+
with self.logger.progress("Scanning Noto families", total=len(families)) as advance:
|
|
250
|
+
for family in families:
|
|
251
|
+
ranges = self._fetch_ranges(family)
|
|
252
|
+
advance()
|
|
253
|
+
if not ranges:
|
|
254
|
+
continue
|
|
255
|
+
key = "".join(ch for ch in family if ch.isalnum())
|
|
256
|
+
styles_meta = styles_index.get(family) or styles_index.get(key) or {}
|
|
257
|
+
dataset.append(
|
|
258
|
+
NotoCoverage(
|
|
259
|
+
family=family,
|
|
260
|
+
ranges=tuple(ranges),
|
|
261
|
+
file_base=styles_meta.get("file_base", key),
|
|
262
|
+
dir_base=styles_meta.get("dir_base", key),
|
|
263
|
+
styles=tuple(styles_meta.get("styles", [])),
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
self._save_cache(dataset)
|
|
267
|
+
return dataset
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
__all__ = [
|
|
271
|
+
"GOOGLE_FONTS_METADATA_URL",
|
|
272
|
+
"NOTOFONTS_STATE_URL",
|
|
273
|
+
"NotoCoverage",
|
|
274
|
+
"NotoCoverageBuilder",
|
|
275
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Helpers to fetch Noto font files on demand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
from texsmith.fonts.cache import FontCache
|
|
9
|
+
from texsmith.fonts.constants import (
|
|
10
|
+
CJK_ALIASES,
|
|
11
|
+
filename_base,
|
|
12
|
+
guess_cjk_path,
|
|
13
|
+
style_suffix,
|
|
14
|
+
)
|
|
15
|
+
from texsmith.fonts.logging import FontPipelineLogger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotoFontDownloader:
|
|
19
|
+
"""Download individual Noto font files into the font cache."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
cache: FontCache | None = None,
|
|
25
|
+
logger: FontPipelineLogger | None = None,
|
|
26
|
+
):
|
|
27
|
+
self.cache = cache or FontCache()
|
|
28
|
+
self.logger = logger or FontPipelineLogger()
|
|
29
|
+
self.fonts_dir = self.cache.path("fonts")
|
|
30
|
+
|
|
31
|
+
def _download(self, url: str, dest: Path) -> bool:
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(url) as response:
|
|
34
|
+
data = response.read()
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
if len(data) < 1024:
|
|
38
|
+
return False
|
|
39
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
try:
|
|
41
|
+
dest.write_bytes(data)
|
|
42
|
+
except Exception:
|
|
43
|
+
return False
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
def _download_font_file(self, filename: str, dir_base: str | None, dest: Path) -> bool:
|
|
47
|
+
base = filename_base(filename)
|
|
48
|
+
alias = CJK_ALIASES.get(base)
|
|
49
|
+
if alias:
|
|
50
|
+
real_base, rel_dir = alias
|
|
51
|
+
real_filename = filename.replace(base, real_base, 1)
|
|
52
|
+
cjk_url = f"https://raw.githubusercontent.com/notofonts/noto-cjk/main/{rel_dir}{real_filename}"
|
|
53
|
+
if self._download(cjk_url, dest):
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
dir_part = dir_base or base
|
|
57
|
+
url_candidates = [
|
|
58
|
+
f"https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/{dir_part}/unhinted/otf/{filename}",
|
|
59
|
+
f"https://raw.githubusercontent.com/notofonts/notofonts.github.io/master/fonts/{dir_part}/unhinted/otf/{filename}",
|
|
60
|
+
f"https://raw.githubusercontent.com/notofonts/notofonts.github.io/master/fonts/{dir_part}/full/otf/{filename}",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if "CJK" in filename or alias:
|
|
64
|
+
guess = guess_cjk_path(filename)
|
|
65
|
+
if guess:
|
|
66
|
+
url_candidates.append(
|
|
67
|
+
f"https://raw.githubusercontent.com/notofonts/noto-cjk/main/{guess}{filename}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
for url in url_candidates:
|
|
71
|
+
if self._download(url, dest):
|
|
72
|
+
return True
|
|
73
|
+
return dest.exists()
|
|
74
|
+
|
|
75
|
+
def ensure(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
font_name: str,
|
|
79
|
+
styles: list[str] | tuple[str, ...] | set[str] | None,
|
|
80
|
+
extension: str,
|
|
81
|
+
dir_base: str | None,
|
|
82
|
+
) -> None:
|
|
83
|
+
styles = list(styles or ["regular", "bold"])
|
|
84
|
+
for style in styles:
|
|
85
|
+
suffix = style_suffix(style)
|
|
86
|
+
filename = f"{font_name}-{suffix}{extension}"
|
|
87
|
+
dest = self.fonts_dir / filename
|
|
88
|
+
if dest.exists():
|
|
89
|
+
continue
|
|
90
|
+
if self._download_font_file(filename, dir_base, dest):
|
|
91
|
+
self.logger.info("Downloaded font %s", filename)
|
|
92
|
+
continue
|
|
93
|
+
# Last-resort fallback to plain NotoSans to avoid missing files.
|
|
94
|
+
if font_name != "NotoSans":
|
|
95
|
+
fallback_filename = f"NotoSans-{suffix}{extension}"
|
|
96
|
+
fallback_dest = self.fonts_dir / fallback_filename
|
|
97
|
+
if fallback_dest.exists():
|
|
98
|
+
continue
|
|
99
|
+
if self._download_font_file(fallback_filename, "noto-sans", fallback_dest):
|
|
100
|
+
self.logger.info("Downloaded fallback font %s", fallback_filename)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = ["NotoFontDownloader"]
|