Skip to content

Commit 5d11f03

Browse files
committed
Add generate_release_artifacts.py with version injection and table-identity verifier
Patches the name table (ID 5) and head.fontRevision with a CalVer YYYY.MICRO version, regenerates WOFF and WOFF2 from the patched OTF, and prepends a header + globalThis.XKCD_MATHJAX_VERSION to xkcd-mathjax3.js. The verifier walks every table and asserts byte-identity against the committed unversioned font, allowing only the expected fields in head (fontRevision, modified, checksumAdjustment) and the expected nameID 5 records to differ. pytest suite runs against the committed xkcd-script.otf for full realism.
1 parent 42a25be commit 5d11f03

3 files changed

Lines changed: 307 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Patch version metadata into xkcd-script fonts and JS, then verify."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import datetime
7+
import pathlib
8+
import re
9+
10+
from fontTools.ttLib import TTFont
11+
12+
_VERSION_RE = re.compile(r"^\d{4}\.\d+$")
13+
_DEV_VERSION = "0.0-dev"
14+
_HEAD_ALLOWED_DIFF = frozenset({"fontRevision", "modified", "checksumAdjustment"})
15+
_NAME_ALLOWED_DIFF = frozenset({5})
16+
17+
18+
class TableMismatch(Exception):
19+
"""Raised when a verified font table differs from its expected baseline."""
20+
21+
22+
def validate_version(version: str) -> None:
23+
"""Raise ValueError if *version* is not a CalVer YYYY.MICRO or "0.0-dev"."""
24+
if version == _DEV_VERSION:
25+
return
26+
if not _VERSION_RE.match(version):
27+
raise ValueError(
28+
f"Version {version!r} does not match YYYY.MICRO or {_DEV_VERSION!r}"
29+
)
30+
31+
32+
def patch_font(font: TTFont, *, version: str, build_date: str) -> None:
33+
"""Patch name table version + head.fontRevision in-place."""
34+
validate_version(version)
35+
36+
version_string = f"Version {version}; {build_date}"
37+
name_table = font["name"]
38+
existing = [r for r in name_table.names if r.nameID == 5]
39+
for record in existing:
40+
name_table.setName(
41+
version_string,
42+
record.nameID,
43+
record.platformID,
44+
record.platEncID,
45+
record.langID,
46+
)
47+
48+
if version != _DEV_VERSION:
49+
font["head"].fontRevision = float(version)
50+
51+
52+
def verify_tables_identical(*, original: TTFont, patched: TTFont) -> None:
53+
"""Raise TableMismatch if any table differs outside the allowed fields."""
54+
# GlyphOrder is a virtual table fontTools exposes but never serialises.
55+
tags_original = {t for t in original.keys() if t != "GlyphOrder"}
56+
tags_patched = {t for t in patched.keys() if t != "GlyphOrder"}
57+
if tags_original != tags_patched:
58+
raise TableMismatch(
59+
f"Table tag set differs: only-original={tags_original - tags_patched}, "
60+
f"only-patched={tags_patched - tags_original}"
61+
)
62+
63+
for tag in sorted(tags_original):
64+
if tag == "head":
65+
_verify_head(original["head"], patched["head"])
66+
elif tag == "name":
67+
_verify_name(original["name"], patched["name"])
68+
else:
69+
_verify_generic(tag, original, patched)
70+
71+
72+
def _verify_head(orig, patched) -> None:
73+
for field in orig.__dict__:
74+
if field in _HEAD_ALLOWED_DIFF:
75+
continue
76+
if getattr(orig, field) != getattr(patched, field):
77+
raise TableMismatch(f"head.{field} changed unexpectedly")
78+
79+
80+
def _verify_name(orig, patched) -> None:
81+
def key(record):
82+
return (record.nameID, record.platformID, record.platEncID, record.langID)
83+
84+
orig_records = {key(r): str(r) for r in orig.names}
85+
patched_records = {key(r): str(r) for r in patched.names}
86+
87+
if set(orig_records) != set(patched_records):
88+
raise TableMismatch("name table record set differs")
89+
90+
for record_key, original_value in orig_records.items():
91+
if record_key[0] in _NAME_ALLOWED_DIFF:
92+
continue
93+
if patched_records[record_key] != original_value:
94+
raise TableMismatch(f"name record {record_key} changed unexpectedly")
95+
96+
97+
def _verify_generic(tag: str, original: TTFont, patched: TTFont) -> None:
98+
orig_bytes = original.getTableData(tag)
99+
patched_bytes = patched.getTableData(tag)
100+
if orig_bytes != patched_bytes:
101+
raise TableMismatch(
102+
f"Table {tag!r} bytes differ ({len(orig_bytes)} vs {len(patched_bytes)})"
103+
)
104+
105+
106+
def inject_js_version(source: str, *, version: str, build_date: str) -> str:
107+
"""Prepend a header comment and a runtime version constant to JS source."""
108+
header = (
109+
f"/*! xkcd-mathjax v{version} — built {build_date} — "
110+
f"https://github.com/ipython/xkcd-font */\n"
111+
)
112+
runtime = f'globalThis.XKCD_MATHJAX_VERSION = "{version}";\n'
113+
return header + runtime + source
114+
115+
116+
def _ttf_seconds(build_date: str) -> int:
117+
"""Seconds since 1904-01-01 for *build_date* (YYYY-MM-DD, UTC midnight)."""
118+
epoch = datetime.datetime(1904, 1, 1, tzinfo=datetime.timezone.utc)
119+
when = datetime.datetime.strptime(build_date, "%Y-%m-%d").replace(
120+
tzinfo=datetime.timezone.utc
121+
)
122+
return int((when - epoch).total_seconds())
123+
124+
125+
def write_patched_font(
126+
in_path: pathlib.Path,
127+
out_path: pathlib.Path,
128+
*,
129+
version: str,
130+
build_date: str,
131+
) -> TTFont:
132+
"""Patch *in_path*, verify the in-memory patched font, then save to *out_path*."""
133+
original = TTFont(in_path)
134+
patched = TTFont(in_path)
135+
patch_font(patched, version=version, build_date=build_date)
136+
if version != _DEV_VERSION:
137+
patched["head"].modified = _ttf_seconds(build_date)
138+
# Verify BEFORE save: fontTools recomputes head bounds + checksums on
139+
# compile, so post-save the in-memory `patched` no longer mirrors the
140+
# original's head fields.
141+
verify_tables_identical(original=original, patched=patched)
142+
out_path.parent.mkdir(parents=True, exist_ok=True)
143+
patched.save(out_path)
144+
return patched
145+
146+
147+
def regenerate_woff(patched_otf: pathlib.Path, out_path: pathlib.Path) -> None:
148+
font = TTFont(patched_otf)
149+
font.flavor = "woff"
150+
font.save(out_path)
151+
152+
153+
def regenerate_woff2(patched_otf: pathlib.Path, out_path: pathlib.Path) -> None:
154+
font = TTFont(patched_otf)
155+
font.flavor = "woff2"
156+
font.save(out_path)
157+
158+
159+
def main(argv: list[str] | None = None) -> int:
160+
parser = argparse.ArgumentParser(description=__doc__)
161+
parser.add_argument("--version", required=True)
162+
parser.add_argument("--font-dir", type=pathlib.Path, required=True)
163+
parser.add_argument("--js", type=pathlib.Path, required=True)
164+
parser.add_argument("--out-dir", type=pathlib.Path, required=True)
165+
parser.add_argument("--build-date", default=datetime.date.today().isoformat())
166+
args = parser.parse_args(argv)
167+
168+
validate_version(args.version)
169+
args.out_dir.mkdir(parents=True, exist_ok=True)
170+
171+
stem = f"xkcd-script-{args.version}"
172+
otf_in = args.font_dir / "xkcd-script.otf"
173+
ttf_in = args.font_dir / "xkcd-script.ttf"
174+
otf_out = args.out_dir / f"{stem}.otf"
175+
ttf_out = args.out_dir / f"{stem}.ttf"
176+
woff_out = args.out_dir / f"{stem}.woff"
177+
woff2_out = args.out_dir / f"{stem}.woff2"
178+
179+
if not otf_in.exists():
180+
raise FileNotFoundError(otf_in)
181+
if not ttf_in.exists():
182+
raise FileNotFoundError(ttf_in)
183+
184+
write_patched_font(
185+
otf_in, otf_out, version=args.version, build_date=args.build_date
186+
)
187+
write_patched_font(
188+
ttf_in, ttf_out, version=args.version, build_date=args.build_date
189+
)
190+
regenerate_woff(otf_out, woff_out)
191+
regenerate_woff2(otf_out, woff2_out)
192+
193+
js_source = args.js.read_text(encoding="utf-8")
194+
js_out = args.out_dir / f"xkcd-mathjax3-{args.version}.js"
195+
js_out.write_text(
196+
inject_js_version(js_source, version=args.version, build_date=args.build_date),
197+
encoding="utf-8",
198+
)
199+
200+
print(f"Wrote artifacts to {args.out_dir}")
201+
return 0
202+
203+
204+
if __name__ == "__main__":
205+
raise SystemExit(main())

