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,880 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
import contextlib
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Any, ClassVar
|
|
11
|
+
import urllib.request
|
|
12
|
+
import warnings
|
|
13
|
+
import zipfile
|
|
14
|
+
|
|
15
|
+
from texsmith.core.fragments.base import BaseFragment, FragmentPiece
|
|
16
|
+
from texsmith.core.templates.manifest import TemplateAttributeSpec, TemplateError
|
|
17
|
+
from texsmith.core.user_dir import get_user_dir
|
|
18
|
+
from texsmith.fonts.cache import FontCache
|
|
19
|
+
from texsmith.fonts.constants import style_suffix
|
|
20
|
+
from texsmith.fonts.downloader import NotoFontDownloader
|
|
21
|
+
from texsmith.fonts.logging import FontPipelineLogger
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_FAMILY_CHOICES: dict[str, str] = {
|
|
25
|
+
"lm": "lm",
|
|
26
|
+
"lm-sans": "lm-sans",
|
|
27
|
+
"bonum": "bonum",
|
|
28
|
+
"libertinus": "libertinus",
|
|
29
|
+
"pagella": "pagella",
|
|
30
|
+
"termes": "termes",
|
|
31
|
+
"schola": "schola",
|
|
32
|
+
"heros": "heros",
|
|
33
|
+
"heros-otf": "heros",
|
|
34
|
+
"adventor": "adventor",
|
|
35
|
+
"adventor-otf": "adventor",
|
|
36
|
+
"cursor": "cursor",
|
|
37
|
+
"cursor-otf": "cursor",
|
|
38
|
+
"plex": "plex",
|
|
39
|
+
"pennstander": "pennstander",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_CTAN_DEPENDENCIES: dict[str, dict[str, str]] = {
|
|
43
|
+
"bonum": {
|
|
44
|
+
"package": "bonum-otf",
|
|
45
|
+
"url": "https://mirrors.ctan.org/fonts/bonum-otf.zip",
|
|
46
|
+
},
|
|
47
|
+
"pagella": {
|
|
48
|
+
"package": "pagella-otf",
|
|
49
|
+
"url": "https://mirrors.ctan.org/fonts/pagella-otf.zip",
|
|
50
|
+
},
|
|
51
|
+
"termes": {
|
|
52
|
+
"package": "termes-otf",
|
|
53
|
+
"url": "https://mirrors.ctan.org/fonts/termes-otf.zip",
|
|
54
|
+
},
|
|
55
|
+
"schola": {
|
|
56
|
+
"package": "schola-otf",
|
|
57
|
+
"url": "https://mirrors.ctan.org/fonts/schola-otf.zip",
|
|
58
|
+
},
|
|
59
|
+
"heros": {
|
|
60
|
+
"package": "heros-otf",
|
|
61
|
+
"url": "https://mirrors.ctan.org/fonts/heros-otf.zip",
|
|
62
|
+
},
|
|
63
|
+
"adventor": {
|
|
64
|
+
"package": "adventor-otf",
|
|
65
|
+
"url": "https://mirrors.ctan.org/fonts/tex-gyre.zip",
|
|
66
|
+
},
|
|
67
|
+
"cursor": {
|
|
68
|
+
"package": "cursor-otf",
|
|
69
|
+
"url": "https://mirrors.ctan.org/fonts/tex-gyre.zip",
|
|
70
|
+
},
|
|
71
|
+
"plex": {
|
|
72
|
+
"package": "plex-otf",
|
|
73
|
+
"url": "https://mirrors.ctan.org/fonts/plex-otf.zip",
|
|
74
|
+
},
|
|
75
|
+
"pennstander": {
|
|
76
|
+
"package": "pennstander-otf",
|
|
77
|
+
"url": "https://mirrors.ctan.org/fonts/pennstander-otf.zip",
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_PLEX_URL = "https://github.com/IBM/plex/releases/download/v6.0.0/TrueType.zip"
|
|
82
|
+
_OPENMOJI_URL = "https://github.com/hfg-gmuend/openmoji/releases/latest/download/openmoji-font.zip"
|
|
83
|
+
_OPENMOJI_FILE_CANDIDATES = {
|
|
84
|
+
"OpenMoji-black-glyf.ttf",
|
|
85
|
+
"OpenMoji-black-glyf/OpenMoji-black-glyf.ttf",
|
|
86
|
+
"fonts/OpenMoji-black-glyf.ttf",
|
|
87
|
+
}
|
|
88
|
+
_NOTO_COLOR_EMOJI_URL = (
|
|
89
|
+
"https://github.com/googlefonts/noto-emoji/raw/refs/heads/main/fonts/NotoColorEmoji.ttf"
|
|
90
|
+
)
|
|
91
|
+
_SKIP_GROUPS = {"latin", "common", "punctuation", "other"}
|
|
92
|
+
_FALLBACK_ALIASES: dict[str, dict[str, object]] = {
|
|
93
|
+
# Prefer widely available Noto families when coverage picks display variants.
|
|
94
|
+
"cyrillics": {"name": "NotoSans", "styles": ["regular", "bold"], "extension": ".otf"},
|
|
95
|
+
"diacritics": {"name": "NotoSans", "styles": ["regular", "bold"], "extension": ".otf"},
|
|
96
|
+
"devanagari": {
|
|
97
|
+
"name": "NotoSansDevanagari",
|
|
98
|
+
"styles": ["regular", "bold"],
|
|
99
|
+
"extension": ".otf",
|
|
100
|
+
"dir": "NotoSansDevanagari",
|
|
101
|
+
},
|
|
102
|
+
"chinese": {"name": "NotoSansSC", "styles": ["regular", "bold"], "extension": ".otf"},
|
|
103
|
+
"symbols": {
|
|
104
|
+
"name": "OpenMojiBlack",
|
|
105
|
+
"styles": ["regular"],
|
|
106
|
+
"extension": ".ttf",
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _resolve_emoji_mode(context: Mapping[str, Any]) -> str:
|
|
112
|
+
"""Infer requested emoji mode from template or press settings."""
|
|
113
|
+
candidates: list[Any] = []
|
|
114
|
+
fonts_cfg = context.get("fonts")
|
|
115
|
+
if isinstance(context.get("emoji"), str):
|
|
116
|
+
candidates.append(context.get("emoji"))
|
|
117
|
+
if isinstance(context.get("emoji_mode"), str):
|
|
118
|
+
candidates.append(context.get("emoji_mode"))
|
|
119
|
+
if isinstance(fonts_cfg, Mapping) and isinstance(fonts_cfg.get("emoji"), str):
|
|
120
|
+
candidates.append(fonts_cfg.get("emoji"))
|
|
121
|
+
for value in candidates:
|
|
122
|
+
lowered = str(value).strip().lower()
|
|
123
|
+
if lowered in {"artifact", "symbola", "color", "black", "twemoji"}:
|
|
124
|
+
return lowered
|
|
125
|
+
return "black"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _normalise_family(raw_value: Any) -> str:
|
|
129
|
+
if isinstance(raw_value, str):
|
|
130
|
+
candidate = raw_value.strip().lower()
|
|
131
|
+
if candidate:
|
|
132
|
+
mapped = _FAMILY_CHOICES.get(candidate)
|
|
133
|
+
if mapped:
|
|
134
|
+
return mapped
|
|
135
|
+
warnings.warn(f"Unknown font family '{raw_value}', falling back to 'lm'.", stacklevel=3)
|
|
136
|
+
return "lm"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _sty_available(sty_name: str) -> bool:
|
|
140
|
+
kpse = shutil.which("kpsewhich")
|
|
141
|
+
if not kpse:
|
|
142
|
+
return False
|
|
143
|
+
try:
|
|
144
|
+
result = subprocess.run(
|
|
145
|
+
[kpse, sty_name],
|
|
146
|
+
check=False,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
)
|
|
150
|
+
except OSError:
|
|
151
|
+
return False
|
|
152
|
+
return result.returncode == 0 and bool(result.stdout.strip())
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _download_archive(url: str, destination: Path) -> None:
|
|
156
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
if destination.exists():
|
|
158
|
+
return
|
|
159
|
+
try:
|
|
160
|
+
with urllib.request.urlopen(url) as response, destination.open("wb") as handle:
|
|
161
|
+
shutil.copyfileobj(response, handle)
|
|
162
|
+
except OSError as exc:
|
|
163
|
+
raise TemplateError(f"Failed to download '{url}': {exc}") from exc
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _extract_sty_from_archive(archive: Path, sty_name: str, target: Path) -> Path:
|
|
167
|
+
with zipfile.ZipFile(archive) as zf:
|
|
168
|
+
for entry in zf.infolist():
|
|
169
|
+
if entry.is_dir():
|
|
170
|
+
continue
|
|
171
|
+
if Path(entry.filename).name != sty_name:
|
|
172
|
+
continue
|
|
173
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
with zf.open(entry) as src, target.open("wb") as dst:
|
|
175
|
+
shutil.copyfileobj(src, dst)
|
|
176
|
+
return target
|
|
177
|
+
raise TemplateError(f"Could not locate '{sty_name}' inside '{archive.name}'.")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _write_stub_package(package_name: str, family: str, target: Path) -> Path:
|
|
181
|
+
fonts: dict[str, str] = {
|
|
182
|
+
"bonum": "TeX Gyre Bonum",
|
|
183
|
+
"pagella": "TeX Gyre Pagella",
|
|
184
|
+
"termes": "TeX Gyre Termes",
|
|
185
|
+
"schola": "TeX Gyre Schola",
|
|
186
|
+
"heros": "TeX Gyre Heros",
|
|
187
|
+
"adventor": "TeX Gyre Adventor",
|
|
188
|
+
"cursor": "TeX Gyre Cursor",
|
|
189
|
+
"plex": "IBM Plex Serif",
|
|
190
|
+
"pennstander": "Latin Modern Roman",
|
|
191
|
+
}
|
|
192
|
+
font_name = fonts.get(family, family)
|
|
193
|
+
lines = [
|
|
194
|
+
f"\\ProvidesPackage{{{package_name}}}[Auto-generated stub]",
|
|
195
|
+
"\\RequirePackage{fontspec}",
|
|
196
|
+
f"\\setmainfont{{{font_name}}}",
|
|
197
|
+
]
|
|
198
|
+
if family in {"heros", "adventor"}:
|
|
199
|
+
lines.append(f"\\setsansfont{{{font_name}}}")
|
|
200
|
+
if family == "cursor":
|
|
201
|
+
lines.append(f"\\setmonofont{{{font_name}}}")
|
|
202
|
+
if family in {"bonum", "pagella", "termes"}:
|
|
203
|
+
lines.append(f"\\setmathfont{{{font_name} Math}}")
|
|
204
|
+
if family == "pennstander":
|
|
205
|
+
lines.append("\\setsansfont{Latin Modern Sans}")
|
|
206
|
+
lines.append("\\setmonofont{Latin Modern Mono}")
|
|
207
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
209
|
+
return target
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _ensure_noto_color_emoji(cache: FontCache) -> Path | None:
|
|
213
|
+
"""Download the Noto Color Emoji font (TTF) into the cache."""
|
|
214
|
+
dest = cache.path("NotoColorEmoji.ttf")
|
|
215
|
+
if dest.exists():
|
|
216
|
+
return dest
|
|
217
|
+
try:
|
|
218
|
+
_download_archive(_NOTO_COLOR_EMOJI_URL, dest)
|
|
219
|
+
except Exception as exc: # pragma: no cover - network edge
|
|
220
|
+
warnings.warn(f"Unable to download Noto Color Emoji font: {exc}", stacklevel=2)
|
|
221
|
+
return None
|
|
222
|
+
return dest if dest.exists() else None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _ensure_openmoji_black(cache: FontCache) -> Path | None:
|
|
226
|
+
"""Download and extract the OpenMoji black glyph font into the cache."""
|
|
227
|
+
dest = cache.path("OpenMoji-black-glyf.ttf")
|
|
228
|
+
if dest.exists():
|
|
229
|
+
return dest
|
|
230
|
+
with cache.tempdir() as tmp_dir:
|
|
231
|
+
archive = tmp_dir / "openmoji.zip"
|
|
232
|
+
try:
|
|
233
|
+
_download_archive(_OPENMOJI_URL, archive)
|
|
234
|
+
with zipfile.ZipFile(archive) as zf:
|
|
235
|
+
candidate = next(
|
|
236
|
+
(
|
|
237
|
+
name
|
|
238
|
+
for name in zf.namelist()
|
|
239
|
+
if name.lower().endswith("openmoji-black-glyf.ttf")
|
|
240
|
+
or name in _OPENMOJI_FILE_CANDIDATES
|
|
241
|
+
),
|
|
242
|
+
None,
|
|
243
|
+
)
|
|
244
|
+
if candidate is None:
|
|
245
|
+
raise FileNotFoundError("OpenMoji black glyph font not found in archive.") # noqa: TRY301
|
|
246
|
+
extracted = zf.extract(candidate, tmp_dir)
|
|
247
|
+
extracted_path = Path(extracted)
|
|
248
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
shutil.copy2(extracted_path, dest)
|
|
250
|
+
return dest
|
|
251
|
+
except Exception as exc: # pragma: no cover - network edge
|
|
252
|
+
warnings.warn(f"Unable to download OpenMoji font: {exc}", stacklevel=2)
|
|
253
|
+
return None
|
|
254
|
+
return dest if dest.exists() else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _write_otf_package(
|
|
258
|
+
*,
|
|
259
|
+
package_name: str,
|
|
260
|
+
family: str,
|
|
261
|
+
target: Path,
|
|
262
|
+
fonts_path: Path,
|
|
263
|
+
font_prefix: str,
|
|
264
|
+
) -> Path:
|
|
265
|
+
path_option = fonts_path.relative_to(target.parent).as_posix()
|
|
266
|
+
fonts = {
|
|
267
|
+
"regular": f"{font_prefix}-regular.otf",
|
|
268
|
+
"bold": f"{font_prefix}-bold.otf",
|
|
269
|
+
"italic": f"{font_prefix}-italic.otf",
|
|
270
|
+
"bold_italic": f"{font_prefix}-bolditalic.otf",
|
|
271
|
+
}
|
|
272
|
+
lines = [
|
|
273
|
+
f"\\ProvidesPackage{{{package_name}}}[Auto-generated OTF wrapper for {family}]",
|
|
274
|
+
"\\RequirePackage{fontspec}",
|
|
275
|
+
f"\\setmainfont{{{fonts['regular']}}}[%",
|
|
276
|
+
f" Path={path_option}/,%",
|
|
277
|
+
" Ligatures=TeX,%",
|
|
278
|
+
f" BoldFont={fonts['bold']},%",
|
|
279
|
+
f" ItalicFont={fonts['italic']},%",
|
|
280
|
+
f" BoldItalicFont={fonts['bold_italic']},%",
|
|
281
|
+
"]%",
|
|
282
|
+
f"\\setsansfont{{{fonts['regular']}}}[%",
|
|
283
|
+
f" Path={path_option}/,%",
|
|
284
|
+
" Ligatures=TeX,%",
|
|
285
|
+
f" BoldFont={fonts['bold']},%",
|
|
286
|
+
f" ItalicFont={fonts['italic']},%",
|
|
287
|
+
f" BoldItalicFont={fonts['bold_italic']},%",
|
|
288
|
+
"]%",
|
|
289
|
+
f"\\setmonofont{{{fonts['regular']}}}[%",
|
|
290
|
+
f" Path={path_option}/,%",
|
|
291
|
+
f" BoldFont={fonts['bold']},%",
|
|
292
|
+
f" ItalicFont={fonts['italic']},%",
|
|
293
|
+
f" BoldItalicFont={fonts['bold_italic']},%",
|
|
294
|
+
"]%",
|
|
295
|
+
]
|
|
296
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
298
|
+
return target
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _ensure_plex_fonts(output_dir: Path) -> None:
|
|
302
|
+
fonts_root = output_dir / "fonts" / "plex-otf"
|
|
303
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="texsmith-plex-"))
|
|
304
|
+
archive_path = temp_dir / "TrueType.zip"
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
_download_archive(_PLEX_URL, archive_path)
|
|
308
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
309
|
+
zf.extractall(temp_dir)
|
|
310
|
+
except Exception as exc: # pragma: no cover - network edge
|
|
311
|
+
warnings.warn(f"Unable to fetch IBM Plex fonts: {exc}", stacklevel=2)
|
|
312
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
wanted = {
|
|
316
|
+
"IBMPlexSerif": [
|
|
317
|
+
"IBMPlexSerif-Regular.ttf",
|
|
318
|
+
"IBMPlexSerif-Bold.ttf",
|
|
319
|
+
"IBMPlexSerif-Italic.ttf",
|
|
320
|
+
"IBMPlexSerif-BoldItalic.ttf",
|
|
321
|
+
],
|
|
322
|
+
"IBMPlexSans": [
|
|
323
|
+
"IBMPlexSans-Regular.ttf",
|
|
324
|
+
"IBMPlexSans-Bold.ttf",
|
|
325
|
+
"IBMPlexSans-Italic.ttf",
|
|
326
|
+
"IBMPlexSans-BoldItalic.ttf",
|
|
327
|
+
],
|
|
328
|
+
"IBMPlexMono": [
|
|
329
|
+
"IBMPlexMono-Regular.ttf",
|
|
330
|
+
"IBMPlexMono-Bold.ttf",
|
|
331
|
+
"IBMPlexMono-Italic.ttf",
|
|
332
|
+
"IBMPlexMono-BoldItalic.ttf",
|
|
333
|
+
],
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
folder_map = {
|
|
337
|
+
"IBMPlexSerif": "IBM-Plex-Serif",
|
|
338
|
+
"IBMPlexSans": "IBM-Plex-Sans",
|
|
339
|
+
"IBMPlexMono": "IBM-Plex-Mono",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for family_dir, files in wanted.items():
|
|
343
|
+
folder = folder_map.get(family_dir, family_dir)
|
|
344
|
+
for name in files:
|
|
345
|
+
source = temp_dir / "TrueType" / folder / name
|
|
346
|
+
if not source.exists():
|
|
347
|
+
continue
|
|
348
|
+
dest = fonts_root / name
|
|
349
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
350
|
+
shutil.copy2(source, dest)
|
|
351
|
+
|
|
352
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _ensure_ctan_sty(family: str, output_dir: Path) -> Path | None:
|
|
356
|
+
dependency = _CTAN_DEPENDENCIES.get(family)
|
|
357
|
+
if not dependency:
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
# Pennstander archive is often unavailable on CTAN mirrors; fall back to a stub.
|
|
361
|
+
if family == "pennstander":
|
|
362
|
+
target = output_dir / "pennstander-otf.sty"
|
|
363
|
+
return _write_stub_package("pennstander-otf", family, target)
|
|
364
|
+
|
|
365
|
+
package_name = dependency["package"]
|
|
366
|
+
sty_name = f"{package_name}.sty"
|
|
367
|
+
|
|
368
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
fonts_dir = output_dir / "fonts" / package_name
|
|
370
|
+
target_path = output_dir / sty_name
|
|
371
|
+
|
|
372
|
+
download_error: Exception | None = None
|
|
373
|
+
temp_dir = Path(tempfile.mkdtemp(prefix=f"texsmith-{family}-"))
|
|
374
|
+
archive_path = temp_dir / f"{package_name}.zip"
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
_download_archive(dependency["url"], archive_path)
|
|
378
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
379
|
+
zf.extractall(temp_dir)
|
|
380
|
+
except Exception as exc: # pragma: no cover - network/path edge cases
|
|
381
|
+
download_error = exc
|
|
382
|
+
|
|
383
|
+
sty_candidates = list(temp_dir.rglob(sty_name))
|
|
384
|
+
if not sty_candidates:
|
|
385
|
+
sty_candidates = list(temp_dir.rglob("*.sty"))
|
|
386
|
+
|
|
387
|
+
if sty_candidates:
|
|
388
|
+
source = sty_candidates[0]
|
|
389
|
+
shutil.copy2(source, target_path)
|
|
390
|
+
|
|
391
|
+
for file in temp_dir.rglob("*"):
|
|
392
|
+
if file.is_dir():
|
|
393
|
+
continue
|
|
394
|
+
if file.suffix.lower() in {".otf", ".ttf"}:
|
|
395
|
+
destination = fonts_dir / file.name
|
|
396
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
shutil.copy2(file, destination)
|
|
398
|
+
if family == "plex":
|
|
399
|
+
_ensure_plex_fonts(output_dir)
|
|
400
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
401
|
+
return target_path
|
|
402
|
+
|
|
403
|
+
if family in {"adventor", "cursor"}:
|
|
404
|
+
fonts = list(temp_dir.rglob(f"**/texgyre{family}*-regular.otf"))
|
|
405
|
+
if not fonts:
|
|
406
|
+
fonts = list(temp_dir.rglob(f"**/{family}*-regular.otf"))
|
|
407
|
+
if fonts:
|
|
408
|
+
font_dir = fonts_dir
|
|
409
|
+
font_dir.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
for file in temp_dir.rglob("*.otf"):
|
|
411
|
+
shutil.copy2(file, font_dir / Path(file).name)
|
|
412
|
+
|
|
413
|
+
prefix = fonts[0].stem.replace("-regular", "")
|
|
414
|
+
_write_otf_package(
|
|
415
|
+
package_name=package_name,
|
|
416
|
+
family=family,
|
|
417
|
+
target=target_path,
|
|
418
|
+
fonts_path=font_dir,
|
|
419
|
+
font_prefix=prefix,
|
|
420
|
+
)
|
|
421
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
422
|
+
return target_path
|
|
423
|
+
|
|
424
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
425
|
+
|
|
426
|
+
stub_path = _write_stub_package(package_name, family, target_path)
|
|
427
|
+
if download_error is not None: # pragma: no cover - logging path
|
|
428
|
+
warnings.warn(
|
|
429
|
+
f"Unable to prepare CTAN package for font '{family}': {download_error}",
|
|
430
|
+
stacklevel=2,
|
|
431
|
+
)
|
|
432
|
+
if family == "plex":
|
|
433
|
+
_ensure_plex_fonts(output_dir)
|
|
434
|
+
return stub_path
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _resolve_output_dir(context: Mapping[str, Any]) -> Path:
|
|
438
|
+
for key in ("output_dir", "build_dir", "output_root"):
|
|
439
|
+
raw_path = context.get(key)
|
|
440
|
+
if raw_path:
|
|
441
|
+
try:
|
|
442
|
+
candidate = Path(str(raw_path)).expanduser().resolve()
|
|
443
|
+
except OSError:
|
|
444
|
+
continue
|
|
445
|
+
return candidate
|
|
446
|
+
return Path("build").resolve()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _slugify(value: str) -> str:
|
|
450
|
+
slug = "".join(ch for ch in value if ch.isalnum())
|
|
451
|
+
if not slug:
|
|
452
|
+
return "script"
|
|
453
|
+
if slug[0].isdigit():
|
|
454
|
+
slug = f"s{slug}"
|
|
455
|
+
return slug.lower()
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _candidate_font_roots(output_dir: Path) -> list[Path]:
|
|
459
|
+
roots: list[Path] = []
|
|
460
|
+
roots.append((output_dir / "fonts").resolve())
|
|
461
|
+
sandbox_fonts = Path(__file__).resolve().parents[4] / "sandbox" / "fonts"
|
|
462
|
+
roots.append(sandbox_fonts)
|
|
463
|
+
user_fonts = get_user_dir().data_dir("fonts", create=False)
|
|
464
|
+
roots.append(user_fonts)
|
|
465
|
+
with contextlib.suppress(Exception):
|
|
466
|
+
cache = FontCache()
|
|
467
|
+
roots.append(cache.root)
|
|
468
|
+
roots.append(cache.path("fonts"))
|
|
469
|
+
return roots
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _find_font_file(name: str, style: str, ext: str, roots: list[Path]) -> Path | None:
|
|
473
|
+
filename = f"{name}-{style_suffix(style)}{ext}"
|
|
474
|
+
for root in roots:
|
|
475
|
+
candidate = root / filename
|
|
476
|
+
if candidate.exists():
|
|
477
|
+
return candidate
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _prepare_fallback_context(context: Mapping[str, Any], *, output_dir: Path) -> dict[str, Any]:
|
|
482
|
+
"""Build fallback metadata for the ts-fonts fragment."""
|
|
483
|
+
fonts_section = context.get("fonts") if isinstance(context.get("fonts"), Mapping) else {}
|
|
484
|
+
fallback_summary = (
|
|
485
|
+
fonts_section.get("fallback_summary") if isinstance(fonts_section, Mapping) else []
|
|
486
|
+
)
|
|
487
|
+
script_usage = fonts_section.get("script_usage") if isinstance(fonts_section, Mapping) else []
|
|
488
|
+
emoji_mode = _resolve_emoji_mode(context)
|
|
489
|
+
|
|
490
|
+
def _entry_score(
|
|
491
|
+
has_bold: bool, style_count: int, count: int | None, *, usage_match: bool = False
|
|
492
|
+
) -> tuple[int, int, int, int]:
|
|
493
|
+
"""Higher score wins: usage-aligned fonts first, then bold support, style breadth, usage count."""
|
|
494
|
+
return (1 if usage_match else 0, 1 if has_bold else 0, style_count, count or 0)
|
|
495
|
+
|
|
496
|
+
usage_index: dict[str, Mapping[str, Any]] = {}
|
|
497
|
+
for entry in script_usage or []:
|
|
498
|
+
if not isinstance(entry, Mapping):
|
|
499
|
+
continue
|
|
500
|
+
slug = str(entry.get("slug") or "").lower()
|
|
501
|
+
group = str(entry.get("group") or "").lower()
|
|
502
|
+
if slug:
|
|
503
|
+
usage_index[slug] = entry
|
|
504
|
+
if group:
|
|
505
|
+
usage_index[group] = entry
|
|
506
|
+
|
|
507
|
+
roots = _candidate_font_roots(output_dir)
|
|
508
|
+
downloader = NotoFontDownloader(cache=FontCache(), logger=FontPipelineLogger())
|
|
509
|
+
emoji_cache = downloader.cache
|
|
510
|
+
roots.append(downloader.fonts_dir)
|
|
511
|
+
|
|
512
|
+
entries_by_slug: dict[str, dict[str, Any]] = {}
|
|
513
|
+
package_options: set[str] = set()
|
|
514
|
+
slug_classes: dict[str, set[str]] = {}
|
|
515
|
+
lua_regular: set[str] = set()
|
|
516
|
+
lua_bold: set[str] = set()
|
|
517
|
+
missing_commands: set[str] = set()
|
|
518
|
+
package_options.add("Latin")
|
|
519
|
+
|
|
520
|
+
for entry in fallback_summary or []:
|
|
521
|
+
if not isinstance(entry, Mapping):
|
|
522
|
+
continue
|
|
523
|
+
group = entry.get("group") or entry.get("class")
|
|
524
|
+
if not isinstance(group, str) or not group.strip():
|
|
525
|
+
continue
|
|
526
|
+
group_lower = group.lower()
|
|
527
|
+
if group_lower in _SKIP_GROUPS:
|
|
528
|
+
continue
|
|
529
|
+
class_name = entry.get("class") or group
|
|
530
|
+
slug_base = _slugify(group)
|
|
531
|
+
slug_class = _slugify(class_name) if isinstance(class_name, str) else slug_base
|
|
532
|
+
slug = slug_class if slug_class != slug_base else slug_base
|
|
533
|
+
# Prefer the slug recorded in script usage to keep commands aligned with detectors.
|
|
534
|
+
if slug_class != slug_base and (slug_base in usage_index or group_lower in usage_index):
|
|
535
|
+
slug = slug_base
|
|
536
|
+
usage = usage_index.get(slug) or usage_index.get(slug_base) or usage_index.get(group_lower)
|
|
537
|
+
font_command = ""
|
|
538
|
+
text_command = ""
|
|
539
|
+
font_name = None
|
|
540
|
+
usage_font = None
|
|
541
|
+
if isinstance(usage, Mapping):
|
|
542
|
+
font_command = str(usage.get("font_command") or "")
|
|
543
|
+
text_command = str(usage.get("text_command") or "")
|
|
544
|
+
font_name = usage.get("font_name") if isinstance(usage.get("font_name"), str) else None
|
|
545
|
+
usage_font = font_name
|
|
546
|
+
if not font_command:
|
|
547
|
+
font_command = f"{slug}font"
|
|
548
|
+
if not text_command:
|
|
549
|
+
text_command = f"text{slug}"
|
|
550
|
+
font_meta = entry.get("font") if isinstance(entry.get("font"), Mapping) else {}
|
|
551
|
+
alias = _FALLBACK_ALIASES.get(group_lower)
|
|
552
|
+
if isinstance(font_meta, Mapping):
|
|
553
|
+
font_name = font_meta.get("name") or font_name
|
|
554
|
+
if not font_name:
|
|
555
|
+
continue
|
|
556
|
+
styles = []
|
|
557
|
+
if isinstance(font_meta, Mapping):
|
|
558
|
+
styles = [str(style).lower() for style in font_meta.get("styles", []) if style]
|
|
559
|
+
count = entry.get("count")
|
|
560
|
+
ext = font_meta.get("extension") if isinstance(font_meta, Mapping) else ".otf"
|
|
561
|
+
ext = ext if isinstance(ext, str) and ext.startswith(".") else ".otf"
|
|
562
|
+
# Resolve emoji font preferences separately because they are not part of the Noto OTF set.
|
|
563
|
+
emoji_path: Path | None = None
|
|
564
|
+
if group_lower == "symbols" or "emoji" in font_name.lower():
|
|
565
|
+
preferred_color = emoji_mode == "color"
|
|
566
|
+
# Prefer OpenMoji (monochrome) for broader engine compatibility,
|
|
567
|
+
# even when color is requested. Fall back to NotoColorEmoji only
|
|
568
|
+
# when OpenMoji is unavailable.
|
|
569
|
+
emoji_path = _ensure_openmoji_black(emoji_cache)
|
|
570
|
+
font_name = "OpenMojiBlack" if emoji_path is not None else font_name
|
|
571
|
+
if emoji_path is None and preferred_color:
|
|
572
|
+
emoji_path = _ensure_noto_color_emoji(emoji_cache)
|
|
573
|
+
if emoji_path is not None:
|
|
574
|
+
font_name = "NotoColorEmoji"
|
|
575
|
+
if emoji_path is None or not emoji_path.exists():
|
|
576
|
+
warnings.warn(
|
|
577
|
+
"Emoji font unavailable; using raw characters for emoji glyphs.",
|
|
578
|
+
stacklevel=2,
|
|
579
|
+
)
|
|
580
|
+
missing_commands.add("texsmithEmoji")
|
|
581
|
+
continue
|
|
582
|
+
styles = ["regular"]
|
|
583
|
+
ext = ".ttf"
|
|
584
|
+
slug = "emoji"
|
|
585
|
+
font_command = "texsmithEmojiFont"
|
|
586
|
+
text_command = "texsmithEmoji"
|
|
587
|
+
usage_index.setdefault(
|
|
588
|
+
"emoji",
|
|
589
|
+
{
|
|
590
|
+
"slug": "emoji",
|
|
591
|
+
"font_command": font_command,
|
|
592
|
+
"text_command": text_command,
|
|
593
|
+
"font_name": font_name,
|
|
594
|
+
},
|
|
595
|
+
)
|
|
596
|
+
usage = usage_index.get("emoji") or usage
|
|
597
|
+
else:
|
|
598
|
+
# Ensure required font files exist locally, downloading into the cache when missing.
|
|
599
|
+
downloader.ensure(
|
|
600
|
+
font_name=font_name,
|
|
601
|
+
styles=styles or ["regular", "bold"],
|
|
602
|
+
extension=ext,
|
|
603
|
+
dir_base=font_meta.get("dir") if isinstance(font_meta, Mapping) else None,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
destination_root = (output_dir / "fonts").resolve()
|
|
607
|
+
destination_root.mkdir(parents=True, exist_ok=True)
|
|
608
|
+
sources: dict[str, Path] = {}
|
|
609
|
+
for style in styles or ["regular", "bold"]:
|
|
610
|
+
if emoji_path is not None:
|
|
611
|
+
src = emoji_path
|
|
612
|
+
else:
|
|
613
|
+
src = downloader.fonts_dir / f"{font_name}-{style_suffix(style)}{ext}"
|
|
614
|
+
if src.exists():
|
|
615
|
+
sources[style] = src
|
|
616
|
+
dest = destination_root / src.name
|
|
617
|
+
if not dest.exists():
|
|
618
|
+
shutil.copy2(src, dest)
|
|
619
|
+
|
|
620
|
+
upright_file = sources.get("regular") or _find_font_file(font_name, "regular", ext, roots)
|
|
621
|
+
bold_file = (
|
|
622
|
+
sources.get("bold")
|
|
623
|
+
if "bold" in styles
|
|
624
|
+
else _find_font_file(font_name, "bold", ext, roots)
|
|
625
|
+
if "bold" in styles
|
|
626
|
+
else None
|
|
627
|
+
)
|
|
628
|
+
if upright_file is None:
|
|
629
|
+
candidate = downloader.fonts_dir / f"{font_name}-{style_suffix('regular')}{ext}"
|
|
630
|
+
if candidate.exists():
|
|
631
|
+
upright_file = candidate
|
|
632
|
+
if bold_file is None and "bold" in styles:
|
|
633
|
+
candidate = downloader.fonts_dir / f"{font_name}-{style_suffix('bold')}{ext}"
|
|
634
|
+
if candidate.exists():
|
|
635
|
+
bold_file = candidate
|
|
636
|
+
style_count = len(styles) if styles else 1
|
|
637
|
+
if upright_file is None:
|
|
638
|
+
alias = _FALLBACK_ALIASES.get(group_lower)
|
|
639
|
+
if alias:
|
|
640
|
+
alt_name = alias.get("name")
|
|
641
|
+
alt_ext = alias.get("extension", ext)
|
|
642
|
+
alt_styles = alias.get("styles", styles)
|
|
643
|
+
if isinstance(alt_name, str):
|
|
644
|
+
alt_dir = alias.get("dir")
|
|
645
|
+
alt_ext = (
|
|
646
|
+
alt_ext if isinstance(alt_ext, str) and alt_ext.startswith(".") else ".otf"
|
|
647
|
+
)
|
|
648
|
+
alt_styles = (
|
|
649
|
+
[str(s).lower() for s in alt_styles]
|
|
650
|
+
if isinstance(alt_styles, (list, tuple, set))
|
|
651
|
+
else styles
|
|
652
|
+
)
|
|
653
|
+
downloader.ensure(
|
|
654
|
+
font_name=alt_name,
|
|
655
|
+
styles=alt_styles or styles or ["regular", "bold"],
|
|
656
|
+
extension=alt_ext,
|
|
657
|
+
dir_base=str(alt_dir) if isinstance(alt_dir, str) else None,
|
|
658
|
+
)
|
|
659
|
+
font_name = alt_name
|
|
660
|
+
ext = alt_ext
|
|
661
|
+
styles = alt_styles
|
|
662
|
+
upright_file = _find_font_file(font_name, "regular", ext, roots)
|
|
663
|
+
bold_file = (
|
|
664
|
+
_find_font_file(font_name, "bold", ext, roots) if "bold" in styles else None
|
|
665
|
+
)
|
|
666
|
+
if upright_file is None:
|
|
667
|
+
candidate = (
|
|
668
|
+
downloader.fonts_dir / f"{font_name}-{style_suffix('regular')}{ext}"
|
|
669
|
+
)
|
|
670
|
+
if candidate.exists():
|
|
671
|
+
upright_file = candidate
|
|
672
|
+
if bold_file is None and "bold" in styles:
|
|
673
|
+
candidate = (
|
|
674
|
+
downloader.fonts_dir / f"{font_name}-{style_suffix('bold')}{ext}"
|
|
675
|
+
)
|
|
676
|
+
if candidate.exists():
|
|
677
|
+
bold_file = candidate
|
|
678
|
+
style_count = len(styles) if styles else 1
|
|
679
|
+
if upright_file is None:
|
|
680
|
+
warnings.warn(
|
|
681
|
+
f"Fallback font '{font_name}' not found on disk; skipping script '{group}'.",
|
|
682
|
+
stacklevel=2,
|
|
683
|
+
)
|
|
684
|
+
missing_commands.add(text_command)
|
|
685
|
+
continue
|
|
686
|
+
|
|
687
|
+
dest_upright = destination_root / upright_file.name
|
|
688
|
+
if not dest_upright.exists():
|
|
689
|
+
shutil.copy2(upright_file, dest_upright)
|
|
690
|
+
|
|
691
|
+
dest_bold: Path | None = None
|
|
692
|
+
if bold_file is not None:
|
|
693
|
+
dest_bold = destination_root / bold_file.name
|
|
694
|
+
if not dest_bold.exists():
|
|
695
|
+
shutil.copy2(bold_file, dest_bold)
|
|
696
|
+
|
|
697
|
+
existing = entries_by_slug.get(slug)
|
|
698
|
+
candidate_payload = {
|
|
699
|
+
"group": group,
|
|
700
|
+
"class": class_name,
|
|
701
|
+
"slug": slug,
|
|
702
|
+
"font_command": font_command,
|
|
703
|
+
"text_command": text_command,
|
|
704
|
+
"font_name": font_name,
|
|
705
|
+
"count": count if isinstance(count, (int, float)) else None,
|
|
706
|
+
"has_bold": bool(dest_bold),
|
|
707
|
+
"upright": dest_upright.stem,
|
|
708
|
+
"bold": dest_bold.stem if dest_bold else None,
|
|
709
|
+
"extension": ext,
|
|
710
|
+
"style_count": style_count,
|
|
711
|
+
}
|
|
712
|
+
usage_match = bool(usage_font) and font_name and usage_font.lower() == font_name.lower()
|
|
713
|
+
candidate_score = _entry_score(
|
|
714
|
+
bool(dest_bold), style_count, candidate_payload["count"], usage_match=usage_match
|
|
715
|
+
)
|
|
716
|
+
if existing is None:
|
|
717
|
+
entries_by_slug[slug] = candidate_payload
|
|
718
|
+
else:
|
|
719
|
+
existing_count = existing.get("count", 0)
|
|
720
|
+
combined_count = int(existing_count or 0) + (
|
|
721
|
+
int(count) if isinstance(count, (int, float)) else 0
|
|
722
|
+
)
|
|
723
|
+
candidate_payload["count"] = combined_count if combined_count else None
|
|
724
|
+
existing_usage_match = (
|
|
725
|
+
bool(usage_font)
|
|
726
|
+
and existing.get("font_name")
|
|
727
|
+
and str(existing.get("font_name")).lower() == str(usage_font).lower()
|
|
728
|
+
)
|
|
729
|
+
existing_score = _entry_score(
|
|
730
|
+
bool(existing.get("has_bold")),
|
|
731
|
+
int(existing.get("style_count") or 1),
|
|
732
|
+
existing.get("count"),
|
|
733
|
+
usage_match=existing_usage_match,
|
|
734
|
+
)
|
|
735
|
+
if candidate_score > existing_score:
|
|
736
|
+
entries_by_slug[slug] = candidate_payload
|
|
737
|
+
else:
|
|
738
|
+
if combined_count:
|
|
739
|
+
existing["count"] = combined_count
|
|
740
|
+
if dest_bold is not None and not existing.get("has_bold"):
|
|
741
|
+
existing["has_bold"] = True
|
|
742
|
+
existing["bold"] = dest_bold.stem
|
|
743
|
+
existing["style_count"] = max(
|
|
744
|
+
int(existing.get("style_count") or 1), style_count
|
|
745
|
+
)
|
|
746
|
+
lua_bold.add(dest_bold.name)
|
|
747
|
+
|
|
748
|
+
package_options.add(str(class_name))
|
|
749
|
+
slug_classes.setdefault(slug, set()).add(str(class_name))
|
|
750
|
+
entry_ref = entries_by_slug[slug]
|
|
751
|
+
lua_regular.add(f"{entry_ref['upright']}{entry_ref['extension']}")
|
|
752
|
+
if entry_ref.get("has_bold") and entry_ref.get("bold"):
|
|
753
|
+
lua_bold.add(f"{entry_ref['bold']}{entry_ref['extension']}")
|
|
754
|
+
else:
|
|
755
|
+
lua_bold.add(f"{entry_ref['upright']}{entry_ref['extension']}")
|
|
756
|
+
|
|
757
|
+
if slug_classes:
|
|
758
|
+
package_options.update(
|
|
759
|
+
cls_name for classes in slug_classes.values() for cls_name in classes
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
transitions: list[str] = []
|
|
763
|
+
for slug, classes in sorted(slug_classes.items()):
|
|
764
|
+
entry = entries_by_slug.get(slug)
|
|
765
|
+
if not entry:
|
|
766
|
+
continue
|
|
767
|
+
font_command = entry.get("font_command")
|
|
768
|
+
if not font_command:
|
|
769
|
+
continue
|
|
770
|
+
for class_name in sorted(classes):
|
|
771
|
+
transitions.append(
|
|
772
|
+
f"\\setTransitionsFor{{{class_name}}}{{\\{font_command}}}{{\\texsmithFallbackFamily}}"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
"entries": sorted(
|
|
777
|
+
entries_by_slug.values(),
|
|
778
|
+
key=lambda e: e.get("group") or e.get("class") or "",
|
|
779
|
+
),
|
|
780
|
+
"package_options": sorted(package_options),
|
|
781
|
+
"transitions": transitions,
|
|
782
|
+
"lua_regular": sorted(lua_regular),
|
|
783
|
+
"lua_bold": sorted(lua_bold),
|
|
784
|
+
"missing_commands": sorted(
|
|
785
|
+
missing_commands
|
|
786
|
+
| {
|
|
787
|
+
str(entry.get("text_command") or f"text{entry.get('slug')}")
|
|
788
|
+
for entry in usage_index.values()
|
|
789
|
+
if entry.get("slug") and entry.get("slug") not in entries_by_slug
|
|
790
|
+
}
|
|
791
|
+
),
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@dataclass(frozen=True)
|
|
796
|
+
class FontsConfig:
|
|
797
|
+
family: str
|
|
798
|
+
output_dir: Path
|
|
799
|
+
fallback: dict[str, Any] = field(default_factory=dict)
|
|
800
|
+
|
|
801
|
+
@classmethod
|
|
802
|
+
def from_context(cls, context: Mapping[str, Any]) -> FontsConfig:
|
|
803
|
+
fonts_section = context.get("fonts")
|
|
804
|
+
if isinstance(fonts_section, Mapping):
|
|
805
|
+
raw_family = fonts_section.get("family")
|
|
806
|
+
else:
|
|
807
|
+
raw_family = context.get("fonts_family")
|
|
808
|
+
family = _normalise_family(raw_family)
|
|
809
|
+
output_dir = _resolve_output_dir(context)
|
|
810
|
+
fallback = _prepare_fallback_context(context, output_dir=output_dir)
|
|
811
|
+
return cls(family=family, output_dir=output_dir, fallback=fallback)
|
|
812
|
+
|
|
813
|
+
def inject_into(self, context: dict[str, Any]) -> None:
|
|
814
|
+
context["fonts_family"] = self.family
|
|
815
|
+
fonts_section = context.get("fonts")
|
|
816
|
+
merged = dict(fonts_section) if isinstance(fonts_section, Mapping) else {}
|
|
817
|
+
merged["family"] = self.family
|
|
818
|
+
if self.fallback and self.fallback.get("entries"):
|
|
819
|
+
merged["fallback"] = self.fallback
|
|
820
|
+
context["fonts"] = merged
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
_ensure_ctan_sty(self.family, self.output_dir)
|
|
824
|
+
except Exception as exc: # pragma: no cover - network/path edge cases
|
|
825
|
+
warnings.warn(
|
|
826
|
+
f"Unable to prepare CTAN package for font '{self.family}': {exc}",
|
|
827
|
+
stacklevel=2,
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class FontsFragment(BaseFragment[FontsConfig]):
|
|
832
|
+
name: ClassVar[str] = "ts-fonts"
|
|
833
|
+
description: ClassVar[str] = "Font selection driven by fonts.family."
|
|
834
|
+
pieces: ClassVar[list[FragmentPiece]] = [
|
|
835
|
+
FragmentPiece(
|
|
836
|
+
template_path=Path(__file__).with_name("ts-fonts.jinja.sty"),
|
|
837
|
+
kind="package",
|
|
838
|
+
slot="extra_packages",
|
|
839
|
+
)
|
|
840
|
+
]
|
|
841
|
+
attributes: ClassVar[dict[str, TemplateAttributeSpec]] = {
|
|
842
|
+
"fonts_family": TemplateAttributeSpec(
|
|
843
|
+
default="lm",
|
|
844
|
+
type="string",
|
|
845
|
+
allow_empty=False,
|
|
846
|
+
choices=sorted(set(_FAMILY_CHOICES.keys())),
|
|
847
|
+
sources=[
|
|
848
|
+
"fonts.family",
|
|
849
|
+
"fonts_family",
|
|
850
|
+
"font_family",
|
|
851
|
+
],
|
|
852
|
+
)
|
|
853
|
+
}
|
|
854
|
+
config_cls: ClassVar[type[FontsConfig]] = FontsConfig
|
|
855
|
+
source: ClassVar[Path] = Path(__file__).with_name("ts-fonts.jinja.sty")
|
|
856
|
+
context_defaults: ClassVar[dict[str, Any]] = {"extra_packages": ""}
|
|
857
|
+
|
|
858
|
+
def build_config(
|
|
859
|
+
self, context: Mapping[str, Any], overrides: Mapping[str, Any] | None = None
|
|
860
|
+
) -> FontsConfig:
|
|
861
|
+
_ = overrides
|
|
862
|
+
return self.config_cls.from_context(context)
|
|
863
|
+
|
|
864
|
+
def inject(
|
|
865
|
+
self,
|
|
866
|
+
config: FontsConfig,
|
|
867
|
+
context: dict[str, Any],
|
|
868
|
+
overrides: Mapping[str, Any] | None = None,
|
|
869
|
+
) -> None:
|
|
870
|
+
_ = overrides
|
|
871
|
+
config.inject_into(context)
|
|
872
|
+
|
|
873
|
+
def should_render(self, config: FontsConfig) -> bool:
|
|
874
|
+
_ = config
|
|
875
|
+
return True
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
fragment = FontsFragment()
|
|
879
|
+
|
|
880
|
+
__all__ = ["FontsConfig", "FontsFragment", "fragment"]
|