Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/actions/build-font/action.yml
Original file line number Diff line number Diff line change
@@ -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/**
54 changes: 5 additions & 49 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
33 changes: 33 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
205 changes: 205 additions & 0 deletions xkcd-script/generator/generate_release_artifacts.py
Original file line number Diff line number Diff line change
@@ -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())
3 changes: 3 additions & 0 deletions xkcd-script/generator/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ scikit-image
scipy
matplotlib
parse
fonttools
brotli
pytest
Loading
Loading