xkcd-script/generator/tests/__init__.py

Whitespace-only changes.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import importlib.util
2+
import pathlib
3+
import sys
4+
5+
import pytest
6+
7+
_HERE = pathlib.Path(__file__).resolve().parent
8+
_SCRIPT = _HERE.parent / "generate_release_artifacts.py"
9+
_spec = importlib.util.spec_from_file_location("generate_release_artifacts", _SCRIPT)
10+
gra = importlib.util.module_from_spec(_spec)
11+
sys.modules["generate_release_artifacts"] = gra
12+
_spec.loader.exec_module(gra)
13+
14+
15+
@pytest.mark.parametrize("version", ["2026.0", "2026.12", "9999.0", "0.0-dev"])
16+
def test_validate_version_accepts(version):
17+
gra.validate_version(version)
18+
19+
20+
@pytest.mark.parametrize("version", ["", "v2026.0", "2026", "2026.0.1", "2026-0", "abc"])
21+
def test_validate_version_rejects(version):
22+
with pytest.raises(ValueError):
23+
gra.validate_version(version)
24+
25+
26+
from fontTools.ttLib import TTFont
27+
28+
_REPO_ROOT = _HERE.parents[2]
29+
_COMMITTED_OTF = _REPO_ROOT / "xkcd-script" / "font" / "xkcd-script.otf"
30+
31+
32+
def _load_committed_font() -> TTFont:
33+
"""Load a fresh TTFont from the committed unversioned xkcd-script.otf."""
34+
if not _COMMITTED_OTF.exists():
35+
pytest.skip(f"Committed font not present at {_COMMITTED_OTF}")
36+
return TTFont(_COMMITTED_OTF)
37+
38+
39+
def test_patch_font_sets_name_version():
40+
font = _load_committed_font()
41+
gra.patch_font(font, version="2026.0", build_date="2026-06-16")
42+
version_records = [r for r in font["name"].names if r.nameID == 5]
43+
assert version_records, "real font must have at least one nameID 5 record"
44+
for r in version_records:
45+
assert str(r) == "Version 2026.0; 2026-06-16"
46+
47+
48+
def test_patch_font_sets_head_fontrevision():
49+
font = _load_committed_font()
50+
gra.patch_font(font, version="2026.1", build_date="2026-06-16")
51+
assert font["head"].fontRevision == pytest.approx(2026.1, abs=1e-4)
52+
53+
54+
def test_patch_font_dev_leaves_fontrevision():
55+
font = _load_committed_font()
56+
original = font["head"].fontRevision
57+
gra.patch_font(font, version="0.0-dev", build_date="2026-06-16")
58+
assert font["head"].fontRevision == original
59+
60+
61+
def test_verify_tables_identical_passes_after_patch_only():
62+
original = _load_committed_font()
63+
patched = _load_committed_font()
64+
gra.patch_font(patched, version="2026.0", build_date="2026-06-16")
65+
gra.verify_tables_identical(original=original, patched=patched)
66+
67+
68+
def test_verify_tables_identical_rejects_unexpected_change():
69+
original = _load_committed_font()
70+
patched = _load_committed_font()
71+
gra.patch_font(patched, version="2026.0", build_date="2026-06-16")
72+
patched["OS/2"].usWeightClass = 999
73+
74+
with pytest.raises(gra.TableMismatch) as exc:
75+
gra.verify_tables_identical(original=original, patched=patched)
76+
assert "OS/2" in str(exc.value)
77+
78+
79+
def test_verify_tables_identical_rejects_unexpected_name_change():
80+
original = _load_committed_font()
81+
patched = _load_committed_font()
82+
gra.patch_font(patched, version="2026.0", build_date="2026-06-16")
83+
patched["name"].setName("EvilFamily", 1, 3, 1, 0x409)
84+
85+
with pytest.raises(gra.TableMismatch):
86+
gra.verify_tables_identical(original=original, patched=patched)
87+
88+
89+
def test_inject_js_version_adds_header_and_constant():
90+
source = "console.log('hi');\n"
91+
out = gra.inject_js_version(source, version="2026.0", build_date="2026-06-16")
92+
assert out.startswith(
93+
"/*! xkcd-mathjax v2026.0 — built 2026-06-16 — https://github.com/ipython/xkcd-font */\n"
94+
)
95+
assert 'globalThis.XKCD_MATHJAX_VERSION = "2026.0";' in out
96+
assert "console.log('hi');" in out
97+
98+
99+
def test_inject_js_version_dev():
100+
out = gra.inject_js_version("x", version="0.0-dev", build_date="2026-06-16")
101+
assert "v0.0-dev" in out
102+
assert 'globalThis.XKCD_MATHJAX_VERSION = "0.0-dev";' in out

0 commit comments

Comments
 (0)