cleome 0.3.0__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.
- cleome/__init__.py +81 -0
- cleome/cli.py +604 -0
- cleome/css.py +351 -0
- cleome/data/__init__.py +5 -0
- cleome/data/kanji_freq.py +72 -0
- cleome/data/kr_freq.py +51 -0
- cleome/data/sc_freq.py +73 -0
- cleome/data/tc_freq.py +95 -0
- cleome/locale.py +246 -0
- cleome/metadata.py +416 -0
- cleome/pipeline.py +508 -0
- cleome/py.typed +1 -0
- cleome/slicer.py +632 -0
- cleome/subsetter.py +539 -0
- cleome/urange.py +48 -0
- cleome-0.3.0.dist-info/METADATA +164 -0
- cleome-0.3.0.dist-info/RECORD +20 -0
- cleome-0.3.0.dist-info/WHEEL +4 -0
- cleome-0.3.0.dist-info/entry_points.txt +2 -0
- cleome-0.3.0.dist-info/licenses/LICENSE +21 -0
cleome/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cleome: `unicode-range` WOFF2 slicer and `@font-face` CSS generator for
|
|
3
|
+
Japanese, Korean, and Chinese variable and static web fonts.
|
|
4
|
+
|
|
5
|
+
Uses CSS cascade shadowing (1-token hex ranges for rare ideographs and syllables)
|
|
6
|
+
and inverted-pyramid frequency granularity.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from cleome.locale import (
|
|
12
|
+
SUPPORTED_LOCALES,
|
|
13
|
+
detect_font_locale,
|
|
14
|
+
)
|
|
15
|
+
from cleome.metadata import (
|
|
16
|
+
FontFaceDescriptors,
|
|
17
|
+
FontMetadata,
|
|
18
|
+
extract_font_face_descriptors,
|
|
19
|
+
extract_font_metadata,
|
|
20
|
+
slugify_family,
|
|
21
|
+
)
|
|
22
|
+
from cleome.slicer import (
|
|
23
|
+
SliceConfig,
|
|
24
|
+
SliceSpec,
|
|
25
|
+
build_slice_plan,
|
|
26
|
+
)
|
|
27
|
+
from cleome.urange import (
|
|
28
|
+
compact_unicode_range,
|
|
29
|
+
format_unicode_range,
|
|
30
|
+
parse_unicode_range,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from cleome.css import generate_css
|
|
35
|
+
from cleome.pipeline import SliceBundleResult, slice_font, slice_fonts
|
|
36
|
+
from cleome.subsetter import (
|
|
37
|
+
BuiltSliceFile,
|
|
38
|
+
export_woff2_slices,
|
|
39
|
+
patch_uvs_missing_base_cmap,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__version__ = "0.2.0"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"BuiltSliceFile",
|
|
46
|
+
"FontFaceDescriptors",
|
|
47
|
+
"FontMetadata",
|
|
48
|
+
"SUPPORTED_LOCALES",
|
|
49
|
+
"SliceBundleResult",
|
|
50
|
+
"SliceConfig",
|
|
51
|
+
"SliceSpec",
|
|
52
|
+
"build_slice_plan",
|
|
53
|
+
"compact_unicode_range",
|
|
54
|
+
"detect_font_locale",
|
|
55
|
+
"export_woff2_slices",
|
|
56
|
+
"extract_font_face_descriptors",
|
|
57
|
+
"extract_font_metadata",
|
|
58
|
+
"format_unicode_range",
|
|
59
|
+
"generate_css",
|
|
60
|
+
"parse_unicode_range",
|
|
61
|
+
"patch_uvs_missing_base_cmap",
|
|
62
|
+
"slice_font",
|
|
63
|
+
"slice_fonts",
|
|
64
|
+
"slugify_family",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def __getattr__(name: str) -> Any:
|
|
69
|
+
if name in ("BuiltSliceFile", "export_woff2_slices", "patch_uvs_missing_base_cmap"):
|
|
70
|
+
from cleome import subsetter as _subsetter
|
|
71
|
+
|
|
72
|
+
return getattr(_subsetter, name)
|
|
73
|
+
if name == "generate_css":
|
|
74
|
+
from cleome import css as _css
|
|
75
|
+
|
|
76
|
+
return getattr(_css, name)
|
|
77
|
+
if name in ("SliceBundleResult", "slice_font", "slice_fonts"):
|
|
78
|
+
from cleome import pipeline as _pipeline
|
|
79
|
+
|
|
80
|
+
return getattr(_pipeline, name)
|
|
81
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
cleome/cli.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface (`cleome`) for slicing Japanese, Korean, and Chinese Variable and Static fonts
|
|
3
|
+
into Web-optimized `unicode-range` WOFF2 chunks and `@font-face` CSS.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import struct
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from fontTools.ttLib import TTFont, TTLibError
|
|
16
|
+
|
|
17
|
+
from cleome.locale import SUPPORTED_LOCALES, detect_font_locale
|
|
18
|
+
from cleome.metadata import (
|
|
19
|
+
FontMetadata,
|
|
20
|
+
extract_font_metadata,
|
|
21
|
+
)
|
|
22
|
+
from cleome.slicer import SliceConfig, SliceSpec, _prepare_font_bundle
|
|
23
|
+
|
|
24
|
+
EXIT_OK = 0
|
|
25
|
+
EXIT_RUNTIME_ERROR = 1
|
|
26
|
+
EXIT_USAGE_ERROR = 2
|
|
27
|
+
EXIT_PERMISSION_ERROR = 3
|
|
28
|
+
EXIT_INPUT_ERROR = 4
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_input_path_error(exc: OSError, input_paths: Sequence[Path]) -> bool:
|
|
32
|
+
err_file = getattr(exc, "filename", None)
|
|
33
|
+
if not err_file or not input_paths:
|
|
34
|
+
return False
|
|
35
|
+
err_str = os.fspath(err_file)
|
|
36
|
+
err_abs = os.path.abspath(err_str)
|
|
37
|
+
return any(
|
|
38
|
+
err_str == os.fspath(p) or err_abs == os.path.abspath(os.fspath(p))
|
|
39
|
+
for p in input_paths
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _handle_cli_exception(exc: BaseException, *, input_paths: Sequence[Path] = ()) -> int:
|
|
44
|
+
"""Format a concise stderr diagnostic and return the Gate 3 exit code (`1`, `2`, `3`, or `4`)."""
|
|
45
|
+
if isinstance(exc, PermissionError):
|
|
46
|
+
target = f": {exc.filename}" if getattr(exc, "filename", None) else f": {exc}"
|
|
47
|
+
print(f"Error: Permission denied{target}", file=sys.stderr)
|
|
48
|
+
return EXIT_PERMISSION_ERROR
|
|
49
|
+
if isinstance(exc, (FileNotFoundError, IsADirectoryError, NotADirectoryError)) and _is_input_path_error(
|
|
50
|
+
exc, input_paths
|
|
51
|
+
):
|
|
52
|
+
target = f": {exc.filename}" if getattr(exc, "filename", None) else f": {exc}"
|
|
53
|
+
print(f"Error: Input font not found or is a directory{target}", file=sys.stderr)
|
|
54
|
+
return EXIT_INPUT_ERROR
|
|
55
|
+
if isinstance(exc, (TTLibError, EOFError, struct.error)):
|
|
56
|
+
print(f"Error: Invalid or unreadable font file: {exc}", file=sys.stderr)
|
|
57
|
+
return EXIT_INPUT_ERROR
|
|
58
|
+
if isinstance(exc, ValueError):
|
|
59
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
60
|
+
return EXIT_USAGE_ERROR
|
|
61
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
62
|
+
return EXIT_RUNTIME_ERROR
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_cli_input_metas(args: argparse.Namespace, config: SliceConfig) -> list[FontMetadata]:
|
|
66
|
+
"""Open input font files and extract `FontMetadata` with CLI overrides applied."""
|
|
67
|
+
metas: list[FontMetadata] = []
|
|
68
|
+
for p in args.inputs:
|
|
69
|
+
with TTFont(p, lazy=True, recalcTimestamp=False) as tt:
|
|
70
|
+
metas.append(
|
|
71
|
+
extract_font_metadata(
|
|
72
|
+
tt,
|
|
73
|
+
family_override=args.family,
|
|
74
|
+
slug_override=args.slug,
|
|
75
|
+
weight_override=args.weight,
|
|
76
|
+
stretch_override=args.stretch,
|
|
77
|
+
style_override=args.style,
|
|
78
|
+
uvs_base_repair=config.uvs_base_repair,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return metas
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _positive_int(val_str: str) -> int:
|
|
85
|
+
try:
|
|
86
|
+
val = int(val_str)
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise argparse.ArgumentTypeError(f"Expected a positive integer, got '{val_str}'.") from exc
|
|
89
|
+
if val <= 0:
|
|
90
|
+
raise argparse.ArgumentTypeError(f"Value must be > 0, got {val}.")
|
|
91
|
+
return val
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_freq_chunks(spec_str: str) -> tuple[tuple[int, int], ...]:
|
|
95
|
+
"""Parse a progressive Tier 3 chunk spec like `"200x2,80x10"` or `"none"` into a tuple of `(size, count)` pairs."""
|
|
96
|
+
cleaned = spec_str.strip()
|
|
97
|
+
if cleaned.lower() == "none":
|
|
98
|
+
return ()
|
|
99
|
+
chunks: list[tuple[int, int]] = []
|
|
100
|
+
for item in cleaned.split(","):
|
|
101
|
+
item = item.strip()
|
|
102
|
+
if not item:
|
|
103
|
+
continue
|
|
104
|
+
if "x" not in item.lower():
|
|
105
|
+
raise argparse.ArgumentTypeError(
|
|
106
|
+
f"Invalid --freq-chunks item '{item}'. Expected format 'SIZE'x'COUNT' (e.g. '200x2,80x10') or 'none'."
|
|
107
|
+
)
|
|
108
|
+
sz_s, cnt_s = item.lower().split("x", 1)
|
|
109
|
+
try:
|
|
110
|
+
sz, cnt = int(sz_s), int(cnt_s)
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
raise argparse.ArgumentTypeError(
|
|
113
|
+
f"Invalid --freq-chunks item '{item}'. Both SIZE and COUNT must be integers."
|
|
114
|
+
) from exc
|
|
115
|
+
if sz <= 0 or cnt <= 0:
|
|
116
|
+
raise argparse.ArgumentTypeError(
|
|
117
|
+
f"Invalid --freq-chunks item '{item}'. Both SIZE and COUNT must be > 0."
|
|
118
|
+
)
|
|
119
|
+
chunks.append((sz, cnt))
|
|
120
|
+
if not chunks:
|
|
121
|
+
raise argparse.ArgumentTypeError("--freq-chunks cannot be empty (use 'none' for flat Tier 3 slicing).")
|
|
122
|
+
return tuple(chunks)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_tag_list(spec_str: str) -> tuple[str, ...]:
|
|
126
|
+
"""Parse a comma- or whitespace-separated list of OpenType table or feature tags."""
|
|
127
|
+
return tuple(t.strip() for t in re.split(r"[,\s]+", spec_str.strip()) if t.strip())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _add_shared_arguments(parser: argparse.ArgumentParser, *, is_build: bool) -> None:
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"inputs",
|
|
133
|
+
nargs="+",
|
|
134
|
+
type=Path,
|
|
135
|
+
help="Input font file(s) (.ttf / .otf). Pass multiple files to bundle static weight/style variants together.",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
slicing_group = parser.add_argument_group("Slicing options")
|
|
139
|
+
slicing_group.add_argument(
|
|
140
|
+
"--locale",
|
|
141
|
+
dest="locale",
|
|
142
|
+
choices=SUPPORTED_LOCALES,
|
|
143
|
+
default="auto",
|
|
144
|
+
help="Target CJK slicing profile: 'auto' (detect from family & cmap), 'jp', 'kr', 'sc', 'tc', or 'hk' (default: 'auto')",
|
|
145
|
+
)
|
|
146
|
+
slicing_group.add_argument(
|
|
147
|
+
"--freq-chunks",
|
|
148
|
+
type=_parse_freq_chunks,
|
|
149
|
+
default=((200, 2), (80, 10)),
|
|
150
|
+
help="Progressive Tier 3 chunk schedule from highest frequency downward, or 'none' (default: '200x2,80x10')",
|
|
151
|
+
)
|
|
152
|
+
slicing_group.add_argument(
|
|
153
|
+
"--freq-tail-chunk-size",
|
|
154
|
+
type=_positive_int,
|
|
155
|
+
default=70,
|
|
156
|
+
help="Target characters per slice for remaining Tier 3 characters (default: 70)",
|
|
157
|
+
)
|
|
158
|
+
slicing_group.add_argument(
|
|
159
|
+
"--rare-chunk-size",
|
|
160
|
+
type=_positive_int,
|
|
161
|
+
default=250,
|
|
162
|
+
help="Target characters per slice for Tier 4 rare/extended characters (default: 250)",
|
|
163
|
+
)
|
|
164
|
+
slicing_group.add_argument(
|
|
165
|
+
"--symbols-chunk-size",
|
|
166
|
+
type=_positive_int,
|
|
167
|
+
default=350,
|
|
168
|
+
help="Target characters per slice for Tier 2 extended symbols (default: 350)",
|
|
169
|
+
)
|
|
170
|
+
slicing_group.add_argument(
|
|
171
|
+
"--uvs-base-repair",
|
|
172
|
+
action=argparse.BooleanOptionalAction,
|
|
173
|
+
default=True,
|
|
174
|
+
help="Repair characters mapped in cmap Format 14 (UVS/IVS) but omitted from Format 4/12 (default: enabled)",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
descriptor_group = parser.add_argument_group("CSS descriptor overrides")
|
|
178
|
+
descriptor_group.add_argument(
|
|
179
|
+
"--family",
|
|
180
|
+
default=None,
|
|
181
|
+
help="Override CSS `font-family` name (auto-detected from font `name` table by default)",
|
|
182
|
+
)
|
|
183
|
+
descriptor_group.add_argument(
|
|
184
|
+
"--slug",
|
|
185
|
+
default=None,
|
|
186
|
+
help="Override filename slug prefix (default: kebab-case of font-family)",
|
|
187
|
+
)
|
|
188
|
+
descriptor_group.add_argument(
|
|
189
|
+
"--weight",
|
|
190
|
+
default=None,
|
|
191
|
+
help="Override CSS `font-weight` descriptor (e.g. '250 900' or '400')",
|
|
192
|
+
)
|
|
193
|
+
descriptor_group.add_argument(
|
|
194
|
+
"--stretch",
|
|
195
|
+
default=None,
|
|
196
|
+
help="Override CSS `font-stretch` descriptor (e.g. '70%% 100%%')",
|
|
197
|
+
)
|
|
198
|
+
descriptor_group.add_argument(
|
|
199
|
+
"--style",
|
|
200
|
+
default=None,
|
|
201
|
+
help="Override CSS `font-style` descriptor (e.g. 'normal' or 'italic')",
|
|
202
|
+
)
|
|
203
|
+
if is_build:
|
|
204
|
+
descriptor_group.add_argument(
|
|
205
|
+
"--display",
|
|
206
|
+
default="swap",
|
|
207
|
+
help="CSS `font-display` descriptor value (default: 'swap')",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
output_group = parser.add_argument_group("Output options")
|
|
211
|
+
output_group.add_argument(
|
|
212
|
+
"-o",
|
|
213
|
+
"--out-dir",
|
|
214
|
+
type=Path,
|
|
215
|
+
default=Path("dist"),
|
|
216
|
+
help="Output directory for generated CSS and slices (default: ./dist)",
|
|
217
|
+
)
|
|
218
|
+
output_group.add_argument(
|
|
219
|
+
"--slices-dir",
|
|
220
|
+
default="slices",
|
|
221
|
+
help="Subdirectory name under --out-dir for .woff2 files (default: 'slices'; use '.' for flat output)",
|
|
222
|
+
)
|
|
223
|
+
output_group.add_argument(
|
|
224
|
+
"--url-prefix",
|
|
225
|
+
default=None,
|
|
226
|
+
help="Custom URL prefix in CSS `src: url('<prefix>/<file>.woff2')` (default: relative path from CSS to <slices-dir>)",
|
|
227
|
+
)
|
|
228
|
+
css_dest_group = output_group.add_mutually_exclusive_group()
|
|
229
|
+
css_dest_group.add_argument(
|
|
230
|
+
"--css-name",
|
|
231
|
+
default=None,
|
|
232
|
+
help="Custom filename for the output CSS file inside --out-dir (default: '<slug>.css')",
|
|
233
|
+
)
|
|
234
|
+
css_dest_group.add_argument(
|
|
235
|
+
"--css-out",
|
|
236
|
+
type=Path,
|
|
237
|
+
default=None,
|
|
238
|
+
help="Write the output CSS file to an explicit path (e.g. 'src/styles/fonts.css' while -o is 'public/fonts')",
|
|
239
|
+
)
|
|
240
|
+
output_group.add_argument(
|
|
241
|
+
"--filename-hash",
|
|
242
|
+
action=argparse.BooleanOptionalAction,
|
|
243
|
+
default=True,
|
|
244
|
+
help="Append an 8-character SHA-256 content hash to each .woff2 slice filename (default: enabled)",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
subset_group = parser.add_argument_group("Subsetting options")
|
|
248
|
+
subset_group.add_argument(
|
|
249
|
+
"--layout-features",
|
|
250
|
+
type=_parse_tag_list,
|
|
251
|
+
default=("*",),
|
|
252
|
+
help="Comma-separated OpenType layout feature tags to retain (default: '*' keeps all features)",
|
|
253
|
+
)
|
|
254
|
+
subset_group.add_argument(
|
|
255
|
+
"--layout-closure",
|
|
256
|
+
action=argparse.BooleanOptionalAction,
|
|
257
|
+
default=True,
|
|
258
|
+
help="Expand glyph closure over GSUB substitution tables (default: enabled)",
|
|
259
|
+
)
|
|
260
|
+
subset_group.add_argument(
|
|
261
|
+
"--drop-tables",
|
|
262
|
+
type=_parse_tag_list,
|
|
263
|
+
default=(),
|
|
264
|
+
help="Comma-separated OpenType table tags to drop during subsetting (e.g. 'GPOS,GSUB,vhea,vmtx')",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
exec_group = parser.add_argument_group("Execution & output format")
|
|
268
|
+
if is_build:
|
|
269
|
+
exec_group.add_argument(
|
|
270
|
+
"-j",
|
|
271
|
+
"--workers",
|
|
272
|
+
type=_positive_int,
|
|
273
|
+
default=None,
|
|
274
|
+
help="Number of parallel worker processes for WOFF2 compression (default: CPU count)",
|
|
275
|
+
)
|
|
276
|
+
exec_group.add_argument(
|
|
277
|
+
"--force",
|
|
278
|
+
action="store_true",
|
|
279
|
+
help="Rebuild all WOFF2 slices and CSS even if cached outputs are up to date",
|
|
280
|
+
)
|
|
281
|
+
exec_group.add_argument(
|
|
282
|
+
"--dry-run",
|
|
283
|
+
action="store_true",
|
|
284
|
+
help="Validate inputs, output paths, and planned slices without creating directories or writing files",
|
|
285
|
+
)
|
|
286
|
+
exec_group.add_argument(
|
|
287
|
+
"--json",
|
|
288
|
+
dest="json_output",
|
|
289
|
+
action="store_true",
|
|
290
|
+
help="Emit structured JSON summary to stdout",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
295
|
+
parser = argparse.ArgumentParser(
|
|
296
|
+
prog="cleome",
|
|
297
|
+
allow_abbrev=False,
|
|
298
|
+
description=(
|
|
299
|
+
"Slice Japanese, Korean, or Chinese Variable and Static fonts (.ttf / .otf) into "
|
|
300
|
+
"Web-optimized unicode-range WOFF2 chunks and @font-face CSS."
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
304
|
+
|
|
305
|
+
plan_parser = subparsers.add_parser(
|
|
306
|
+
"plan",
|
|
307
|
+
allow_abbrev=False,
|
|
308
|
+
help="Read-only inspection: compute and output the 4-tier unicode-range slice plan without writing files",
|
|
309
|
+
)
|
|
310
|
+
_add_shared_arguments(plan_parser, is_build=False)
|
|
311
|
+
|
|
312
|
+
build_subparser = subparsers.add_parser(
|
|
313
|
+
"build",
|
|
314
|
+
allow_abbrev=False,
|
|
315
|
+
help="Write mode: slice input fonts into WOFF2 chunks and generate @font-face CSS",
|
|
316
|
+
)
|
|
317
|
+
_add_shared_arguments(build_subparser, is_build=True)
|
|
318
|
+
|
|
319
|
+
return parser
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _summarize_plan_tiers(plan: list[SliceSpec]) -> dict[int, tuple[int, int, int]]:
|
|
323
|
+
"""Return `{tier_num: (slice_count, total_codepoints, total_unicode_range_chars)}` for Tiers 4..1."""
|
|
324
|
+
by_tier: dict[int, list[SliceSpec]] = {4: [], 3: [], 2: [], 1: []}
|
|
325
|
+
for s in plan:
|
|
326
|
+
by_tier[s.tier].append(s)
|
|
327
|
+
return {
|
|
328
|
+
tier_num: (
|
|
329
|
+
len(t_slices),
|
|
330
|
+
sum(len(s.binary_codepoints) for s in t_slices),
|
|
331
|
+
sum(len(s.css_unicode_range) for s in t_slices),
|
|
332
|
+
)
|
|
333
|
+
for tier_num, t_slices in by_tier.items()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _run_plan(args: argparse.Namespace, config: SliceConfig) -> int:
|
|
338
|
+
try:
|
|
339
|
+
raw_metas = _load_cli_input_metas(args, config)
|
|
340
|
+
prepared = _prepare_font_bundle(
|
|
341
|
+
raw_metas,
|
|
342
|
+
config,
|
|
343
|
+
family_override=args.family,
|
|
344
|
+
slug_override=args.slug,
|
|
345
|
+
)
|
|
346
|
+
except (ValueError, TTLibError, EOFError, struct.error, OSError) as exc:
|
|
347
|
+
return _handle_cli_exception(exc, input_paths=args.inputs)
|
|
348
|
+
|
|
349
|
+
first = prepared.metas[0]
|
|
350
|
+
resolved_loc = (
|
|
351
|
+
detect_font_locale(first.codepoints, family_name=prepared.family_name)
|
|
352
|
+
if config.locale == "auto"
|
|
353
|
+
else config.locale
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
if args.json_output:
|
|
357
|
+
payload = {
|
|
358
|
+
"ok": True,
|
|
359
|
+
"command": "plan",
|
|
360
|
+
"family_name": prepared.family_name,
|
|
361
|
+
"slug": prepared.slug,
|
|
362
|
+
"locale": resolved_loc,
|
|
363
|
+
"configured_locale": config.locale,
|
|
364
|
+
"is_variable": first.is_variable,
|
|
365
|
+
"file_count": len(prepared.metas),
|
|
366
|
+
"variants": [
|
|
367
|
+
{
|
|
368
|
+
"weight": meta.weight_css,
|
|
369
|
+
"stretch": meta.stretch_css or "normal",
|
|
370
|
+
"styles": list(meta.styles_css or (meta.style_css,)),
|
|
371
|
+
"variant_suffix": meta.variant_suffix,
|
|
372
|
+
"codepoints": len(meta.codepoints),
|
|
373
|
+
"total_slices": len(plan),
|
|
374
|
+
"tiers": {
|
|
375
|
+
str(t): {"slices": sc, "codepoints": cp_c, "unicode_range_chars": ur_c}
|
|
376
|
+
for t, (sc, cp_c, ur_c) in _summarize_plan_tiers(plan).items()
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
for meta, plan in zip(prepared.metas, prepared.variant_plans)
|
|
380
|
+
],
|
|
381
|
+
}
|
|
382
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
383
|
+
return EXIT_OK
|
|
384
|
+
|
|
385
|
+
print(f"Font Family : {prepared.family_name} (slug: {prepared.slug})")
|
|
386
|
+
print(f"Locale : {resolved_loc} (config: {config.locale})")
|
|
387
|
+
print(f"Type : {'Variable' if first.is_variable else 'Static'} ({len(prepared.metas)} file(s))")
|
|
388
|
+
for m, plan in zip(prepared.metas, prepared.variant_plans):
|
|
389
|
+
style_label = " + ".join(m.styles_css or (m.style_css,))
|
|
390
|
+
print(
|
|
391
|
+
f"Variant : weight={m.weight_css}, stretch={m.stretch_css or 'normal'}, "
|
|
392
|
+
f"style={style_label} | {len(m.codepoints):,d} chars -> {len(plan)} slices"
|
|
393
|
+
)
|
|
394
|
+
for tier_num, (sc, t_chars, t_ur_len) in _summarize_plan_tiers(plan).items():
|
|
395
|
+
print(
|
|
396
|
+
f" Tier {tier_num}: {sc:2d} slices | {t_chars:6,d} chars | "
|
|
397
|
+
f"unicode-range len: {t_ur_len:6,d} chars ({t_ur_len/1024:.1f} KB)"
|
|
398
|
+
)
|
|
399
|
+
return EXIT_OK
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _run_build_dry_run(args: argparse.Namespace, config: SliceConfig) -> int:
|
|
403
|
+
from cleome.css import _resolve_and_validate_outputs
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
raw_metas = _load_cli_input_metas(args, config)
|
|
407
|
+
prepared = _prepare_font_bundle(
|
|
408
|
+
raw_metas,
|
|
409
|
+
config,
|
|
410
|
+
family_override=args.family,
|
|
411
|
+
slug_override=args.slug,
|
|
412
|
+
)
|
|
413
|
+
outputs = _resolve_and_validate_outputs(
|
|
414
|
+
args.out_dir,
|
|
415
|
+
config,
|
|
416
|
+
prepared.metas,
|
|
417
|
+
prepared.variant_plans,
|
|
418
|
+
css_filename=args.css_name,
|
|
419
|
+
css_path=args.css_out,
|
|
420
|
+
)
|
|
421
|
+
except (ValueError, TTLibError, EOFError, struct.error, OSError) as exc:
|
|
422
|
+
return _handle_cli_exception(exc, input_paths=args.inputs)
|
|
423
|
+
|
|
424
|
+
total_slices = 0
|
|
425
|
+
preload_slices: list[dict[str, str]] = []
|
|
426
|
+
hash_placeholder = "<hash8>" if config.filename_hash else ""
|
|
427
|
+
for m, plan in zip(prepared.metas, prepared.variant_plans):
|
|
428
|
+
for spec in plan:
|
|
429
|
+
if not (spec.binary_codepoints & m.codepoints):
|
|
430
|
+
continue
|
|
431
|
+
total_slices += 1
|
|
432
|
+
if spec.tier == 1:
|
|
433
|
+
preview_fname = spec.filename(m.slug, m.variant_suffix, hash_placeholder)
|
|
434
|
+
preload_slices.append(
|
|
435
|
+
{
|
|
436
|
+
"slice_id": spec.slice_id,
|
|
437
|
+
"variant_suffix": m.variant_suffix,
|
|
438
|
+
"filename": preview_fname,
|
|
439
|
+
"url": f"{outputs.url_prefix}{preview_fname}",
|
|
440
|
+
}
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
resolved_loc = (
|
|
444
|
+
detect_font_locale(prepared.metas[0].codepoints, family_name=prepared.family_name)
|
|
445
|
+
if config.locale == "auto"
|
|
446
|
+
else config.locale
|
|
447
|
+
)
|
|
448
|
+
if args.json_output:
|
|
449
|
+
payload = {
|
|
450
|
+
"ok": True,
|
|
451
|
+
"command": "build",
|
|
452
|
+
"dry_run": True,
|
|
453
|
+
"cached": False,
|
|
454
|
+
"family_name": prepared.family_name,
|
|
455
|
+
"slug": prepared.slug,
|
|
456
|
+
"locale": resolved_loc,
|
|
457
|
+
"filename_hash": config.filename_hash,
|
|
458
|
+
"css_path": str(outputs.css_path),
|
|
459
|
+
"slices_dir": str(outputs.slices_dir),
|
|
460
|
+
"slice_count": total_slices,
|
|
461
|
+
"preload_slices": preload_slices,
|
|
462
|
+
}
|
|
463
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
464
|
+
return EXIT_OK
|
|
465
|
+
|
|
466
|
+
print(
|
|
467
|
+
f"[cleome] Dry run: would build {total_slices} WOFF2 slices + "
|
|
468
|
+
f"{outputs.css_path.name} (no files written)"
|
|
469
|
+
)
|
|
470
|
+
print(f" CSS : {outputs.css_path}")
|
|
471
|
+
print(f" Slices: {outputs.slices_dir}/")
|
|
472
|
+
if preload_slices:
|
|
473
|
+
print(" Preload (Tier 1):")
|
|
474
|
+
for item in preload_slices:
|
|
475
|
+
print(
|
|
476
|
+
f' <link rel="preload" href="{item["url"]}" '
|
|
477
|
+
f'as="font" type="font/woff2" crossorigin>'
|
|
478
|
+
)
|
|
479
|
+
return EXIT_OK
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _run_build(args: argparse.Namespace, config: SliceConfig) -> int:
|
|
483
|
+
if args.dry_run:
|
|
484
|
+
return _run_build_dry_run(args, config)
|
|
485
|
+
|
|
486
|
+
from cleome.css import _resolve_url_prefix
|
|
487
|
+
from cleome.pipeline import slice_fonts
|
|
488
|
+
|
|
489
|
+
t0 = time.time()
|
|
490
|
+
try:
|
|
491
|
+
result = slice_fonts(
|
|
492
|
+
fonts=args.inputs,
|
|
493
|
+
out_dir=args.out_dir,
|
|
494
|
+
family_name=args.family,
|
|
495
|
+
slug=args.slug,
|
|
496
|
+
css_filename=args.css_name,
|
|
497
|
+
css_path=args.css_out,
|
|
498
|
+
weight=args.weight,
|
|
499
|
+
stretch=args.stretch,
|
|
500
|
+
style=args.style,
|
|
501
|
+
config=config,
|
|
502
|
+
workers=args.workers,
|
|
503
|
+
force=args.force,
|
|
504
|
+
)
|
|
505
|
+
except (ValueError, TTLibError, EOFError, struct.error, RuntimeError, OSError) as exc:
|
|
506
|
+
return _handle_cli_exception(exc, input_paths=args.inputs)
|
|
507
|
+
|
|
508
|
+
elapsed = time.time() - t0
|
|
509
|
+
url_prefix = _resolve_url_prefix(
|
|
510
|
+
config, css_path=result.css_path, slices_dir=result.slices_dir
|
|
511
|
+
)
|
|
512
|
+
preload_slices = [
|
|
513
|
+
{
|
|
514
|
+
"slice_id": bf.spec.slice_id,
|
|
515
|
+
"variant_suffix": bf.variant_suffix,
|
|
516
|
+
"filename": bf.filename,
|
|
517
|
+
"url": f"{url_prefix}{bf.filename}",
|
|
518
|
+
}
|
|
519
|
+
for bf in result.built_files
|
|
520
|
+
if bf.spec.tier == 1
|
|
521
|
+
]
|
|
522
|
+
if args.json_output:
|
|
523
|
+
first_meta = result.fonts_metadata[0]
|
|
524
|
+
resolved_loc = (
|
|
525
|
+
detect_font_locale(first_meta.codepoints, family_name=first_meta.family_name)
|
|
526
|
+
if config.locale == "auto"
|
|
527
|
+
else config.locale
|
|
528
|
+
)
|
|
529
|
+
payload = {
|
|
530
|
+
"ok": True,
|
|
531
|
+
"command": "build",
|
|
532
|
+
"dry_run": False,
|
|
533
|
+
"cached": result.cached,
|
|
534
|
+
"family_name": result.family_name,
|
|
535
|
+
"slug": result.slug,
|
|
536
|
+
"locale": resolved_loc,
|
|
537
|
+
"filename_hash": config.filename_hash,
|
|
538
|
+
"css_path": str(result.css_path),
|
|
539
|
+
"css_bytes": result.css_bytes,
|
|
540
|
+
"slices_dir": str(result.slices_dir),
|
|
541
|
+
"slice_count": len(result.built_files),
|
|
542
|
+
"total_woff2_bytes": result.total_woff2_bytes,
|
|
543
|
+
"preload_slices": preload_slices,
|
|
544
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
545
|
+
}
|
|
546
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
547
|
+
return EXIT_OK
|
|
548
|
+
|
|
549
|
+
action_label = "Cached" if result.cached else "Built"
|
|
550
|
+
cache_suffix = " (up to date)" if result.cached else ""
|
|
551
|
+
print(
|
|
552
|
+
f"[cleome] {action_label} {len(result.built_files)} WOFF2 slices "
|
|
553
|
+
f"({result.total_woff2_bytes / (1024 * 1024):.2f} MB total) + "
|
|
554
|
+
f"{result.css_path.name} ({result.css_bytes / 1024:.1f} KB) in {elapsed:.2f}s{cache_suffix}"
|
|
555
|
+
)
|
|
556
|
+
print(f" CSS : {result.css_path}")
|
|
557
|
+
print(f" Slices: {result.slices_dir}/")
|
|
558
|
+
if preload_slices:
|
|
559
|
+
print(" Preload (Tier 1):")
|
|
560
|
+
for item in preload_slices:
|
|
561
|
+
print(
|
|
562
|
+
f' <link rel="preload" href="{item["url"]}" '
|
|
563
|
+
f'as="font" type="font/woff2" crossorigin>'
|
|
564
|
+
)
|
|
565
|
+
return EXIT_OK
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def main(argv: list[str] | None = None) -> int:
|
|
569
|
+
args = build_parser().parse_args(argv)
|
|
570
|
+
|
|
571
|
+
for p in args.inputs:
|
|
572
|
+
if not p.exists():
|
|
573
|
+
print(f"Error: Input font not found: {p}", file=sys.stderr)
|
|
574
|
+
return EXIT_INPUT_ERROR
|
|
575
|
+
if p.is_dir():
|
|
576
|
+
print(f"Error: Input path is a directory, expected a font file: {p}", file=sys.stderr)
|
|
577
|
+
return EXIT_INPUT_ERROR
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
config = SliceConfig(
|
|
581
|
+
locale=args.locale,
|
|
582
|
+
freq_chunks=args.freq_chunks,
|
|
583
|
+
freq_tail_chunk_size=args.freq_tail_chunk_size,
|
|
584
|
+
rare_chunk_size=args.rare_chunk_size,
|
|
585
|
+
symbols_ext_chunk_size=args.symbols_chunk_size,
|
|
586
|
+
layout_closure=getattr(args, "layout_closure", True),
|
|
587
|
+
layout_features=tuple(getattr(args, "layout_features", ("*",))),
|
|
588
|
+
drop_tables=tuple(getattr(args, "drop_tables", ())),
|
|
589
|
+
uvs_base_repair=args.uvs_base_repair,
|
|
590
|
+
slices_subdir=getattr(args, "slices_dir", "slices"),
|
|
591
|
+
url_prefix=getattr(args, "url_prefix", None),
|
|
592
|
+
font_display=getattr(args, "display", "swap"),
|
|
593
|
+
filename_hash=getattr(args, "filename_hash", True),
|
|
594
|
+
)
|
|
595
|
+
except ValueError as exc:
|
|
596
|
+
return _handle_cli_exception(exc)
|
|
597
|
+
|
|
598
|
+
if args.subcommand == "plan":
|
|
599
|
+
return _run_plan(args, config)
|
|
600
|
+
return _run_build(args, config)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
if __name__ == "__main__":
|
|
604
|
+
raise SystemExit(main())
|