diff --git a/.github/actions/build-font/action.yml b/.github/actions/build-font/action.yml new file mode 100644 index 0000000..0f8f717 --- /dev/null +++ b/.github/actions/build-font/action.yml @@ -0,0 +1,83 @@ +name: Build xkcd-script font +description: Build the unversioned font, run the diff check, generate versioned release artifacts, and upload dist/ as a workflow artifact. + +inputs: + version: + description: 'Version to bake into release artifacts (e.g. 2026.0, or 0.0-dev for non-release CI).' + required: true + push-image: + description: 'Whether to push the fontbuilder Docker image to GHCR.' + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Log in to GitHub Container Registry + if: inputs.push-image == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ env.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build (and optionally push) Docker image + uses: docker/build-push-action@v6 + with: + context: xkcd-script/generator + load: true + push: ${{ inputs.push-image }} + tags: ghcr.io/ipython/xkcd-font:fontbuilder + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Generate font + shell: bash + run: FONTBUILDER_IMAGE=ghcr.io/ipython/xkcd-font:fontbuilder xkcd-script/generator/run.sh + + - name: Generate samples + shell: bash + run: xkcd-script/samples/preview.sh + + - name: Check generated files match committed files + shell: bash + run: | + git diff --exit-code xkcd-script/font/ xkcd-script/samples/ || { + echo "" + echo "Generated files differ from committed files." + echo "Download the 'xkcd-script-font-${{ inputs.version }}' artifact from this run and commit the updated unversioned files (xkcd-script.{otf,ttf,woff,sfd} and the sample PNGs)." + exit 1 + } + + - name: Run pytest + shell: bash + run: | + docker run --rm -v "$PWD":/repo -w /repo ghcr.io/ipython/xkcd-font:fontbuilder \ + python3 -m pytest xkcd-script/generator/tests/ -v + + - name: Generate release artifacts + shell: bash + run: | + rm -rf dist + docker run --rm -v "$PWD":/repo -w /repo ghcr.io/ipython/xkcd-font:fontbuilder \ + python3 xkcd-script/generator/generate_release_artifacts.py \ + --version "${{ inputs.version }}" \ + --font-dir xkcd-script/font \ + --js xkcd-script/xkcd-mathjax3.js \ + --out-dir dist + + - name: Upload unversioned + release artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: xkcd-script-font-${{ inputs.version }} + path: | + xkcd-script/font/xkcd-script.otf + xkcd-script/font/xkcd-script.ttf + xkcd-script/font/xkcd-script.woff + xkcd-script/font/xkcd-script.sfd + xkcd-script/samples/**/*.png + dist/** diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8874191..364d427 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,55 +12,11 @@ jobs: permissions: contents: read packages: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 - - - name: Log in to GitHub Container Registry - if: github.event_name == 'push' - uses: docker/login-action@v3 + - uses: ./.github/actions/build-font with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build (and push on master) Docker image - uses: docker/build-push-action@v6 - with: - context: xkcd-script/generator - load: true - push: ${{ github.event_name == 'push' }} - tags: ghcr.io/ipython/xkcd-font:fontbuilder - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Generate font - run: FONTBUILDER_IMAGE=ghcr.io/ipython/xkcd-font:fontbuilder xkcd-script/generator/run.sh - - - name: Generate samples - run: xkcd-script/samples/preview.sh - - - name: Upload generated artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: xkcd-script-font - path: | - xkcd-script/font/xkcd-script.otf - xkcd-script/font/xkcd-script.ttf - xkcd-script/font/xkcd-script.woff - xkcd-script/font/xkcd-script.sfd - xkcd-script/samples/ipsum.png - xkcd-script/samples/handwriting.png - xkcd-script/samples/kerning.png - - - name: Check generated files match committed files - run: | - git diff --exit-code xkcd-script/font/ xkcd-script/samples/ || { - echo "" - echo "Generated files differ from committed files." - echo "Download the 'xkcd-script-font' artifact from this run and commit the updated files." - exit 1 - } + version: 0.0-dev + push-image: ${{ github.event_name == 'push' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..77f70cc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + release: + types: [published] + +jobs: + build-and-upload: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - uses: ./.github/actions/build-font + with: + version: ${{ github.event.release.tag_name }} + push-image: 'true' + + - name: Upload assets to the release + run: | + gh release upload "${{ github.event.release.tag_name }}" \ + dist/xkcd-script-${{ github.event.release.tag_name }}.otf \ + dist/xkcd-script-${{ github.event.release.tag_name }}.ttf \ + dist/xkcd-script-${{ github.event.release.tag_name }}.woff \ + dist/xkcd-script-${{ github.event.release.tag_name }}.woff2 \ + dist/xkcd-mathjax3-${{ github.event.release.tag_name }}.js \ + --clobber diff --git a/xkcd-script/generator/generate_release_artifacts.py b/xkcd-script/generator/generate_release_artifacts.py new file mode 100644 index 0000000..5393e68 --- /dev/null +++ b/xkcd-script/generator/generate_release_artifacts.py @@ -0,0 +1,205 @@ +"""Patch version metadata into xkcd-script fonts and JS, then verify.""" + +from __future__ import annotations + +import argparse +import datetime +import pathlib +import re + +from fontTools.ttLib import TTFont + +_VERSION_RE = re.compile(r"^\d{4}\.\d+$") +_DEV_VERSION = "0.0-dev" +_HEAD_ALLOWED_DIFF = frozenset({"fontRevision", "modified", "checksumAdjustment"}) +_NAME_ALLOWED_DIFF = frozenset({5}) + + +class TableMismatch(Exception): + """Raised when a verified font table differs from its expected baseline.""" + + +def validate_version(version: str) -> None: + """Raise ValueError if *version* is not a CalVer YYYY.MICRO or "0.0-dev".""" + if version == _DEV_VERSION: + return + if not _VERSION_RE.match(version): + raise ValueError( + f"Version {version!r} does not match YYYY.MICRO or {_DEV_VERSION!r}" + ) + + +def patch_font(font: TTFont, *, version: str, build_date: str) -> None: + """Patch name table version + head.fontRevision in-place.""" + validate_version(version) + + version_string = f"Version {version}; {build_date}" + name_table = font["name"] + existing = [r for r in name_table.names if r.nameID == 5] + for record in existing: + name_table.setName( + version_string, + record.nameID, + record.platformID, + record.platEncID, + record.langID, + ) + + if version != _DEV_VERSION: + font["head"].fontRevision = float(version) + + +def verify_tables_identical(*, original: TTFont, patched: TTFont) -> None: + """Raise TableMismatch if any table differs outside the allowed fields.""" + # GlyphOrder is a virtual table fontTools exposes but never serialises. + tags_original = {t for t in original.keys() if t != "GlyphOrder"} + tags_patched = {t for t in patched.keys() if t != "GlyphOrder"} + if tags_original != tags_patched: + raise TableMismatch( + f"Table tag set differs: only-original={tags_original - tags_patched}, " + f"only-patched={tags_patched - tags_original}" + ) + + for tag in sorted(tags_original): + if tag == "head": + _verify_head(original["head"], patched["head"]) + elif tag == "name": + _verify_name(original["name"], patched["name"]) + else: + _verify_generic(tag, original, patched) + + +def _verify_head(orig, patched) -> None: + for field in orig.__dict__: + if field in _HEAD_ALLOWED_DIFF: + continue + if getattr(orig, field) != getattr(patched, field): + raise TableMismatch(f"head.{field} changed unexpectedly") + + +def _verify_name(orig, patched) -> None: + def key(record): + return (record.nameID, record.platformID, record.platEncID, record.langID) + + orig_records = {key(r): str(r) for r in orig.names} + patched_records = {key(r): str(r) for r in patched.names} + + if set(orig_records) != set(patched_records): + raise TableMismatch("name table record set differs") + + for record_key, original_value in orig_records.items(): + if record_key[0] in _NAME_ALLOWED_DIFF: + continue + if patched_records[record_key] != original_value: + raise TableMismatch(f"name record {record_key} changed unexpectedly") + + +def _verify_generic(tag: str, original: TTFont, patched: TTFont) -> None: + orig_bytes = original.getTableData(tag) + patched_bytes = patched.getTableData(tag) + if orig_bytes != patched_bytes: + raise TableMismatch( + f"Table {tag!r} bytes differ ({len(orig_bytes)} vs {len(patched_bytes)})" + ) + + +def inject_js_version(source: str, *, version: str, build_date: str) -> str: + """Prepend a header comment and a runtime version constant to JS source.""" + header = ( + f"/*! xkcd-mathjax v{version} — built {build_date} — " + f"https://github.com/ipython/xkcd-font */\n" + ) + runtime = f'globalThis.XKCD_MATHJAX_VERSION = "{version}";\n' + return header + runtime + source + + +def _ttf_seconds(build_date: str) -> int: + """Seconds since 1904-01-01 for *build_date* (YYYY-MM-DD, UTC midnight).""" + epoch = datetime.datetime(1904, 1, 1, tzinfo=datetime.timezone.utc) + when = datetime.datetime.strptime(build_date, "%Y-%m-%d").replace( + tzinfo=datetime.timezone.utc + ) + return int((when - epoch).total_seconds()) + + +def write_patched_font( + in_path: pathlib.Path, + out_path: pathlib.Path, + *, + version: str, + build_date: str, +) -> TTFont: + """Patch *in_path*, verify the in-memory patched font, then save to *out_path*.""" + original = TTFont(in_path) + patched = TTFont(in_path) + patch_font(patched, version=version, build_date=build_date) + if version != _DEV_VERSION: + patched["head"].modified = _ttf_seconds(build_date) + # Verify BEFORE save: fontTools recomputes head bounds + checksums on + # compile, so post-save the in-memory `patched` no longer mirrors the + # original's head fields. + verify_tables_identical(original=original, patched=patched) + out_path.parent.mkdir(parents=True, exist_ok=True) + patched.save(out_path) + return patched + + +def regenerate_woff(patched_otf: pathlib.Path, out_path: pathlib.Path) -> None: + font = TTFont(patched_otf) + font.flavor = "woff" + font.save(out_path) + + +def regenerate_woff2(patched_otf: pathlib.Path, out_path: pathlib.Path) -> None: + font = TTFont(patched_otf) + font.flavor = "woff2" + font.save(out_path) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--font-dir", type=pathlib.Path, required=True) + parser.add_argument("--js", type=pathlib.Path, required=True) + parser.add_argument("--out-dir", type=pathlib.Path, required=True) + parser.add_argument("--build-date", default=datetime.date.today().isoformat()) + args = parser.parse_args(argv) + + validate_version(args.version) + args.out_dir.mkdir(parents=True, exist_ok=True) + + stem = f"xkcd-script-{args.version}" + otf_in = args.font_dir / "xkcd-script.otf" + ttf_in = args.font_dir / "xkcd-script.ttf" + otf_out = args.out_dir / f"{stem}.otf" + ttf_out = args.out_dir / f"{stem}.ttf" + woff_out = args.out_dir / f"{stem}.woff" + woff2_out = args.out_dir / f"{stem}.woff2" + + if not otf_in.exists(): + raise FileNotFoundError(otf_in) + if not ttf_in.exists(): + raise FileNotFoundError(ttf_in) + + write_patched_font( + otf_in, otf_out, version=args.version, build_date=args.build_date + ) + write_patched_font( + ttf_in, ttf_out, version=args.version, build_date=args.build_date + ) + regenerate_woff(otf_out, woff_out) + regenerate_woff2(otf_out, woff2_out) + + js_source = args.js.read_text(encoding="utf-8") + js_out = args.out_dir / f"xkcd-mathjax3-{args.version}.js" + js_out.write_text( + inject_js_version(js_source, version=args.version, build_date=args.build_date), + encoding="utf-8", + ) + + print(f"Wrote artifacts to {args.out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/xkcd-script/generator/requirements.in b/xkcd-script/generator/requirements.in index 83528c8..3794394 100644 --- a/xkcd-script/generator/requirements.in +++ b/xkcd-script/generator/requirements.in @@ -2,3 +2,6 @@ scikit-image scipy matplotlib parse +fonttools +brotli +pytest diff --git a/xkcd-script/generator/requirements.txt b/xkcd-script/generator/requirements.txt index 5308717..89b9e08 100644 --- a/xkcd-script/generator/requirements.txt +++ b/xkcd-script/generator/requirements.txt @@ -1,7 +1,9 @@ +brotli==1.2.0 contourpy==1.3.3 cycler==0.12.1 fonttools==4.62.1 imageio==2.37.3 +iniconfig==2.3.0 kiwisolver==1.5.0 lazy-loader==0.5 matplotlib==3.10.9 @@ -10,7 +12,10 @@ numpy==2.4.4 packaging==26.2 parse==1.22.0 pillow==12.2.0 +pluggy==1.6.0 +pygments==2.20.0 pyparsing==3.3.2 +pytest==9.1.0 python-dateutil==2.9.0.post0 scikit-image==0.26.0 scipy==1.17.1 diff --git a/xkcd-script/generator/tests/__init__.py b/xkcd-script/generator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xkcd-script/generator/tests/test_generate_release_artifacts.py b/xkcd-script/generator/tests/test_generate_release_artifacts.py new file mode 100644 index 0000000..86500b3 --- /dev/null +++ b/xkcd-script/generator/tests/test_generate_release_artifacts.py @@ -0,0 +1,102 @@ +import importlib.util +import pathlib +import sys + +import pytest + +_HERE = pathlib.Path(__file__).resolve().parent +_SCRIPT = _HERE.parent / "generate_release_artifacts.py" +_spec = importlib.util.spec_from_file_location("generate_release_artifacts", _SCRIPT) +gra = importlib.util.module_from_spec(_spec) +sys.modules["generate_release_artifacts"] = gra +_spec.loader.exec_module(gra) + + +@pytest.mark.parametrize("version", ["2026.0", "2026.12", "9999.0", "0.0-dev"]) +def test_validate_version_accepts(version): + gra.validate_version(version) + + +@pytest.mark.parametrize("version", ["", "v2026.0", "2026", "2026.0.1", "2026-0", "abc"]) +def test_validate_version_rejects(version): + with pytest.raises(ValueError): + gra.validate_version(version) + + +from fontTools.ttLib import TTFont + +_REPO_ROOT = _HERE.parents[2] +_COMMITTED_OTF = _REPO_ROOT / "xkcd-script" / "font" / "xkcd-script.otf" + + +def _load_committed_font() -> TTFont: + """Load a fresh TTFont from the committed unversioned xkcd-script.otf.""" + if not _COMMITTED_OTF.exists(): + pytest.skip(f"Committed font not present at {_COMMITTED_OTF}") + return TTFont(_COMMITTED_OTF) + + +def test_patch_font_sets_name_version(): + font = _load_committed_font() + gra.patch_font(font, version="2026.0", build_date="2026-06-16") + version_records = [r for r in font["name"].names if r.nameID == 5] + assert version_records, "real font must have at least one nameID 5 record" + for r in version_records: + assert str(r) == "Version 2026.0; 2026-06-16" + + +def test_patch_font_sets_head_fontrevision(): + font = _load_committed_font() + gra.patch_font(font, version="2026.1", build_date="2026-06-16") + assert font["head"].fontRevision == pytest.approx(2026.1, abs=1e-4) + + +def test_patch_font_dev_leaves_fontrevision(): + font = _load_committed_font() + original = font["head"].fontRevision + gra.patch_font(font, version="0.0-dev", build_date="2026-06-16") + assert font["head"].fontRevision == original + + +def test_verify_tables_identical_passes_after_patch_only(): + original = _load_committed_font() + patched = _load_committed_font() + gra.patch_font(patched, version="2026.0", build_date="2026-06-16") + gra.verify_tables_identical(original=original, patched=patched) + + +def test_verify_tables_identical_rejects_unexpected_change(): + original = _load_committed_font() + patched = _load_committed_font() + gra.patch_font(patched, version="2026.0", build_date="2026-06-16") + patched["OS/2"].usWeightClass = 999 + + with pytest.raises(gra.TableMismatch) as exc: + gra.verify_tables_identical(original=original, patched=patched) + assert "OS/2" in str(exc.value) + + +def test_verify_tables_identical_rejects_unexpected_name_change(): + original = _load_committed_font() + patched = _load_committed_font() + gra.patch_font(patched, version="2026.0", build_date="2026-06-16") + patched["name"].setName("EvilFamily", 1, 3, 1, 0x409) + + with pytest.raises(gra.TableMismatch): + gra.verify_tables_identical(original=original, patched=patched) + + +def test_inject_js_version_adds_header_and_constant(): + source = "console.log('hi');\n" + out = gra.inject_js_version(source, version="2026.0", build_date="2026-06-16") + assert out.startswith( + "/*! xkcd-mathjax v2026.0 — built 2026-06-16 — https://github.com/ipython/xkcd-font */\n" + ) + assert 'globalThis.XKCD_MATHJAX_VERSION = "2026.0";' in out + assert "console.log('hi');" in out + + +def test_inject_js_version_dev(): + out = gra.inject_js_version("x", version="0.0-dev", build_date="2026-06-16") + assert "v0.0-dev" in out + assert 'globalThis.XKCD_MATHJAX_VERSION = "0.0-dev";' in out