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,493 @@
|
|
|
1
|
+
"""Script-aware helpers for wrapping LaTeX moving arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
|
|
10
|
+
from texsmith.adapters.latex.utils import escape_latex_chars
|
|
11
|
+
from texsmith.core.context import RenderContext
|
|
12
|
+
from texsmith.fonts.cache import FontCache
|
|
13
|
+
from texsmith.fonts.fallback import (
|
|
14
|
+
FallbackBuilder,
|
|
15
|
+
FallbackEntry,
|
|
16
|
+
FallbackLookup,
|
|
17
|
+
FallbackPlan,
|
|
18
|
+
FallbackRepository,
|
|
19
|
+
merge_fallback_summaries,
|
|
20
|
+
)
|
|
21
|
+
from texsmith.fonts.logging import FontPipelineLogger
|
|
22
|
+
from texsmith.fonts.pipeline import generate_noto_metadata, generate_ucharclasses_data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_SKIP_GROUPS = {"latin", "common", "punctuation", "other"}
|
|
26
|
+
|
|
27
|
+
# Prefer native math macros for single-letter Greek/hebrew math symbols to avoid
|
|
28
|
+
# relying on \textgreek wrappers when a math glyph already exists.
|
|
29
|
+
_MATH_LETTER_MAP = {
|
|
30
|
+
"\N{GREEK SMALL LETTER ALPHA}": r"\alpha",
|
|
31
|
+
"\N{GREEK SMALL LETTER BETA}": r"\beta",
|
|
32
|
+
"\N{GREEK SMALL LETTER GAMMA}": r"\gamma",
|
|
33
|
+
"\N{GREEK SMALL LETTER DELTA}": r"\delta",
|
|
34
|
+
"\N{GREEK SMALL LETTER EPSILON}": r"\epsilon",
|
|
35
|
+
"\N{GREEK SMALL LETTER ZETA}": r"\zeta",
|
|
36
|
+
"\N{GREEK SMALL LETTER ETA}": r"\eta",
|
|
37
|
+
"\N{GREEK SMALL LETTER THETA}": r"\theta",
|
|
38
|
+
"\N{GREEK SMALL LETTER IOTA}": r"\iota",
|
|
39
|
+
"\N{GREEK SMALL LETTER KAPPA}": r"\kappa",
|
|
40
|
+
"\N{GREEK SMALL LETTER LAMDA}": r"\lambda",
|
|
41
|
+
"\N{GREEK SMALL LETTER MU}": r"\mu",
|
|
42
|
+
"\N{GREEK SMALL LETTER NU}": r"\nu",
|
|
43
|
+
"\N{GREEK SMALL LETTER XI}": r"\xi",
|
|
44
|
+
"\N{GREEK SMALL LETTER PI}": r"\pi",
|
|
45
|
+
"\N{GREEK SMALL LETTER RHO}": r"\rho",
|
|
46
|
+
"\N{GREEK SMALL LETTER SIGMA}": r"\sigma",
|
|
47
|
+
"\N{GREEK SMALL LETTER FINAL SIGMA}": r"\varsigma",
|
|
48
|
+
"\N{GREEK SMALL LETTER TAU}": r"\tau",
|
|
49
|
+
"\N{GREEK SMALL LETTER UPSILON}": r"\upsilon",
|
|
50
|
+
"\N{GREEK SMALL LETTER PHI}": r"\phi",
|
|
51
|
+
"\N{GREEK SMALL LETTER CHI}": r"\chi",
|
|
52
|
+
"\N{GREEK SMALL LETTER PSI}": r"\psi",
|
|
53
|
+
"\N{GREEK SMALL LETTER OMEGA}": r"\omega",
|
|
54
|
+
"\N{GREEK THETA SYMBOL}": r"\vartheta",
|
|
55
|
+
"\N{GREEK PHI SYMBOL}": r"\varphi",
|
|
56
|
+
"\N{GREEK PI SYMBOL}": r"\varpi",
|
|
57
|
+
"\N{GREEK RHO SYMBOL}": r"\varrho",
|
|
58
|
+
"\N{GREEK KAPPA SYMBOL}": r"\varkappa",
|
|
59
|
+
"\N{GREEK LUNATE EPSILON SYMBOL}": r"\varepsilon",
|
|
60
|
+
"\N{GREEK CAPITAL LETTER GAMMA}": r"\Gamma",
|
|
61
|
+
"\N{GREEK CAPITAL LETTER DELTA}": r"\Delta",
|
|
62
|
+
"\N{GREEK CAPITAL LETTER THETA}": r"\Theta",
|
|
63
|
+
"\N{GREEK CAPITAL LETTER LAMDA}": r"\Lambda",
|
|
64
|
+
"\N{GREEK CAPITAL LETTER XI}": r"\Xi",
|
|
65
|
+
"\N{GREEK CAPITAL LETTER PI}": r"\Pi",
|
|
66
|
+
"\N{GREEK CAPITAL LETTER SIGMA}": r"\Sigma",
|
|
67
|
+
"\N{GREEK CAPITAL LETTER UPSILON}": r"\Upsilon",
|
|
68
|
+
"\N{GREEK CAPITAL LETTER PHI}": r"\Phi",
|
|
69
|
+
"\N{GREEK CAPITAL LETTER PSI}": r"\Psi",
|
|
70
|
+
"\N{GREEK CAPITAL LETTER OMEGA}": r"\Omega",
|
|
71
|
+
"\N{ALEF SYMBOL}": r"\aleph",
|
|
72
|
+
"\N{BET SYMBOL}": r"\beth",
|
|
73
|
+
"\N{GIMEL SYMBOL}": r"\gimel",
|
|
74
|
+
"\N{DALET SYMBOL}": r"\daleth",
|
|
75
|
+
}
|
|
76
|
+
_MATH_LETTER_GROUPS = {"greek", "symbols", "hebrew"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _slugify(value: str) -> str:
|
|
80
|
+
slug = re.sub(r"[^A-Za-z0-9]+", "", value)
|
|
81
|
+
if not slug:
|
|
82
|
+
return "script"
|
|
83
|
+
if slug[0].isdigit():
|
|
84
|
+
slug = f"s{slug}"
|
|
85
|
+
return slug.lower()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _math_letter_override(chunk: str, group: str | None) -> str | None:
|
|
89
|
+
"""Return a math-mode macro for lone Greek/Hebrew symbols when available."""
|
|
90
|
+
if not chunk:
|
|
91
|
+
return None
|
|
92
|
+
normalized_group = group.lower() if isinstance(group, str) else None
|
|
93
|
+
if normalized_group and normalized_group not in _MATH_LETTER_GROUPS:
|
|
94
|
+
return None
|
|
95
|
+
trimmed = chunk.strip()
|
|
96
|
+
if len(trimmed) != 1:
|
|
97
|
+
return None
|
|
98
|
+
command = _MATH_LETTER_MAP.get(trimmed)
|
|
99
|
+
if command is None:
|
|
100
|
+
return None
|
|
101
|
+
prefix_len = len(chunk) - len(chunk.lstrip())
|
|
102
|
+
suffix_len = len(chunk) - len(chunk.rstrip())
|
|
103
|
+
prefix = chunk[:prefix_len]
|
|
104
|
+
suffix = chunk[len(chunk) - suffix_len :] if suffix_len else ""
|
|
105
|
+
return f"{prefix}${command}${suffix}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class ScriptSpec:
|
|
110
|
+
group: str
|
|
111
|
+
slug: str
|
|
112
|
+
font_name: str | None
|
|
113
|
+
font_command: str
|
|
114
|
+
text_command: str
|
|
115
|
+
count: int = 0
|
|
116
|
+
|
|
117
|
+
def to_mapping(self) -> dict[str, str | None]:
|
|
118
|
+
return {
|
|
119
|
+
"group": self.group,
|
|
120
|
+
"slug": self.slug,
|
|
121
|
+
"font_name": self.font_name,
|
|
122
|
+
"font_command": self.font_command,
|
|
123
|
+
"text_command": self.text_command,
|
|
124
|
+
"count": self.count,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ScriptDetector:
|
|
129
|
+
"""Detect script runs and wrap them with dedicated LaTeX macros."""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
cache: FontCache | None = None,
|
|
135
|
+
logger: FontPipelineLogger | None = None,
|
|
136
|
+
skip_groups: Iterable[str] | None = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
self.cache = cache or FontCache()
|
|
139
|
+
self.logger = logger or FontPipelineLogger()
|
|
140
|
+
self.skip_groups = {entry.lower() for entry in (skip_groups or _SKIP_GROUPS)}
|
|
141
|
+
self._lookup: FallbackLookup | None = None
|
|
142
|
+
self._specs: dict[str, ScriptSpec] = {}
|
|
143
|
+
|
|
144
|
+
def _ensure_lookup(self) -> FallbackLookup:
|
|
145
|
+
if self._lookup is None:
|
|
146
|
+
repository = FallbackRepository(cache=self.cache, logger=self.logger)
|
|
147
|
+
had_cache = repository.cache_path.exists()
|
|
148
|
+
classes = generate_ucharclasses_data(cache=self.cache, logger=self.logger)
|
|
149
|
+
coverage = generate_noto_metadata(cache=self.cache, logger=self.logger)
|
|
150
|
+
announce = not had_cache
|
|
151
|
+
entries = FallbackBuilder(logger=self.logger).build(
|
|
152
|
+
classes, coverage, announce=announce
|
|
153
|
+
)
|
|
154
|
+
signature = repository._signature(entries) # noqa: SLF001
|
|
155
|
+
cached = repository.load(expected_signature=signature)
|
|
156
|
+
if cached is None:
|
|
157
|
+
cached = repository.load_or_build(entries)
|
|
158
|
+
self._lookup = FallbackLookup(cached)
|
|
159
|
+
return self._lookup
|
|
160
|
+
|
|
161
|
+
def _classify_char(self, char: str) -> FallbackEntry | None:
|
|
162
|
+
lookup = self._ensure_lookup()
|
|
163
|
+
candidates = lookup.index.ranges_for_codepoint(ord(char))
|
|
164
|
+
if not candidates:
|
|
165
|
+
return None
|
|
166
|
+
return candidates[0]
|
|
167
|
+
|
|
168
|
+
def _group_name(self, entry: FallbackEntry | None) -> str | None:
|
|
169
|
+
if entry is None:
|
|
170
|
+
return None
|
|
171
|
+
group = entry.group or entry.name
|
|
172
|
+
if not group:
|
|
173
|
+
return None
|
|
174
|
+
if group.lower() in self.skip_groups:
|
|
175
|
+
return None
|
|
176
|
+
return group
|
|
177
|
+
|
|
178
|
+
def _segment_text(
|
|
179
|
+
self,
|
|
180
|
+
text: str,
|
|
181
|
+
*,
|
|
182
|
+
include_whitespace: bool,
|
|
183
|
+
) -> list[tuple[str | None, str, FallbackEntry | None]]:
|
|
184
|
+
override_group = self._resolve_cjk_override(text)
|
|
185
|
+
runs: list[tuple[str | None, str, FallbackEntry | None]] = []
|
|
186
|
+
current_group: str | None = None
|
|
187
|
+
current_entry: FallbackEntry | None = None
|
|
188
|
+
buffer: list[str] = []
|
|
189
|
+
|
|
190
|
+
for char in text:
|
|
191
|
+
entry = self._classify_char(char)
|
|
192
|
+
group = self._group_name(entry)
|
|
193
|
+
if (
|
|
194
|
+
override_group
|
|
195
|
+
and group
|
|
196
|
+
and group.lower() in {"chinese", "japanese", "korean", "cjk"}
|
|
197
|
+
):
|
|
198
|
+
group = override_group
|
|
199
|
+
entry = entry
|
|
200
|
+
combining = bool(unicodedata.combining(char))
|
|
201
|
+
if current_group is not None and (
|
|
202
|
+
combining or (group and group.lower() == "diacritics")
|
|
203
|
+
):
|
|
204
|
+
group = current_group
|
|
205
|
+
entry = current_entry
|
|
206
|
+
if include_whitespace and char.isspace() and current_group is not None:
|
|
207
|
+
group = current_group
|
|
208
|
+
entry = current_entry
|
|
209
|
+
|
|
210
|
+
if group != current_group and buffer:
|
|
211
|
+
runs.append((current_group, "".join(buffer), current_entry))
|
|
212
|
+
buffer.clear()
|
|
213
|
+
|
|
214
|
+
buffer.append(char)
|
|
215
|
+
current_group = group
|
|
216
|
+
current_entry = entry if group else None
|
|
217
|
+
|
|
218
|
+
if buffer:
|
|
219
|
+
runs.append((current_group, "".join(buffer), current_entry))
|
|
220
|
+
|
|
221
|
+
return runs
|
|
222
|
+
|
|
223
|
+
def _resolve_cjk_override(self, text: str) -> str | None:
|
|
224
|
+
try:
|
|
225
|
+
summary = self._ensure_lookup().summary(text)
|
|
226
|
+
except Exception:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
counts: dict[str, int] = {}
|
|
230
|
+
for entry in summary:
|
|
231
|
+
group = entry.get("group")
|
|
232
|
+
count = entry.get("count")
|
|
233
|
+
if not isinstance(group, str) or not isinstance(count, int):
|
|
234
|
+
continue
|
|
235
|
+
lowered = group.lower()
|
|
236
|
+
if lowered in {"chinese", "japanese", "korean", "cjk"}:
|
|
237
|
+
counts[lowered] = counts.get(lowered, 0) + count
|
|
238
|
+
|
|
239
|
+
if not counts:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
chinese_count = counts.get("chinese", 0) + counts.get("cjk", 0)
|
|
243
|
+
japanese_count = counts.get("japanese", 0)
|
|
244
|
+
korean_count = counts.get("korean", 0)
|
|
245
|
+
|
|
246
|
+
if japanese_count and japanese_count >= chinese_count * 0.5:
|
|
247
|
+
return "japanese"
|
|
248
|
+
if korean_count and korean_count >= chinese_count * 0.5:
|
|
249
|
+
return "korean"
|
|
250
|
+
|
|
251
|
+
dominant = max(counts.items(), key=lambda item: item[1])[0]
|
|
252
|
+
return dominant
|
|
253
|
+
|
|
254
|
+
def _record_spec(self, group: str, entry: FallbackEntry | None) -> ScriptSpec:
|
|
255
|
+
slug = _slugify(group)
|
|
256
|
+
font_name = None
|
|
257
|
+
if entry and entry.font:
|
|
258
|
+
font_name = entry.font.get("name")
|
|
259
|
+
spec = self._specs.get(slug)
|
|
260
|
+
if spec is None:
|
|
261
|
+
spec = ScriptSpec(
|
|
262
|
+
group=group,
|
|
263
|
+
slug=slug,
|
|
264
|
+
font_name=font_name,
|
|
265
|
+
font_command=f"{slug}font",
|
|
266
|
+
text_command=f"text{slug}",
|
|
267
|
+
)
|
|
268
|
+
self._specs[slug] = spec
|
|
269
|
+
elif font_name and not spec.font_name:
|
|
270
|
+
spec.font_name = font_name
|
|
271
|
+
return spec
|
|
272
|
+
|
|
273
|
+
def render(
|
|
274
|
+
self,
|
|
275
|
+
text: str,
|
|
276
|
+
*,
|
|
277
|
+
include_whitespace: bool = True,
|
|
278
|
+
legacy_accents: bool = False,
|
|
279
|
+
escape: bool = True,
|
|
280
|
+
wrap_scripts: bool = False,
|
|
281
|
+
) -> tuple[str, list[dict[str, str | None]]]:
|
|
282
|
+
"""Return LaTeX text with script-specific wrappers and usage metadata."""
|
|
283
|
+
if not text:
|
|
284
|
+
return "", []
|
|
285
|
+
segments = self._segment_text(text, include_whitespace=include_whitespace)
|
|
286
|
+
rendered: list[str] = []
|
|
287
|
+
for group, chunk, entry in segments:
|
|
288
|
+
math_override = _math_letter_override(chunk, group)
|
|
289
|
+
if math_override is not None:
|
|
290
|
+
rendered.append(math_override)
|
|
291
|
+
continue
|
|
292
|
+
escaped = escape_latex_chars(chunk, legacy_accents=legacy_accents) if escape else chunk
|
|
293
|
+
if group:
|
|
294
|
+
spec = self._record_spec(group, entry)
|
|
295
|
+
spec.count += len(chunk)
|
|
296
|
+
if wrap_scripts:
|
|
297
|
+
rendered.append(f"\\{spec.text_command}{{{escaped}}}")
|
|
298
|
+
continue
|
|
299
|
+
rendered.append(escaped)
|
|
300
|
+
usages = [spec.to_mapping() for spec in self._specs.values()]
|
|
301
|
+
return "".join(rendered), usages
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def fallback_summary_to_usage(
|
|
305
|
+
summary: Sequence[Mapping[str, object]],
|
|
306
|
+
) -> list[dict[str, str | None]]:
|
|
307
|
+
"""Convert a fallback scan summary into script usage records."""
|
|
308
|
+
plan_fonts: dict[str, Mapping[str, object]] = {}
|
|
309
|
+
if isinstance(summary, FallbackPlan):
|
|
310
|
+
plan_fonts = summary.group_fonts or {}
|
|
311
|
+
summary = summary.summary
|
|
312
|
+
|
|
313
|
+
usages: list[dict[str, str | None]] = []
|
|
314
|
+
for entry in summary:
|
|
315
|
+
if not isinstance(entry, Mapping):
|
|
316
|
+
continue
|
|
317
|
+
group = entry.get("group") or entry.get("class") or entry.get("name")
|
|
318
|
+
if not isinstance(group, str):
|
|
319
|
+
continue
|
|
320
|
+
group_lower = group.lower()
|
|
321
|
+
if group_lower in _SKIP_GROUPS:
|
|
322
|
+
continue
|
|
323
|
+
slug = _slugify(group)
|
|
324
|
+
font_name = None
|
|
325
|
+
plan_font = plan_fonts.get(group) or plan_fonts.get(group_lower) if plan_fonts else None
|
|
326
|
+
if isinstance(plan_font, Mapping):
|
|
327
|
+
raw_name = plan_font.get("name")
|
|
328
|
+
font_name = str(raw_name) if isinstance(raw_name, str) else None
|
|
329
|
+
if font_name is None:
|
|
330
|
+
font_payload = entry.get("font")
|
|
331
|
+
if isinstance(font_payload, Mapping):
|
|
332
|
+
raw_name = font_payload.get("name")
|
|
333
|
+
font_name = str(raw_name) if isinstance(raw_name, str) else None
|
|
334
|
+
is_emoji = group_lower == "symbols" or (font_name and "emoji" in font_name.lower())
|
|
335
|
+
if is_emoji:
|
|
336
|
+
slug = "emoji"
|
|
337
|
+
count_value = entry.get("count")
|
|
338
|
+
count = int(count_value) if isinstance(count_value, (int, float)) else None
|
|
339
|
+
usages.append(
|
|
340
|
+
{
|
|
341
|
+
"group": group,
|
|
342
|
+
"slug": slug,
|
|
343
|
+
"font_name": font_name,
|
|
344
|
+
"font_command": "texsmithEmojiFont" if slug == "emoji" else f"{slug}font",
|
|
345
|
+
"text_command": "texsmithEmoji" if slug == "emoji" else f"text{slug}",
|
|
346
|
+
"count": count,
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
return usages
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def merge_script_usage(
|
|
353
|
+
existing: Sequence[Mapping[str, str | None]],
|
|
354
|
+
updates: Sequence[Mapping[str, str | None]],
|
|
355
|
+
) -> list[dict[str, str | None]]:
|
|
356
|
+
"""Merge script usage lists keyed by slug."""
|
|
357
|
+
merged: dict[str, dict[str, str | None]] = {
|
|
358
|
+
str(entry.get("slug")): dict(entry) for entry in existing if entry.get("slug")
|
|
359
|
+
}
|
|
360
|
+
for entry in updates:
|
|
361
|
+
slug = entry.get("slug")
|
|
362
|
+
if not slug:
|
|
363
|
+
continue
|
|
364
|
+
if slug not in merged:
|
|
365
|
+
merged[slug] = dict(entry)
|
|
366
|
+
continue
|
|
367
|
+
existing_font = merged[slug].get("font_name")
|
|
368
|
+
update_font = entry.get("font_name")
|
|
369
|
+
existing_count = merged[slug].get("count")
|
|
370
|
+
update_count = entry.get("count")
|
|
371
|
+
|
|
372
|
+
# Prefer the font coming from the entry with the highest count (when available).
|
|
373
|
+
if update_font:
|
|
374
|
+
if not existing_font:
|
|
375
|
+
merged[slug]["font_name"] = update_font
|
|
376
|
+
else:
|
|
377
|
+
existing_value = (
|
|
378
|
+
int(existing_count) if isinstance(existing_count, (int, float)) else 0
|
|
379
|
+
)
|
|
380
|
+
update_value = int(update_count) if isinstance(update_count, (int, float)) else 0
|
|
381
|
+
if update_value > existing_value:
|
|
382
|
+
merged[slug]["font_name"] = update_font
|
|
383
|
+
|
|
384
|
+
if isinstance(existing_count, (int, float)) or isinstance(update_count, (int, float)):
|
|
385
|
+
existing_value = int(existing_count) if isinstance(existing_count, (int, float)) else 0
|
|
386
|
+
update_value = int(update_count) if isinstance(update_count, (int, float)) else 0
|
|
387
|
+
merged_count = existing_value + update_value
|
|
388
|
+
merged[slug]["count"] = merged_count if merged_count else None
|
|
389
|
+
return list(merged.values())
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def record_script_usage_for_slug(
|
|
393
|
+
slug: str,
|
|
394
|
+
text: str,
|
|
395
|
+
context: RenderContext,
|
|
396
|
+
*,
|
|
397
|
+
detector: ScriptDetector | None = None,
|
|
398
|
+
) -> dict[str, str | None]:
|
|
399
|
+
"""Record usage/fallback metadata for a known script slug."""
|
|
400
|
+
if detector is None:
|
|
401
|
+
detector_key = "_texsmith_script_detector"
|
|
402
|
+
detector = context.runtime.get(detector_key)
|
|
403
|
+
if not isinstance(detector, ScriptDetector):
|
|
404
|
+
detector = ScriptDetector(cache=FontCache())
|
|
405
|
+
context.runtime[detector_key] = detector
|
|
406
|
+
|
|
407
|
+
group = slug
|
|
408
|
+
font_name = None
|
|
409
|
+
try:
|
|
410
|
+
summary = detector._ensure_lookup().summary(text) # noqa: SLF001
|
|
411
|
+
except Exception:
|
|
412
|
+
summary = []
|
|
413
|
+
|
|
414
|
+
if summary:
|
|
415
|
+
dominant = max(summary, key=lambda entry: entry.get("count", 0) or 0)
|
|
416
|
+
candidate_group = dominant.get("group") or dominant.get("class")
|
|
417
|
+
if isinstance(candidate_group, str) and candidate_group.strip():
|
|
418
|
+
group = candidate_group
|
|
419
|
+
font_meta = dominant.get("font")
|
|
420
|
+
if isinstance(font_meta, Mapping):
|
|
421
|
+
raw_name = font_meta.get("name")
|
|
422
|
+
if isinstance(raw_name, str):
|
|
423
|
+
font_name = raw_name
|
|
424
|
+
|
|
425
|
+
usage_entry = {
|
|
426
|
+
"group": group,
|
|
427
|
+
"slug": slug,
|
|
428
|
+
"font_name": font_name,
|
|
429
|
+
"font_command": f"{slug}font",
|
|
430
|
+
"text_command": f"text{slug}",
|
|
431
|
+
"count": len(text) if text else None,
|
|
432
|
+
}
|
|
433
|
+
state_usage = getattr(context.state, "script_usage", [])
|
|
434
|
+
context.state.script_usage = merge_script_usage(state_usage, [usage_entry])
|
|
435
|
+
if summary:
|
|
436
|
+
existing = getattr(context.state, "fallback_summary", [])
|
|
437
|
+
context.state.fallback_summary = merge_fallback_summaries(existing, summary)
|
|
438
|
+
return usage_entry
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def render_moving_text(
|
|
442
|
+
text: str | None,
|
|
443
|
+
context: RenderContext,
|
|
444
|
+
*,
|
|
445
|
+
include_whitespace: bool = True,
|
|
446
|
+
legacy_accents: bool | None = None,
|
|
447
|
+
escape: bool = True,
|
|
448
|
+
wrap_scripts: bool = False,
|
|
449
|
+
) -> str | None:
|
|
450
|
+
"""Return LaTeX-safe text with script wrappers and record usage in state."""
|
|
451
|
+
if text is None:
|
|
452
|
+
return None
|
|
453
|
+
detector_key = "_texsmith_script_detector"
|
|
454
|
+
detector = context.runtime.get(detector_key)
|
|
455
|
+
if not isinstance(detector, ScriptDetector):
|
|
456
|
+
detector = ScriptDetector(cache=FontCache())
|
|
457
|
+
context.runtime[detector_key] = detector
|
|
458
|
+
rendered, usage = detector.render(
|
|
459
|
+
text,
|
|
460
|
+
include_whitespace=include_whitespace,
|
|
461
|
+
legacy_accents=bool(legacy_accents)
|
|
462
|
+
if legacy_accents is not None
|
|
463
|
+
else getattr(context.config, "legacy_latex_accents", False),
|
|
464
|
+
escape=escape,
|
|
465
|
+
wrap_scripts=wrap_scripts,
|
|
466
|
+
)
|
|
467
|
+
state_usage = getattr(context.state, "script_usage", [])
|
|
468
|
+
context.state.script_usage = merge_script_usage(state_usage, usage)
|
|
469
|
+
try:
|
|
470
|
+
summary = detector._ensure_lookup().summary(text) # noqa: SLF001
|
|
471
|
+
existing = getattr(context.state, "fallback_summary", [])
|
|
472
|
+
context.state.fallback_summary = merge_fallback_summaries(existing, summary)
|
|
473
|
+
except Exception:
|
|
474
|
+
pass
|
|
475
|
+
return rendered
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def render_script_macros(usages: Iterable[Mapping[str, str | None]]) -> str:
|
|
479
|
+
"""Render LaTeX macros declaring script-specific font commands."""
|
|
480
|
+
from texsmith.adapters.latex.formatter import LaTeXFormatter
|
|
481
|
+
|
|
482
|
+
scripts = sorted(
|
|
483
|
+
(dict(entry) for entry in usages if entry.get("slug")),
|
|
484
|
+
key=lambda entry: str(entry.get("slug")),
|
|
485
|
+
)
|
|
486
|
+
if not scripts:
|
|
487
|
+
return ""
|
|
488
|
+
formatter = LaTeXFormatter()
|
|
489
|
+
try:
|
|
490
|
+
return formatter.script_macros(scripts=scripts)
|
|
491
|
+
except AttributeError:
|
|
492
|
+
# When the script_macros partial is not available, skip emitting anything.
|
|
493
|
+
return ""
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tools to fetch and parse ucharclasses definitions from CTAN."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import urllib.request
|
|
11
|
+
import zipfile
|
|
12
|
+
|
|
13
|
+
from texsmith.fonts.cache import FontCache
|
|
14
|
+
from texsmith.fonts.logging import FontPipelineLogger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
CTAN_UCHARCLASSES_ZIP = "https://mirrors.ctan.org/macros/xetex/latex/ucharclasses.zip"
|
|
18
|
+
|
|
19
|
+
DO_PATTERN = re.compile(r"""\\do\{([^}]+)\}\{"?([0-9A-Fa-f]+)\}\{"?([0-9A-Fa-f]+)\}""")
|
|
20
|
+
GROUP_PATTERN = re.compile(r"""\\def\\([A-Za-z0-9]+)Classes\{""")
|
|
21
|
+
DO_NAME_PATTERN = re.compile(r"""\\do\{([^}]+)\}""")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class UCharClass:
|
|
26
|
+
name: str
|
|
27
|
+
start: int
|
|
28
|
+
end: int
|
|
29
|
+
group: str | None = None
|
|
30
|
+
font: dict | None = None
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict:
|
|
33
|
+
payload = {
|
|
34
|
+
"name": self.name,
|
|
35
|
+
"start": self.start,
|
|
36
|
+
"end": self.end,
|
|
37
|
+
"start_hex": f"U+{self.start:04X}",
|
|
38
|
+
"end_hex": f"U+{self.end:04X}",
|
|
39
|
+
}
|
|
40
|
+
if self.group:
|
|
41
|
+
payload["group"] = self.group
|
|
42
|
+
if self.font:
|
|
43
|
+
payload["font"] = self.font
|
|
44
|
+
return payload
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UCharClassesBuilder:
|
|
48
|
+
"""Download and parse ucharclasses.sty from CTAN."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
cache: FontCache | None = None,
|
|
54
|
+
logger: FontPipelineLogger | None = None,
|
|
55
|
+
source_url: str = CTAN_UCHARCLASSES_ZIP,
|
|
56
|
+
extra_sources: list[Path] | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.cache = cache or FontCache()
|
|
59
|
+
self.logger = logger or FontPipelineLogger()
|
|
60
|
+
self.source_url = source_url
|
|
61
|
+
self.extra_sources = extra_sources or []
|
|
62
|
+
|
|
63
|
+
def _cached_sty_path(self) -> Path:
|
|
64
|
+
return self.cache.path("ucharclasses", "ucharclasses.sty")
|
|
65
|
+
|
|
66
|
+
def _download_zip(self, target: Path) -> Path:
|
|
67
|
+
self.logger.info("Downloading ucharclasses from %s", self.source_url)
|
|
68
|
+
with urllib.request.urlopen(self.source_url) as response:
|
|
69
|
+
target.write_bytes(response.read())
|
|
70
|
+
return target
|
|
71
|
+
|
|
72
|
+
def _extract_sty(self, archive: Path, destination: Path) -> Path:
|
|
73
|
+
with zipfile.ZipFile(archive) as zf:
|
|
74
|
+
for info in zf.infolist():
|
|
75
|
+
if info.filename.lower().endswith("ucharclasses.sty"):
|
|
76
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
with zf.open(info) as src, destination.open("wb") as dst:
|
|
78
|
+
shutil.copyfileobj(src, dst)
|
|
79
|
+
return destination
|
|
80
|
+
raise FileNotFoundError("ucharclasses.sty not found in downloaded archive.")
|
|
81
|
+
|
|
82
|
+
def _ensure_sty(self) -> Path:
|
|
83
|
+
cached = self._cached_sty_path()
|
|
84
|
+
candidates = [
|
|
85
|
+
cached,
|
|
86
|
+
*self.extra_sources,
|
|
87
|
+
]
|
|
88
|
+
for candidate in candidates:
|
|
89
|
+
if candidate.exists():
|
|
90
|
+
if candidate != cached:
|
|
91
|
+
cached.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
shutil.copy(candidate, cached)
|
|
93
|
+
self.logger.debug("ucharclasses.sty loaded from %s", candidate)
|
|
94
|
+
return cached
|
|
95
|
+
with self.cache.tempdir() as tmp:
|
|
96
|
+
archive = tmp / "ucharclasses.zip"
|
|
97
|
+
self._download_zip(archive)
|
|
98
|
+
sty_path = self._extract_sty(archive, cached)
|
|
99
|
+
self.logger.info("ucharclasses.sty extracted to %s", sty_path)
|
|
100
|
+
return sty_path
|
|
101
|
+
|
|
102
|
+
def sty_path(self) -> Path:
|
|
103
|
+
"""Return the local ucharclasses.sty path, downloading it if needed."""
|
|
104
|
+
return self._ensure_sty()
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _iter_class_ranges(text: str):
|
|
108
|
+
for line in text.splitlines():
|
|
109
|
+
line = line.strip()
|
|
110
|
+
if not line or line.startswith("%"):
|
|
111
|
+
continue
|
|
112
|
+
for match in DO_PATTERN.finditer(line):
|
|
113
|
+
name, start_hex, end_hex = match.groups()
|
|
114
|
+
yield name, int(start_hex, 16), int(end_hex, 16)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _apply_grouping(raw: str, classes: dict[str, UCharClass]) -> None:
|
|
118
|
+
group_priority = {"Japanese": 3, "Korean": 3, "Chinese": 3, "CJK": 1}
|
|
119
|
+
priorities: dict[str, int] = {}
|
|
120
|
+
current_group = None
|
|
121
|
+
for line in raw.splitlines():
|
|
122
|
+
stripped = line.strip()
|
|
123
|
+
match = GROUP_PATTERN.match(stripped)
|
|
124
|
+
if match:
|
|
125
|
+
current_group = match.group(1)
|
|
126
|
+
continue
|
|
127
|
+
if current_group and stripped.startswith("}"):
|
|
128
|
+
current_group = None
|
|
129
|
+
continue
|
|
130
|
+
if current_group and current_group not in ("All", "Other"):
|
|
131
|
+
priority = group_priority.get(current_group, 2)
|
|
132
|
+
for match in DO_NAME_PATTERN.finditer(stripped):
|
|
133
|
+
cls_name = match.group(1)
|
|
134
|
+
if cls_name not in classes:
|
|
135
|
+
continue
|
|
136
|
+
previous_priority = priorities.get(cls_name, 0)
|
|
137
|
+
if previous_priority >= priority:
|
|
138
|
+
continue
|
|
139
|
+
classes[cls_name].group = current_group
|
|
140
|
+
priorities[cls_name] = priority
|
|
141
|
+
|
|
142
|
+
def build(self) -> list[UCharClass]:
|
|
143
|
+
"""Return parsed ucharclasses definitions, downloading assets if needed."""
|
|
144
|
+
had_cached_sty = self._cached_sty_path().exists()
|
|
145
|
+
sty_path = self._ensure_sty()
|
|
146
|
+
raw = sty_path.read_text(encoding="utf-8")
|
|
147
|
+
seen: dict[str, UCharClass] = {}
|
|
148
|
+
for name, start, end in self._iter_class_ranges(raw):
|
|
149
|
+
seen.setdefault(name, UCharClass(name=name, start=start, end=end))
|
|
150
|
+
self._apply_grouping(raw, seen)
|
|
151
|
+
ordered = sorted(seen.values(), key=lambda c: c.start)
|
|
152
|
+
log_fn = self.logger.info if not had_cached_sty else self.logger.debug
|
|
153
|
+
log_fn(f"{len(ordered)} Unicode classes detected.")
|
|
154
|
+
return ordered
|
|
155
|
+
|
|
156
|
+
def export_json(self, target: Path) -> None:
|
|
157
|
+
"""Persist classes to JSON; useful for debugging or downstream tools."""
|
|
158
|
+
data = [cls.to_dict() for cls in self.build()]
|
|
159
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
target.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
161
|
+
self.logger.info("Writing Unicode classes to %s", target)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
__all__ = ["CTAN_UCHARCLASSES_ZIP", "UCharClass", "UCharClassesBuilder"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Legacy fragment namespace.
|
|
2
|
+
|
|
3
|
+
Fragments now register via ``fragment.toml`` or entry points returning
|
|
4
|
+
``Fragment``/``FragmentDefinition`` objects. This module is kept only to avoid
|
|
5
|
+
import errors for legacy paths; no registration occurs here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = []
|