localtools-font-subsetter 1.0.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ dist/
2
+ /*.exe
3
+ node_modules/
4
+ test-results/
5
+ playwright-report/
6
+ __pycache__/
7
+ *.egg-info/
8
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chaitanya Prabuddha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.5
2
+ Name: localtools-font-subsetter
3
+ Version: 1.0.0
4
+ Summary: Subset fonts to the characters you use, instance variable fonts, and convert to WOFF2 (localtools, offline, fontTools)
5
+ Project-URL: Source, https://github.com/chayprabs/localtools
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: brotli>=1.1
10
+ Requires-Dist: fonttools>=4.60
11
+ Requires-Dist: multipart>=1.2
12
+ Description-Content-Type: text/markdown
13
+
14
+ # localtools-font-subsetter
15
+
16
+ Make web fonts small, on your machine: subset TTF/OTF/WOFF/WOFF2 fonts to the characters you actually use (Unicode
17
+ presets, your own text, or Unicode ranges), keep or drop OpenType features, drop hinting, pin or limit variable-font axes
18
+ (e.g. `wght` 400 to 700), and write WOFF2, WOFF or TTF/OTF. Each result shows before/after size and glyph count, a coverage
19
+ report (which requested characters the font does not have), font metadata (family, style, axes, license, fsType embedding
20
+ bits, with a warning when they forbid embedding or subsetting), a live preview rendered with the subset font, and a
21
+ ready-to-paste `@font-face` rule with the exact `unicode-range`. Nothing leaves your computer.
22
+
23
+ ## Install
24
+
25
+ | OS | command |
26
+ |---|---|
27
+ | Windows (PowerShell) | `irm https://raw.githubusercontent.com/chayprabs/localtools/main/font-subsetter/install.ps1 \| iex` |
28
+ | macOS / Linux | `curl -fsSL https://raw.githubusercontent.com/chayprabs/localtools/main/font-subsetter/install.sh \| bash` |
29
+ | PyPI (Python 3.10+) | `pipx install localtools-font-subsetter` |
30
+
31
+ The installer unpacks a portable build with its own Python 3.13 (17 MB zip on Windows, 24 to 39 MB on macOS/Linux).
32
+
33
+ ## Use
34
+
35
+ **UI:** run `localtools-font-subsetter` (or the Start menu / Applications launcher). It opens `http://127.0.0.1:47129`.
36
+ Drop one or more fonts, tick presets (Latin, Latin Ext, Cyrillic, Cyrillic Ext, Greek, Greek Ext, Vietnamese, or Keep all)
37
+ and/or type sample text, optionally Unicode ranges, layout features and axes, pick a format, press Subset. The preview line
38
+ is editable. Copy CSS and Download sit on each result.
39
+
40
+ - Presets use the same ranges Google Fonts serves, so `unicode-range` splitting works the way the web expects.
41
+ - Layout features: `default` (fontTools' set: kern, liga, calt, mark, mkmk, locl, ...), `all`, `none`, an exact list
42
+ (`kern,liga`) or edits to the default (`+ss01,+tnum,-liga`).
43
+ - Axes use fontTools instancer syntax: `wght=400` pins, `wght=400:700` limits, `wght=300:400:700` limits and moves the
44
+ default, `opsz=drop` pins to the default. Pin every axis to get a static font.
45
+ - Hinting is dropped by default (smaller files; browsers on modern systems barely use it). Tick Keep hinting to keep it.
46
+
47
+ **CLI:**
48
+
49
+ ```sh
50
+ localtools-font-subsetter subset Inter.ttf --preset latin --text "Ωμέγα" --format woff2 -o out/
51
+ localtools-font-subsetter subset RobotoFlex.ttf --preset latin,latin-ext --axes "wght=400:700 opsz=drop" -o out/ --css > fonts.css
52
+ localtools-font-subsetter subset *.otf --text-file page.html --features +tnum --format woff -o out/ --json
53
+ ```
54
+
55
+ Output files are `<name>-subset.<ext>`. Missing characters and license warnings go to stderr; `--css` prints the
56
+ `@font-face` rules, `--json` the full report.
57
+
58
+ **API:** `POST /api/subset`, multipart: `file` (repeat for several fonts), `preset` (comma list), `text`, `unicodes`,
59
+ `features`, `hinting` (`1`), `axes`, `format` (`woff2 woff ttf otf`). One font returns the font file, several return a zip
60
+ with `fonts.css`. `POST /api/report` takes the same fields and returns JSON (sizes, glyphs, axes, metadata, coverage,
61
+ warnings, CSS, base64 font). Errors: `400 {"error": "..."}`.
62
+
63
+ ```sh
64
+ curl -F file=@Inter.ttf -F preset=latin -F text=Hello -F format=woff2 -o Inter-subset.woff2 http://127.0.0.1:47129/api/subset
65
+ ```
66
+
67
+ ## Engine
68
+
69
+ [fontTools](https://github.com/fonttools/fonttools) (MIT): `fontTools.subset` is pyftsubset, and `fontTools.varLib.instancer`
70
+ is the reference variable-font instancer; Google Fonts and most font foundries build with them. WOFF2 uses Google's
71
+ [brotli](https://github.com/google/brotli) (MIT). The font is subset first and instanced second: that is faster, and it
72
+ avoids a fontTools failure when subsetting an already partially-instanced font in memory. The full name table (copyright,
73
+ license, instance names) is kept.
74
+
75
+ ## Not included
76
+
77
+ - Font collections (`.ttc`/`.otc`): refused with a clear error; extract a single font first.
78
+ - EOT and SVG fonts: obsolete web formats.
79
+ - Instancing large variable fonts is slow (a 2 MB, 4,500-glyph font takes about 20 s). Subsetting first helps a lot.
80
+
81
+ ## Update / uninstall
82
+
83
+ `localtools-font-subsetter update` / `localtools-font-subsetter uninstall` (installer builds). For PyPI installs:
84
+ `pipx upgrade localtools-font-subsetter` / `pipx uninstall localtools-font-subsetter`.
85
+
86
+ ## License
87
+
88
+ MIT. Test fonts in `samples/` are subsets of Inter, Roboto Flex and Noto Sans (SIL OFL 1.1, see `samples/licenses/`).
@@ -0,0 +1,75 @@
1
+ # localtools-font-subsetter
2
+
3
+ Make web fonts small, on your machine: subset TTF/OTF/WOFF/WOFF2 fonts to the characters you actually use (Unicode
4
+ presets, your own text, or Unicode ranges), keep or drop OpenType features, drop hinting, pin or limit variable-font axes
5
+ (e.g. `wght` 400 to 700), and write WOFF2, WOFF or TTF/OTF. Each result shows before/after size and glyph count, a coverage
6
+ report (which requested characters the font does not have), font metadata (family, style, axes, license, fsType embedding
7
+ bits, with a warning when they forbid embedding or subsetting), a live preview rendered with the subset font, and a
8
+ ready-to-paste `@font-face` rule with the exact `unicode-range`. Nothing leaves your computer.
9
+
10
+ ## Install
11
+
12
+ | OS | command |
13
+ |---|---|
14
+ | Windows (PowerShell) | `irm https://raw.githubusercontent.com/chayprabs/localtools/main/font-subsetter/install.ps1 \| iex` |
15
+ | macOS / Linux | `curl -fsSL https://raw.githubusercontent.com/chayprabs/localtools/main/font-subsetter/install.sh \| bash` |
16
+ | PyPI (Python 3.10+) | `pipx install localtools-font-subsetter` |
17
+
18
+ The installer unpacks a portable build with its own Python 3.13 (17 MB zip on Windows, 24 to 39 MB on macOS/Linux).
19
+
20
+ ## Use
21
+
22
+ **UI:** run `localtools-font-subsetter` (or the Start menu / Applications launcher). It opens `http://127.0.0.1:47129`.
23
+ Drop one or more fonts, tick presets (Latin, Latin Ext, Cyrillic, Cyrillic Ext, Greek, Greek Ext, Vietnamese, or Keep all)
24
+ and/or type sample text, optionally Unicode ranges, layout features and axes, pick a format, press Subset. The preview line
25
+ is editable. Copy CSS and Download sit on each result.
26
+
27
+ - Presets use the same ranges Google Fonts serves, so `unicode-range` splitting works the way the web expects.
28
+ - Layout features: `default` (fontTools' set: kern, liga, calt, mark, mkmk, locl, ...), `all`, `none`, an exact list
29
+ (`kern,liga`) or edits to the default (`+ss01,+tnum,-liga`).
30
+ - Axes use fontTools instancer syntax: `wght=400` pins, `wght=400:700` limits, `wght=300:400:700` limits and moves the
31
+ default, `opsz=drop` pins to the default. Pin every axis to get a static font.
32
+ - Hinting is dropped by default (smaller files; browsers on modern systems barely use it). Tick Keep hinting to keep it.
33
+
34
+ **CLI:**
35
+
36
+ ```sh
37
+ localtools-font-subsetter subset Inter.ttf --preset latin --text "Ωμέγα" --format woff2 -o out/
38
+ localtools-font-subsetter subset RobotoFlex.ttf --preset latin,latin-ext --axes "wght=400:700 opsz=drop" -o out/ --css > fonts.css
39
+ localtools-font-subsetter subset *.otf --text-file page.html --features +tnum --format woff -o out/ --json
40
+ ```
41
+
42
+ Output files are `<name>-subset.<ext>`. Missing characters and license warnings go to stderr; `--css` prints the
43
+ `@font-face` rules, `--json` the full report.
44
+
45
+ **API:** `POST /api/subset`, multipart: `file` (repeat for several fonts), `preset` (comma list), `text`, `unicodes`,
46
+ `features`, `hinting` (`1`), `axes`, `format` (`woff2 woff ttf otf`). One font returns the font file, several return a zip
47
+ with `fonts.css`. `POST /api/report` takes the same fields and returns JSON (sizes, glyphs, axes, metadata, coverage,
48
+ warnings, CSS, base64 font). Errors: `400 {"error": "..."}`.
49
+
50
+ ```sh
51
+ curl -F file=@Inter.ttf -F preset=latin -F text=Hello -F format=woff2 -o Inter-subset.woff2 http://127.0.0.1:47129/api/subset
52
+ ```
53
+
54
+ ## Engine
55
+
56
+ [fontTools](https://github.com/fonttools/fonttools) (MIT): `fontTools.subset` is pyftsubset, and `fontTools.varLib.instancer`
57
+ is the reference variable-font instancer; Google Fonts and most font foundries build with them. WOFF2 uses Google's
58
+ [brotli](https://github.com/google/brotli) (MIT). The font is subset first and instanced second: that is faster, and it
59
+ avoids a fontTools failure when subsetting an already partially-instanced font in memory. The full name table (copyright,
60
+ license, instance names) is kept.
61
+
62
+ ## Not included
63
+
64
+ - Font collections (`.ttc`/`.otc`): refused with a clear error; extract a single font first.
65
+ - EOT and SVG fonts: obsolete web formats.
66
+ - Instancing large variable fonts is slow (a 2 MB, 4,500-glyph font takes about 20 s). Subsetting first helps a lot.
67
+
68
+ ## Update / uninstall
69
+
70
+ `localtools-font-subsetter update` / `localtools-font-subsetter uninstall` (installer builds). For PyPI installs:
71
+ `pipx upgrade localtools-font-subsetter` / `pipx uninstall localtools-font-subsetter`.
72
+
73
+ ## License
74
+
75
+ MIT. Test fonts in `samples/` are subsets of Inter, Roboto Flex and Noto Sans (SIL OFL 1.1, see `samples/licenses/`).
@@ -0,0 +1,156 @@
1
+ import argparse
2
+ import base64
3
+ import io
4
+ import json
5
+ import sys
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ import multipart
10
+
11
+ from .engine import FORMATS, PRESETS, FontError, Options, process
12
+ from .localtools import HTTPError, Tool, main as run
13
+
14
+ USAGE = f"""localtools-font-subsetter: subset, instance and convert fonts for the web, offline (fontTools).
15
+
16
+ Usage:
17
+ localtools-font-subsetter [serve] [--port N] [--no-open] start the web UI (default port 47129)
18
+ localtools-font-subsetter subset FONT... [options] subset fonts from the command line
19
+ --preset P[,P] {", ".join(PRESETS)}
20
+ --text TEXT keep the glyphs of this text --text-file FILE same, from a UTF-8 file
21
+ --unicodes U Unicode ranges, e.g. "U+0020-007E, U+2014"
22
+ --features F default | all | none | kern,liga | +ss01,-liga (OpenType layout features)
23
+ --hinting keep hinting (dropped by default: smaller web fonts)
24
+ --axes A variable fonts: pin or limit axes, e.g. "wght=400:700 wdth=100 opsz=drop"
25
+ --format F woff2 (default) | woff | ttf | otf
26
+ -o DIR output folder (default: current folder)
27
+ --css print @font-face rules --json print the full report as JSON
28
+ localtools-font-subsetter update | uninstall re-run the installer / remove the tool
29
+ localtools-font-subsetter --version | --help
30
+
31
+ API: POST /api/subset (multipart: file..., preset, text, unicodes, features, hinting, axes, format)
32
+ returns the font (one file) or a zip with fonts.css (several); POST /api/report returns the JSON report.
33
+ """
34
+
35
+ UI = Path(__file__).with_name("ui") # wheel: packaged ui/; source checkout: repo-root ui/
36
+ if not UI.is_dir():
37
+ UI = Path(__file__).parents[1] / "ui"
38
+ MAX_UPLOAD = 256 << 20
39
+
40
+
41
+ def read_form(req):
42
+ """Like localtools.form(), but keeps every uploaded file (the UI sends several under "file")."""
43
+ ctype, opts = multipart.parse_options_header(req.headers.get("Content-Type", ""))
44
+ if ctype != "multipart/form-data" or "boundary" not in opts:
45
+ raise HTTPError(400, "expected multipart/form-data")
46
+ size = int(req.headers.get("Content-Length") or 0)
47
+ if size > MAX_UPLOAD:
48
+ raise HTTPError(413, "upload too large (256 MB max)")
49
+ try:
50
+ parts = list(multipart.MultipartParser(req.rfile, opts["boundary"], size))
51
+ except multipart.MultipartError as e:
52
+ raise HTTPError(400, f"malformed upload: {e}")
53
+ try:
54
+ files = [(p.filename, p.raw) for p in parts if p.filename]
55
+ fields = {p.name: p.value for p in parts if not p.filename}
56
+ finally:
57
+ for p in parts:
58
+ p.close()
59
+ if not files:
60
+ raise HTTPError(400, "upload at least one font (TTF, OTF, WOFF or WOFF2)")
61
+ return files, options(fields)
62
+
63
+
64
+ def options(f):
65
+ return Options(presets=[p.strip() for p in f.get("preset", "").split(",") if p.strip()],
66
+ text=f.get("text", ""), unicodes=f.get("unicodes", ""),
67
+ features=f.get("features", "default") or "default",
68
+ hinting=f.get("hinting", "").lower() in ("1", "on", "true", "yes"),
69
+ axes=f.get("axes", ""), format=(f.get("format") or "woff2").lower())
70
+
71
+
72
+ def run_all(files, o):
73
+ try:
74
+ return [process(data, name, o) for name, data in files]
75
+ except FontError as e:
76
+ raise HTTPError(400, str(e))
77
+
78
+
79
+ def api_subset(req):
80
+ results = run_all(*read_form(req))
81
+ if len(results) == 1:
82
+ body, rep = results[0]
83
+ ctype, filename = rep["mime"], rep["output"]
84
+ else:
85
+ buf = io.BytesIO()
86
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
87
+ for data, rep in results:
88
+ z.writestr(rep["output"], data)
89
+ z.writestr("fonts.css", "\n".join(rep["css"] for _, rep in results))
90
+ body, ctype, filename = buf.getvalue(), "application/zip", "fonts-subset.zip"
91
+ req.send_response(200)
92
+ req.send_header("Content-Type", ctype)
93
+ req.send_header("Content-Disposition", f'attachment; filename="{filename}"')
94
+ req.send_header("Content-Length", str(len(body)))
95
+ req.end_headers()
96
+ req.wfile.write(body)
97
+
98
+
99
+ def api_report(req):
100
+ results = run_all(*read_form(req))
101
+ req.send_json(200, {"fonts": [{**rep, "data": base64.b64encode(data).decode()} for data, rep in results]})
102
+
103
+
104
+ def kb(n):
105
+ return f"{n / 1024:.1f} KB"
106
+
107
+
108
+ def cli_subset(args):
109
+ p = argparse.ArgumentParser(prog="localtools-font-subsetter subset")
110
+ p.add_argument("fonts", nargs="+")
111
+ p.add_argument("--preset", default="")
112
+ p.add_argument("--text", default="")
113
+ p.add_argument("--text-file")
114
+ p.add_argument("--unicodes", default="")
115
+ p.add_argument("--features", default="default")
116
+ p.add_argument("--hinting", action="store_true")
117
+ p.add_argument("--axes", default="")
118
+ p.add_argument("--format", default="woff2", choices=list(FORMATS))
119
+ p.add_argument("-o", "--out", default=".")
120
+ p.add_argument("--css", action="store_true")
121
+ p.add_argument("--json", action="store_true")
122
+ a = p.parse_args(args)
123
+ for s in (sys.stdout, sys.stderr): # missing-character lists are Unicode; Windows pipes default to cp1252
124
+ s.reconfigure(encoding="utf-8")
125
+ text = a.text + (Path(a.text_file).read_text(encoding="utf-8") if a.text_file else "")
126
+ o = Options(presets=[x for x in a.preset.split(",") if x], text=text, unicodes=a.unicodes,
127
+ features=a.features, hinting=a.hinting, axes=a.axes, format=a.format)
128
+ out = Path(a.out)
129
+ out.mkdir(parents=True, exist_ok=True)
130
+ reports = []
131
+ for f in a.fonts:
132
+ data, rep = process(Path(f).read_bytes(), Path(f).name, o)
133
+ (out / rep["output"]).write_bytes(data)
134
+ reports.append(rep)
135
+ b, af = rep["before"], rep["after"]
136
+ print(f"{f} -> {out / rep['output']} {kb(b['size'])} -> {kb(af['size'])} "
137
+ f"({(af['size'] - b['size']) * 100 / b['size']:+.0f}%), {b['glyphs']} -> {af['glyphs']} glyphs",
138
+ file=sys.stderr if a.css or a.json else sys.stdout)
139
+ for w in rep["warnings"]:
140
+ print(f" {w['severity']}: {w['message']}", file=sys.stderr)
141
+ if a.json:
142
+ print(json.dumps(reports, indent=2, ensure_ascii=False))
143
+ elif a.css:
144
+ print("\n".join(r["css"] for r in reports), end="")
145
+
146
+
147
+ tool = Tool(slug="font-subsetter", port=47129, usage=USAGE, ui=UI,
148
+ routes={"POST /api/subset": api_subset, "POST /api/report": api_report}, cli={"subset": cli_subset})
149
+
150
+
151
+ def main():
152
+ sys.exit(run(tool))
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
@@ -0,0 +1,233 @@
1
+ """Font subsetting, variable-font instancing and conversion with fontTools (pyftsubset + varLib.instancer)."""
2
+ import io
3
+ from itertools import count, groupby
4
+ import re
5
+ from dataclasses import dataclass, field
6
+
7
+ from fontTools import subset
8
+ from fontTools.ttLib import TTFont
9
+ from fontTools.varLib import instancer
10
+
11
+ # Same ranges Google Fonts serves in its CSS, so the output matches what the web already uses.
12
+ PRESETS = {
13
+ "latin": "U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,"
14
+ "U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD",
15
+ "latin-ext": "U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,"
16
+ "U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF",
17
+ "cyrillic": "U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116",
18
+ "cyrillic-ext": "U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F",
19
+ "greek": "U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF",
20
+ "greek-ext": "U+1F00-1FFF",
21
+ "vietnamese": "U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,"
22
+ "U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB",
23
+ "all": "", # keep every character (convert or instance only)
24
+ }
25
+ FORMATS = {"woff2": ("woff2", "font/woff2"), "woff": ("woff", "font/woff"), "ttf": (None, "font/ttf"), "otf": (None, "font/otf")}
26
+ EMBEDDING = {0: "Installable", 2: "Restricted License", 4: "Preview & Print", 8: "Editable"}
27
+
28
+
29
+ class FontError(ValueError):
30
+ pass
31
+
32
+
33
+ @dataclass
34
+ class Options:
35
+ presets: list = field(default_factory=list)
36
+ text: str = ""
37
+ unicodes: str = ""
38
+ features: str = "default" # default | all | none | tag list | +tag,-tag (relative to default)
39
+ hinting: bool = False
40
+ axes: str = "" # instancer limits: "wght=400:700 wdth=100 opsz=drop"
41
+ format: str = "woff2"
42
+
43
+
44
+ def parse_unicodes(s):
45
+ try:
46
+ return set(subset.parse_unicodes(s.replace(";", ",")))
47
+ except ValueError:
48
+ raise FontError(f'bad Unicode ranges "{s}" (use e.g. U+0020-007E, U+2014)')
49
+
50
+
51
+ def requested(o):
52
+ """-> (codepoints or None for "keep all", [(label, codepoint set)])"""
53
+ groups = []
54
+ for p in o.presets:
55
+ if p not in PRESETS:
56
+ raise FontError(f'unknown preset "{p}" (choose from {", ".join(PRESETS)})')
57
+ if p == "all":
58
+ return None, []
59
+ groups.append((p, parse_unicodes(PRESETS[p])))
60
+ if o.unicodes.strip():
61
+ groups.append(("ranges", parse_unicodes(o.unicodes)))
62
+ text = {ord(c) for c in o.text if ord(c) >= 0x20}
63
+ if text:
64
+ groups.append(("text", text))
65
+ if not groups:
66
+ raise FontError("choose characters: a preset, sample text or Unicode ranges")
67
+ return set().union(*(g for _, g in groups)), groups
68
+
69
+
70
+ def features(spec):
71
+ s = spec.replace(" ", "").strip(",")
72
+ default = list(subset.Options().layout_features)
73
+ if s in ("", "default"):
74
+ return default
75
+ if s in ("all", "*"):
76
+ return ["*"]
77
+ if s == "none":
78
+ return []
79
+ tags = s.split(",")
80
+ if not all(re.fullmatch(r"[+-]?[A-Za-z0-9 ]{1,4}", t) for t in tags):
81
+ raise FontError(f'bad feature list "{spec}" (use e.g. kern,liga or +ss01,-liga)')
82
+ if all(t[0] in "+-" for t in tags):
83
+ add = [t[1:] for t in tags if t[0] == "+"]
84
+ return [f for f in default if f"-{f}" not in tags] + add
85
+ return tags
86
+
87
+
88
+ def ranges_css(cps):
89
+ runs = [list(g) for _, g in groupby(sorted(cps), key=lambda c, n=count(): c - next(n))]
90
+ return ", ".join(f"U+{r[0]:04X}" + (f"-{r[-1]:04X}" if len(r) > 1 else "") for r in runs)
91
+
92
+
93
+ def name(font, *ids):
94
+ for i in ids:
95
+ if "name" in font and (n := font["name"].getDebugName(i)):
96
+ return n
97
+ return ""
98
+
99
+
100
+ def axes(font):
101
+ if "fvar" not in font:
102
+ return []
103
+ return [{"tag": a.axisTag, "name": name(font, a.axisNameID) or a.axisTag,
104
+ "min": a.minValue, "default": a.defaultValue, "max": a.maxValue} for a in font["fvar"].axes]
105
+
106
+
107
+ def kind(font):
108
+ outlines = "CFF2" if "CFF2" in font else "CFF" if "CFF " in font else "TrueType"
109
+ return (font.flavor or ("otf" if outlines.startswith("CFF") else "ttf")), outlines
110
+
111
+
112
+ def describe(font, size):
113
+ fmt, outlines = kind(font)
114
+ return {"size": size, "glyphs": len(font.getGlyphOrder()), "format": fmt, "outlines": outlines,
115
+ "codepoints": len(font.getBestCmap() or {}), "axes": axes(font)}
116
+
117
+
118
+ def metadata(font):
119
+ os2 = font.get("OS/2")
120
+ fs = os2.fsType if os2 else 0
121
+ feats = sorted({fr.FeatureTag for t in ("GSUB", "GPOS") if t in font and font[t].table.FeatureList
122
+ for fr in font[t].table.FeatureList.FeatureRecord})
123
+ return {"family": name(font, 16, 1), "style": name(font, 17, 2), "version": name(font, 5),
124
+ "vendor": (os2.achVendID.strip() if os2 else ""), "copyright": name(font, 0),
125
+ "license": name(font, 13), "license_url": name(font, 14), "fsType": fs,
126
+ "embedding": EMBEDDING.get(fs & 0xF, f"unknown ({fs & 0xF})"), "features": feats}
127
+
128
+
129
+ def license_warnings(meta):
130
+ fs, w = meta["fsType"], []
131
+ if fs & 0xF == 2:
132
+ w.append(("error", "fsType says Restricted License embedding: the vendor forbids embedding this font. Check your license before using it on the web."))
133
+ elif fs & 0xF == 4:
134
+ w.append(("warn", "fsType says Preview & Print embedding only: editing or modifying the font is not allowed by default."))
135
+ if fs & 0x100:
136
+ w.append(("error", "fsType has the No-subsetting bit: the vendor forbids subsetting this font."))
137
+ if fs & 0x200:
138
+ w.append(("warn", "fsType has the Bitmap-embedding-only bit: outlines may not be embedded."))
139
+ lic = (meta["license"] + " " + meta["license_url"]).lower()
140
+ if "reserved font name" in lic:
141
+ w.append(("info", "The OFL license reserves the font name: a modified font may need a different family name for redistribution."))
142
+ return w
143
+
144
+
145
+ def load(data, filename=""):
146
+ if data[:4] == b"ttcf":
147
+ raise FontError(f"{filename}: font collections (.ttc/.otc) are not supported; extract one font first")
148
+ try:
149
+ font = TTFont(io.BytesIO(data))
150
+ font.getGlyphOrder()
151
+ font.getBestCmap()
152
+ return font
153
+ except Exception as e:
154
+ raise FontError(f"{filename or 'input'}: not a readable TTF/OTF/WOFF/WOFF2 font ({type(e).__name__}: {e})")
155
+
156
+
157
+ def font_face(family, after_font, filename, fmt):
158
+ a = {x["tag"]: x for x in axes(after_font)}
159
+ os2 = after_font.get("OS/2")
160
+ italic = bool(os2 and os2.fsSelection & 1) or ("post" in after_font and after_font["post"].italicAngle != 0)
161
+ weight = f"{a['wght']['min']:g} {a['wght']['max']:g}" if "wght" in a else str(os2.usWeightClass if os2 else 400)
162
+ lines = [f' font-family: "{family}";', f" font-style: {'italic' if italic else 'normal'};", f" font-weight: {weight};"]
163
+ if "wdth" in a:
164
+ lines.append(f" font-stretch: {a['wdth']['min']:g}% {a['wdth']['max']:g}%;")
165
+ css_fmt = {"woff2": "woff2", "woff": "woff", "ttf": "truetype", "otf": "opentype"}[fmt]
166
+ lines += [" font-display: swap;", f' src: url("{filename}") format("{css_fmt}");',
167
+ f" unicode-range: {ranges_css((after_font.getBestCmap() or {}).keys())};"]
168
+ return "@font-face {\n" + "\n".join(lines) + "\n}\n"
169
+
170
+
171
+ def process(data, filename, o):
172
+ """Subset, instance and convert one font. Returns (output bytes, report dict)."""
173
+ if o.format not in FORMATS:
174
+ raise FontError(f'unknown format "{o.format}" (woff2, woff, ttf, otf)')
175
+ cps, groups = requested(o)
176
+ font = load(data, filename)
177
+ before, meta = describe(font, len(data)), metadata(font)
178
+ warnings = license_warnings(meta)
179
+ cmap = set((font.getBestCmap() or {}).keys())
180
+
181
+ limits = None
182
+ if o.axes.strip():
183
+ if "fvar" not in font:
184
+ raise FontError(f"{filename}: not a variable font, so there are no axes to pin or limit")
185
+ try:
186
+ limits = instancer.parseLimits(o.axes.replace(",", " ").split())
187
+ except ValueError as e:
188
+ raise FontError(f'bad axes "{o.axes}": {e} (use e.g. wght=400:700 wdth=100 opsz=drop)')
189
+ unknown = set(limits) - {a["tag"] for a in before["axes"]}
190
+ if unknown:
191
+ raise FontError(f"{filename}: no axis {', '.join(sorted(unknown))} (font axes: {', '.join(a['tag'] for a in before['axes'])})")
192
+
193
+ opts = subset.Options()
194
+ opts.layout_features = features(o.features)
195
+ opts.hinting = o.hinting
196
+ opts.name_IDs = ["*"] # keep copyright, license and instance names
197
+ opts.name_languages = ["*"]
198
+ opts.notdef_outline = True
199
+ opts.ignore_missing_unicodes = True
200
+ sub = subset.Subsetter(opts)
201
+ sub.populate(unicodes=cmap if cps is None else cps)
202
+ try:
203
+ sub.subset(font)
204
+ if limits is not None: # after subsetting: less to instance, and avoids fontTools' lazy-gvar subsetting bug
205
+ font = instancer.instantiateVariableFont(font, limits)
206
+ except Exception as e:
207
+ raise FontError(f"{filename}: fontTools could not process this font ({type(e).__name__}: {e})")
208
+
209
+ flavor, mime = FORMATS[o.format]
210
+ font.flavor = flavor
211
+ buf = io.BytesIO()
212
+ font.save(buf)
213
+ out = buf.getvalue()
214
+ after = describe(load(out, filename), len(out))
215
+ fmt = o.format if flavor else kind(font)[0]
216
+ stem = re.sub(r"\.(ttf|otf|woff2?)$", "", filename, flags=re.I) or "font"
217
+ outname = f"{stem}-subset.{fmt}"
218
+
219
+ coverage = []
220
+ for label, g in groups:
221
+ g = {c for c in g if not (c < 0x20 or 0x7F <= c <= 0x9F)} # control characters have no glyphs
222
+ missing = sorted(g - cmap)
223
+ coverage.append({"group": label, "requested": len(g), "covered": len(g) - len(missing),
224
+ "missing": "".join(map(chr, missing[:500])) if label == "text" else ranges_css(missing)})
225
+ text_missing = next((c["missing"] for c in coverage if c["group"] == "text"), "")
226
+ if text_missing:
227
+ warnings.append(("warn", f"{len(text_missing)} character(s) of your text are not in this font: {text_missing}"))
228
+ return out, {
229
+ "name": filename, "output": outname, "mime": mime if flavor else f"font/{fmt}",
230
+ "before": before, "after": after, "meta": meta, "coverage": coverage,
231
+ "warnings": [{"severity": s, "message": m} for s, m in warnings],
232
+ "css": font_face(meta["family"] or stem, font, outname, fmt),
233
+ }