From e44e9e0391cf1de5116a5426da48f232ed41455c Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Wed, 13 May 2026 14:55:34 +0100 Subject: [PATCH 01/16] wip: render-text --- .github/workflows/render-text.yml | 48 ++++++ Lib/gftools/render_text/__init__.py | 158 ++++++++++++++++++ Lib/gftools/render_text/coretext_backend.py | 95 +++++++++++ .../render_text/directwrite_backend.py | 101 +++++++++++ Lib/gftools/render_text/freetype_backend.py | 94 +++++++++++ Lib/gftools/scripts/render_text.py | 107 ++++++++++++ docs/gftools-render-text/spec.md | 99 +++++++++++ pyproject.toml | 5 + 8 files changed, 707 insertions(+) create mode 100644 .github/workflows/render-text.yml create mode 100644 Lib/gftools/render_text/__init__.py create mode 100644 Lib/gftools/render_text/coretext_backend.py create mode 100644 Lib/gftools/render_text/directwrite_backend.py create mode 100644 Lib/gftools/render_text/freetype_backend.py create mode 100644 Lib/gftools/scripts/render_text.py create mode 100644 docs/gftools-render-text/spec.md diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml new file mode 100644 index 000000000..f648c4abd --- /dev/null +++ b/.github/workflows/render-text.yml @@ -0,0 +1,48 @@ +name: Render Text + +on: + workflow_dispatch: + push: + paths: + - "Lib/gftools/scripts/render_text.py" + - "Lib/gftools/render_text/**" + - ".github/workflows/render-text.yml" + +jobs: + render: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Cairo (Linux) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y libcairo2-dev + + - name: Install gftools + run: pip install -e . + + - name: Render Inconsolata waterfall + shell: bash + run: | + gftools render-text \ + 'data/test/Inconsolata[wdth,wght].ttf' \ + "The quick brown fox jumps over the lazy dog" + + - name: Upload rendered PNG + uses: actions/upload-artifact@v4 + with: + name: render-text-output-${{ matrix.os }} + path: "data/test/Inconsolata[wdth,wght].png" + if-no-files-found: error diff --git a/Lib/gftools/render_text/__init__.py b/Lib/gftools/render_text/__init__.py new file mode 100644 index 000000000..e51c1b224 --- /dev/null +++ b/Lib/gftools/render_text/__init__.py @@ -0,0 +1,158 @@ +"""Render strings from a font as a waterfall PNG. + +The rendering backend is platform-native by default: + macOS -> CoreText + Windows -> DirectWrite (implemented via Skia-Python; see + directwrite_backend.py for rationale) + Linux -> FreeType (with HarfBuzz for shaping) + +See ``docs/gftools-render-text/spec.md`` for the design spec. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Iterable, Iterator + +from fontTools.ttLib import TTFont +from PIL import Image + + +DEFAULT_PPEMS: tuple[int, ...] = (8, 10, 12, 14, 16, 20, 24, 36) +ROW_PADDING = 6 +CANVAS_PADDING = 12 + + +def default_backend() -> str: + if sys.platform == "darwin": + return "coretext" + if sys.platform == "win32": + return "directwrite" + return "freetype" + + +def parse_variations(spec: str) -> dict[str, float]: + """Parse ``wght=400,wdth=75`` into ``{"wght": 400.0, "wdth": 75.0}``.""" + result: dict[str, float] = {} + for token in spec.split(","): + token = token.strip() + if not token: + continue + if "=" not in token: + raise ValueError(f"variation token {token!r} missing '='") + axis, value = token.split("=", 1) + axis = axis.strip() + if len(axis) != 4: + raise ValueError(f"axis tag must be 4 chars, got {axis!r}") + result[axis] = float(value.strip()) + return result + + +def is_variable(font_path: Path) -> bool: + with TTFont(font_path) as font: + return "fvar" in font + + +def iter_fvar_instances( + font_path: Path, +) -> Iterator[tuple[str, dict[str, float]]]: + """Yield ``(subfamily_name, {axis_tag: value})`` for each fvar instance.""" + with TTFont(font_path) as font: + if "fvar" not in font: + return + fvar = font["fvar"] + name = font["name"] + for inst in fvar.instances: + label = name.getDebugName(inst.subfamilyNameID) or "Instance" + yield label, dict(inst.coordinates) + + +_FILENAME_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+") + + +def _filename_safe(s: str) -> str: + return _FILENAME_SAFE_RE.sub("", s) + + +def _format_value(value: float) -> str: + return str(int(value)) if float(value).is_integer() else f"{value:g}" + + +def _format_variations_suffix(variations: dict[str, float]) -> str: + return "-".join(f"{tag}{_format_value(val)}" for tag, val in variations.items()) + + +def output_path_for( + font_path: Path, + *, + variations: dict[str, float] | None = None, + output: str | Path | None = None, +) -> Path: + if output is not None: + return Path(output) + stem = font_path.stem + if variations: + stem = f"{stem}-{_format_variations_suffix(variations)}" + return font_path.parent / f"{stem}.png" + + +def output_dir_for_all( + font_path: Path, *, output_dir: str | Path | None = None +) -> Path: + if output_dir is not None: + return Path(output_dir) + return font_path.parent / f"{font_path.stem}_imgs" + + +def output_path_for_instance( + font_path: Path, instance_name: str, output_dir: Path +) -> Path: + return output_dir / f"{font_path.stem}-{_filename_safe(instance_name)}.png" + + +def render_waterfall( + font_path: Path, + text: str, + *, + ppems: Iterable[int] = DEFAULT_PPEMS, + variations: dict[str, float] | None = None, + backend: str | None = None, +) -> Image.Image: + """Render ``text`` from ``font_path`` at each ppem and stack vertically.""" + backend = backend or default_backend() + render_row = _load_backend(backend) + rows = [render_row(font_path, text, ppem, variations) for ppem in ppems] + return _compose_waterfall(rows) + + +def _load_backend(name: str): + if name == "freetype": + from . import freetype_backend + + return freetype_backend.render_row + if name == "coretext": + from . import coretext_backend + + return coretext_backend.render_row + if name == "directwrite": + from . import directwrite_backend + + return directwrite_backend.render_row + raise ValueError(f"unknown backend {name!r}") + + +def _compose_waterfall(rows: list[Image.Image]) -> Image.Image: + width = max((row.width for row in rows), default=0) + CANVAS_PADDING * 2 + height = ( + sum(row.height for row in rows) + + ROW_PADDING * (len(rows) - 1 if rows else 0) + + CANVAS_PADDING * 2 + ) + canvas = Image.new("RGB", (width, height), "white") + y = CANVAS_PADDING + for row in rows: + canvas.paste(row, (CANVAS_PADDING, y)) + y += row.height + ROW_PADDING + return canvas diff --git a/Lib/gftools/render_text/coretext_backend.py b/Lib/gftools/render_text/coretext_backend.py new file mode 100644 index 000000000..8eb90d007 --- /dev/null +++ b/Lib/gftools/render_text/coretext_backend.py @@ -0,0 +1,95 @@ +"""CoreText rendering backend (macOS). + +Uses CoreText for shaping and CGBitmapContext for rasterisation. Runs only +on macOS — pyobjc-framework-CoreText / pyobjc-framework-Quartz must be +installed (declared as platform-conditional deps in pyproject.toml). +""" + +from __future__ import annotations + +from pathlib import Path + +from PIL import Image + +import CoreText +import Quartz +from Foundation import NSData + +PADDING = 2 + + +def render_row( + font_path: Path, + text: str, + ppem: int, + variations: dict[str, float] | None = None, +) -> Image.Image: + ct_font = _make_ct_font(font_path, ppem, variations) + line = _make_line(ct_font, text) + + width_d, ascent, descent, _leading = _measure(line) + width = max(int(width_d) + PADDING * 2, 1) + height = max(int(ascent + descent) + PADDING * 2, 1) + baseline_y = descent + PADDING + + ctx = _gray_bitmap_context(width, height) + Quartz.CGContextSetGrayFillColor(ctx, 1.0, 1.0) + Quartz.CGContextFillRect(ctx, ((0, 0), (width, height))) + Quartz.CGContextSetGrayFillColor(ctx, 0.0, 1.0) + Quartz.CGContextSetTextPosition(ctx, PADDING, baseline_y) + CoreText.CTLineDraw(line, ctx) + + pixels = Quartz.CGBitmapContextGetData(ctx).as_buffer(width * height) + img = Image.frombytes("L", (width, height), bytes(pixels)) + return img.convert("RGB") + + +def _make_ct_font(font_path: Path, ppem: int, variations: dict[str, float] | None): + data = NSData.dataWithContentsOfFile_(str(font_path)) + if data is None: + raise FileNotFoundError(font_path) + provider = Quartz.CGDataProviderCreateWithCFData(data) + cg_font = Quartz.CGFontCreateWithDataProvider(provider) + if cg_font is None: + raise RuntimeError(f"CoreText could not load font: {font_path}") + ct_font = CoreText.CTFontCreateWithGraphicsFont(cg_font, ppem, None, None) + if not variations: + return ct_font + return _apply_variations(ct_font, variations, ppem) + + +def _apply_variations(ct_font, variations: dict[str, float], ppem: int): + var_attr = {_tag_to_int(tag): float(value) for tag, value in variations.items()} + descriptor = CoreText.CTFontCopyFontDescriptor(ct_font) + new_desc = CoreText.CTFontDescriptorCreateCopyWithAttributes( + descriptor, {CoreText.kCTFontVariationAttribute: var_attr} + ) + return CoreText.CTFontCreateWithFontDescriptor(new_desc, ppem, None) + + +def _make_line(ct_font, text: str): + attrs = {CoreText.kCTFontAttributeName: ct_font} + astr = CoreText.CFAttributedStringCreate(None, text, attrs) + return CoreText.CTLineCreateWithAttributedString(astr) + + +def _measure(line) -> tuple[float, float, float, float]: + ascent, descent, leading = 0.0, 0.0, 0.0 + width = CoreText.CTLineGetTypographicBounds(line, None, None, None) + if isinstance(width, tuple): + width, ascent, descent, leading = width + return width, ascent, descent, leading + + +def _gray_bitmap_context(width: int, height: int): + color_space = Quartz.CGColorSpaceCreateDeviceGray() + return Quartz.CGBitmapContextCreate( + None, width, height, 8, width, color_space, Quartz.kCGImageAlphaNone + ) + + +def _tag_to_int(tag: str) -> int: + if len(tag) != 4: + raise ValueError(f"axis tag must be 4 chars: {tag!r}") + b = tag.encode("ascii") + return (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3] diff --git a/Lib/gftools/render_text/directwrite_backend.py b/Lib/gftools/render_text/directwrite_backend.py new file mode 100644 index 000000000..eb74fd677 --- /dev/null +++ b/Lib/gftools/render_text/directwrite_backend.py @@ -0,0 +1,101 @@ +"""DirectWrite rendering backend (Windows), implemented via Skia-Python. + +We *don't* call DirectWrite directly through comtypes. The rationale: + +- DirectWrite is a deep COM hierarchy. A direct comtypes implementation + needs ~400 lines of vtable scaffolding (interface declarations for + IDWriteFactory, IDWriteTextAnalyzer, IDWriteGdiInterop, + IDWriteBitmapRenderTarget, plus DWRITE structs and GDI plumbing for + pixel readback) before any actual rendering happens. +- Skia delegates to ``SkFontMgr_DirectWrite`` and ``SkScalerContext_DW`` + on Windows, so font loading and glyph rasterisation still hit the + platform-native DirectWrite stack — Skia just adds a thin + gamma/contrast layer in front of it. The platform-rendering signal we + care about (ClearType, subpixel positioning, DWrite hinting) is + preserved. +- Symmetrically, Skia uses CoreText on macOS and FreeType+FontConfig on + Linux, so anyone wanting a uniformly Skia-based matrix could use this + backend on all three platforms via ``--backend directwrite`` (though + ``coretext`` and ``freetype`` remain the platform defaults). + +Fidelity tradeoff: Skia's default text path uses HarfBuzz for shaping, +not DirectWrite's shaper. For Latin pangrams this is invisible; for a +complex-script regression test it would matter and we'd need to +revisit. +""" + +from __future__ import annotations + +from pathlib import Path + +from PIL import Image + +try: + import skia +except ImportError as e: # pragma: no cover + raise ImportError( + "DirectWrite backend requires skia-python. " + "Install with: pip install skia-python" + ) from e + +PADDING = 4 + + +def render_row( + font_path: Path, + text: str, + ppem: int, + variations: dict[str, float] | None = None, +) -> Image.Image: + typeface = skia.Typeface.MakeFromFile(str(font_path)) + if typeface is None: + raise RuntimeError(f"Skia could not load font: {font_path}") + if variations: + typeface = _apply_variations(typeface, variations) + + font = skia.Font(typeface, float(ppem)) + font.setSubpixel(True) + + blob = skia.TextBlob.MakeFromString(text, font) + metrics = font.getMetrics() + ascent = -metrics.fAscent + descent = metrics.fDescent + bounds = blob.bounds() + + width = max(int(bounds.width()) + PADDING * 2, 1) + height = max(int(ascent + descent) + PADDING, 1) + baseline_y = int(ascent) + PADDING // 2 + + surface = skia.Surface(width, height) + with surface as canvas: + canvas.clear(skia.ColorWHITE) + paint = skia.Paint() + paint.setColor(skia.ColorBLACK) + paint.setAntiAlias(True) + canvas.drawTextBlob(blob, PADDING, baseline_y, paint) + + info = skia.ImageInfo.Make( + width, height, skia.kRGBA_8888_ColorType, skia.kUnpremul_AlphaType + ) + buffer = bytearray(width * height * 4) + if not surface.readPixels(info, buffer, width * 4, 0, 0): + raise RuntimeError("Skia surface.readPixels failed") + return Image.frombytes("RGBA", (width, height), bytes(buffer)).convert("RGB") + + +def _apply_variations(typeface, variations: dict[str, float]): + Coord = skia.FontArguments.VariationPosition.Coordinate + coord_list = skia.FontArguments.VariationPosition.Coordinates( + [Coord(_tag_to_int(tag), float(val)) for tag, val in variations.items()] + ) + pos = skia.FontArguments.VariationPosition(coord_list) + args = skia.FontArguments() + args.setVariationDesignPosition(pos) + return typeface.makeClone(args) + + +def _tag_to_int(tag: str) -> int: + if len(tag) != 4: + raise ValueError(f"axis tag must be 4 chars: {tag!r}") + b = tag.encode("ascii") + return (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3] diff --git a/Lib/gftools/render_text/freetype_backend.py b/Lib/gftools/render_text/freetype_backend.py new file mode 100644 index 000000000..706a31de3 --- /dev/null +++ b/Lib/gftools/render_text/freetype_backend.py @@ -0,0 +1,94 @@ +"""FreeType + HarfBuzz rendering backend. + +HarfBuzz handles shaping; FreeType rasterises each glyph. The two are kept +in sync on variable fonts by applying the variation coords to both. +""" + +from __future__ import annotations + +from pathlib import Path + +import freetype +import uharfbuzz as hb +from fontTools.ttLib import TTFont +from PIL import Image + + +def render_row( + font_path: Path, + text: str, + ppem: int, + variations: dict[str, float] | None = None, +) -> Image.Image: + glyph_infos, glyph_positions = _shape(font_path, text, ppem, variations) + ft_face = _make_ft_face(font_path, ppem, variations) + + metrics = ft_face.size + ascender_px = metrics.ascender // 64 + descender_px = metrics.descender // 64 + line_height = ascender_px - descender_px + baseline_y = ascender_px + + pen_x = 0 + glyphs: list[tuple[Image.Image, int, int]] = [] + for info, pos in zip(glyph_infos, glyph_positions): + ft_face.load_glyph(info.codepoint, freetype.FT_LOAD_RENDER) + slot = ft_face.glyph + bm = slot.bitmap + if bm.width and bm.rows: + glyph_img = Image.frombytes("L", (bm.width, bm.rows), bytes(bm.buffer)) + x = pen_x + (pos.x_offset // 64) + slot.bitmap_left + y = baseline_y - (pos.y_offset // 64) - slot.bitmap_top + glyphs.append((glyph_img, x, y)) + pen_x += pos.x_advance // 64 + + width = max(pen_x, 1) + 4 + height = max(line_height, 1) + 4 + canvas = Image.new("L", (width, height), 255) + for glyph_img, x, y in glyphs: + ink = Image.new("L", glyph_img.size, 0) + canvas.paste(ink, (x + 2, y + 2), mask=glyph_img) + return canvas.convert("RGB") + + +def _shape( + font_path: Path, + text: str, + ppem: int, + variations: dict[str, float] | None, +): + blob = hb.Blob.from_file_path(str(font_path)) + face = hb.Face(blob) + font = hb.Font(face) + font.scale = (ppem * 64, ppem * 64) + if variations: + font.set_variations(variations) + buf = hb.Buffer() + buf.add_str(text) + buf.guess_segment_properties() + hb.shape(font, buf) + return buf.glyph_infos, buf.glyph_positions + + +def _make_ft_face( + font_path: Path, ppem: int, variations: dict[str, float] | None +) -> freetype.Face: + face = freetype.Face(str(font_path)) + face.set_pixel_sizes(0, ppem) + if variations: + coords = _design_coords_in_axis_order(font_path, variations) + if coords is not None: + face.set_var_design_coords(coords) + return face + + +def _design_coords_in_axis_order( + font_path: Path, variations: dict[str, float] +) -> list[float] | None: + with TTFont(font_path) as font: + if "fvar" not in font: + return None + return [ + variations.get(axis.axisTag, axis.defaultValue) + for axis in font["fvar"].axes + ] diff --git a/Lib/gftools/scripts/render_text.py b/Lib/gftools/scripts/render_text.py new file mode 100644 index 000000000..d0544aec3 --- /dev/null +++ b/Lib/gftools/scripts/render_text.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# +# Copyright 2026 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Render a string from a font as a waterfall PNG. + +The rendering backend is selected from the host platform by default +(CoreText on macOS, DirectWrite on Windows, FreeType on Linux). Use +``--backend`` to override. +""" + +from __future__ import annotations + +import sys +from argparse import ArgumentParser +from pathlib import Path + +from gftools.render_text import ( + default_backend, + is_variable, + iter_fvar_instances, + output_dir_for_all, + output_path_for, + output_path_for_instance, + parse_variations, + render_waterfall, +) + + +def main(args=None): + parser = ArgumentParser(description=__doc__) + parser.add_argument("font", type=Path, help="Path to a .ttf/.otf font") + parser.add_argument("text", help="String to render") + parser.add_argument( + "-o", + "--output", + help=( + "Output PNG path. With --all, treated as an output directory. " + "Defaults to .png (or _imgs/ for --all)." + ), + ) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--variations", + help='Variation location, e.g. "wght=400,wdth=75". Default instance if omitted.', + ) + group.add_argument( + "--all", + action="store_true", + help="Render one image per fvar instance.", + ) + parser.add_argument( + "--backend", + choices=("coretext", "directwrite", "freetype"), + default=None, + help="Rendering backend. Defaults to the platform-native backend.", + ) + + opts = parser.parse_args(args) + backend = opts.backend or default_backend() + + if opts.all: + _render_all(opts.font, opts.text, opts.output, backend) + else: + variations = parse_variations(opts.variations) if opts.variations else None + out = output_path_for(opts.font, variations=variations, output=opts.output) + img = render_waterfall( + opts.font, opts.text, variations=variations, backend=backend + ) + img.save(out) + print(out) + + +def _render_all(font: Path, text: str, output: str | None, backend: str) -> None: + if not is_variable(font): + print( + f"warning: {font} is a static font — rendering default style only.", + file=sys.stderr, + ) + out = output_path_for(font, output=output) + img = render_waterfall(font, text, backend=backend) + img.save(out) + print(out) + return + + out_dir = output_dir_for_all(font, output_dir=output) + out_dir.mkdir(parents=True, exist_ok=True) + for instance_name, location in iter_fvar_instances(font): + out = output_path_for_instance(font, instance_name, out_dir) + img = render_waterfall(font, text, variations=location, backend=backend) + img.save(out) + print(out) + + +if __name__ == "__main__": + main() diff --git a/docs/gftools-render-text/spec.md b/docs/gftools-render-text/spec.md new file mode 100644 index 000000000..5bb282c89 --- /dev/null +++ b/docs/gftools-render-text/spec.md @@ -0,0 +1,99 @@ +# gftools render-text — Spec + +**Status:** Design — not yet implemented. + +## Purpose + +Render a string from a font as a waterfall PNG, using the platform-native text rendering backend (CoreText on macOS, DirectWrite on Windows, FreeType on Linux). The motivating use case is a GitHub Actions matrix job that produces one image per platform so rendering regressions can be diff'd across backends. + +## Synopsis + +``` +gftools render-text FONT TEXT [-o OUTPUT] [--variations AXES | --all] [--backend BACKEND] +``` + +## Examples + +``` +gftools render-text Roboto-Regular.ttf "The quick brown fox jumps" +gftools render-text Roboto[wght].ttf "Hamburgefonstiv" --variations wght=400,wdth=75 +gftools render-text Roboto[wght].ttf "Hamburgefonstiv" --all +gftools render-text Roboto-Regular.ttf "..." --backend freetype +``` + +## Output + +Default output is a waterfall PNG containing the string rendered at the following ppem sizes, stacked vertically: + +``` +8, 10, 12, 14, 16, 20, 24, 36 +``` + +### Output filename + +If `-o` is **not** provided, the output filename is derived from the input font: + +| Invocation | Output | +|---|---| +| `Roboto-Regular.ttf` (default instance) | `Roboto-Regular.png` | +| `Roboto[wght].ttf --variations wght=400,wdth=75` | `Roboto-wght400-wdth75.png` | +| `Roboto[wght].ttf --all` | `Roboto[wght]_imgs/Roboto-Regular.png`, `Roboto[wght]_imgs/Roboto-Bold.png`, `Roboto[wght]_imgs/Roboto-SemiBoldCondensed.png`, … | + +For `--all`, the default output directory is `_imgs/` next to the font. Per-image filenames inside the directory take the suffix from each fvar instance's subfamily name (read from the `fvar` instance records), with non-filesystem-safe characters stripped (spaces removed, slashes etc. replaced). + +If `-o` **is** provided with `--all`, it is treated as the output directory (created if needed) and per-instance filenames are generated inside it. + +## Flags + +### `-o, --output PATH` + +Optional. Output file path (or directory when used with `--all`). Defaults to `.png` next to the font. + +### `--variations AXES` + +Render at a specific variable-font location. Format: `axis=value` pairs, comma-separated, no spaces: + +``` +--variations wght=400,wdth=75 +``` + +Mutually exclusive with `--all`. If the font is static, this flag is an error. + +### `--all` + +Render one image per fvar instance defined in the font. + +- Mutually exclusive with `--variations`. +- On a **static** font, prints a warning to stderr ("font is static — rendering default style only") and renders a single default image. Exit code is 0. + +### `--backend {coretext,directwrite,freetype}` + +Force a specific rendering backend. If omitted, the backend is selected from the host platform: + +| Platform | Default backend | +|---|---| +| macOS | CoreText | +| Windows | DirectWrite | +| Linux | FreeType | + +The override is primarily for (a) developing/testing the FreeType path on a Mac, and (b) letting CI assert which backend ran rather than inferring from `runs-on`. + +## Shaping + +**Native shaping per backend.** Each backend uses its own shaper: + +- CoreText shapes via CoreText. +- DirectWrite shapes via DirectWrite. +- FreeType has no shaper, so the FreeType path uses HarfBuzz. + +This tests the full platform stack (what users actually see end-to-end). The tradeoff is that a shaping bug and a rasterizer bug are indistinguishable in the diff — accepted for the v1 scope. + +A future `--shaper harfbuzz` flag could force HarfBuzz everywhere to isolate the rasterizer, but is out of scope for v1. + +## Out of scope (v1) + +- Custom colors / themes (default is black-on-white). +- Custom DPI / device scaling. +- Multi-line text wrapping. +- PDF or SVG output. +- A `--shaper` override (see above). diff --git a/pyproject.toml b/pyproject.toml index bd9cc2f33..60fba13af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,10 @@ dependencies = [ 'jinja2', 'fontFeatures', 'vharfbuzz', + 'freetype-py', + 'pyobjc-framework-CoreText; sys_platform == "darwin"', + 'pyobjc-framework-Quartz; sys_platform == "darwin"', + 'skia-python; sys_platform == "win32"', 'nanoemoji>=0.15.0', 'beautifulsoup4', 'rich', @@ -151,6 +155,7 @@ gftools-push-stats = "gftools.scripts.push_stats:main" gftools-push-status = "gftools.scripts.push_status:main" gftools-qa = "gftools.scripts.qa:main" gftools-rangify = "gftools.scripts.rangify:main" +gftools-render-text = "gftools.scripts.render_text:main" gftools-rename-font = "gftools.scripts.rename_font:main" gftools-rename-glyphs = "gftools.scripts.rename_glyphs:main" gftools-remap-font = "gftools.scripts.remap_font:main" From 8a5a30f45121bda001e70055d38e3a0c7d6bd4ea Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Wed, 13 May 2026 15:05:12 +0100 Subject: [PATCH 02/16] render-text CI: write to bracket-free path so upload-artifact glob can find it --- .github/workflows/render-text.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml index f648c4abd..99fb14913 100644 --- a/.github/workflows/render-text.yml +++ b/.github/workflows/render-text.yml @@ -37,6 +37,7 @@ jobs: shell: bash run: | gftools render-text \ + -o inconsolata.png \ 'data/test/Inconsolata[wdth,wght].ttf' \ "The quick brown fox jumps over the lazy dog" @@ -44,5 +45,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: render-text-output-${{ matrix.os }} - path: "data/test/Inconsolata[wdth,wght].png" + path: inconsolata.png if-no-files-found: error From b738df95c31587b465b041d49061663657e288f9 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Wed, 13 May 2026 15:27:39 +0100 Subject: [PATCH 03/16] render-text CI: pin python to 3.13 --- .github/workflows/render-text.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml index 99fb14913..611afcc69 100644 --- a/.github/workflows/render-text.yml +++ b/.github/workflows/render-text.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install Cairo (Linux) if: matrix.os == 'ubuntu-latest' From ae73797dd58b48e4d2fce02c0510b00416256749 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Wed, 13 May 2026 15:30:16 +0100 Subject: [PATCH 04/16] render-text CI: include platform in image filename --- .github/workflows/render-text.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml index 611afcc69..4618582c7 100644 --- a/.github/workflows/render-text.yml +++ b/.github/workflows/render-text.yml @@ -37,7 +37,7 @@ jobs: shell: bash run: | gftools render-text \ - -o inconsolata.png \ + -o inconsolata-${{ matrix.os }}.png \ 'data/test/Inconsolata[wdth,wght].ttf' \ "The quick brown fox jumps over the lazy dog" @@ -45,5 +45,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: render-text-output-${{ matrix.os }} - path: inconsolata.png + path: inconsolata-${{ matrix.os }}.png if-no-files-found: error From 7df16f91b847fa53e7cff09a21267536688e93c3 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Wed, 13 May 2026 15:56:29 +0100 Subject: [PATCH 05/16] render-text CI: bump actions to node 24 majors (checkout@v6, setup-python@v6, upload-artifact@v7) --- .github/workflows/render-text.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml index 4618582c7..c18af5560 100644 --- a/.github/workflows/render-text.yml +++ b/.github/workflows/render-text.yml @@ -16,13 +16,13 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -42,7 +42,7 @@ jobs: "The quick brown fox jumps over the lazy dog" - name: Upload rendered PNG - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: render-text-output-${{ matrix.os }} path: inconsolata-${{ matrix.os }}.png From 69dcde4610a365585e14d55798bf2aaf90fa166c Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Wed, 13 May 2026 15:59:00 +0100 Subject: [PATCH 06/16] render-text: stamp platform + backend in bottom-left of each image --- Lib/gftools/render_text/__init__.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/Lib/gftools/render_text/__init__.py b/Lib/gftools/render_text/__init__.py index e51c1b224..d3cf5c004 100644 --- a/Lib/gftools/render_text/__init__.py +++ b/Lib/gftools/render_text/__init__.py @@ -11,13 +11,14 @@ from __future__ import annotations +import platform import re import sys from pathlib import Path from typing import Iterable, Iterator from fontTools.ttLib import TTFont -from PIL import Image +from PIL import Image, ImageDraw, ImageFont DEFAULT_PPEMS: tuple[int, ...] = (8, 10, 12, 14, 16, 20, 24, 36) @@ -124,7 +125,27 @@ def render_waterfall( backend = backend or default_backend() render_row = _load_backend(backend) rows = [render_row(font_path, text, ppem, variations) for ppem in ppems] - return _compose_waterfall(rows) + canvas = _compose_waterfall(rows) + _annotate_platform(canvas, backend) + return canvas + + +_BACKEND_DISPLAY = { + "coretext": "CoreText", + "directwrite": "DirectWrite", + "freetype": "FreeType", +} + + +def _annotate_platform(canvas: Image.Image, backend: str) -> None: + label = f"{platform.system()} / {_BACKEND_DISPLAY.get(backend, backend)}" + font = ImageFont.load_default(size=12) + draw = ImageDraw.Draw(canvas) + bbox = draw.textbbox((0, 0), label, font=font) + text_height = bbox[3] - bbox[1] + x = CANVAS_PADDING // 2 + y = canvas.height - text_height - CANVAS_PADDING // 2 + draw.text((x, y), label, fill=(128, 128, 128), font=font) def _load_backend(name: str): From 23cc48c0a77625d6a63251b98c6f5f2325d3b9d9 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Wed, 13 May 2026 16:20:13 +0100 Subject: [PATCH 07/16] render-text: normalize row height across backends from OS/2 typo metrics --- Lib/gftools/render_text/__init__.py | 36 ++++++++++++++++++- Lib/gftools/render_text/coretext_backend.py | 19 +++++----- .../render_text/directwrite_backend.py | 17 ++++----- Lib/gftools/render_text/freetype_backend.py | 14 +++----- 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/Lib/gftools/render_text/__init__.py b/Lib/gftools/render_text/__init__.py index d3cf5c004..5bf82cd6a 100644 --- a/Lib/gftools/render_text/__init__.py +++ b/Lib/gftools/render_text/__init__.py @@ -24,6 +24,9 @@ DEFAULT_PPEMS: tuple[int, ...] = (8, 10, 12, 14, 16, 20, 24, 36) ROW_PADDING = 6 CANVAS_PADDING = 12 +# Per-row vertical padding above ascender and below descender. Shared by all +# backends so row heights match exactly across platforms. +ROW_VPAD = 2 def default_backend() -> str: @@ -124,12 +127,43 @@ def render_waterfall( """Render ``text`` from ``font_path`` at each ppem and stack vertically.""" backend = backend or default_backend() render_row = _load_backend(backend) - rows = [render_row(font_path, text, ppem, variations) for ppem in ppems] + ascent_du, descent_du, upem = _font_line_metrics(font_path) + rows = [] + for ppem in ppems: + ascent_px = int(round(ascent_du * ppem / upem)) + descent_px = int(round(descent_du * ppem / upem)) + target_h = ascent_px + descent_px + ROW_VPAD * 2 + baseline_y = ascent_px + ROW_VPAD + rows.append( + render_row( + font_path, + text, + ppem, + variations, + target_height=target_h, + baseline_y=baseline_y, + ) + ) canvas = _compose_waterfall(rows) _annotate_platform(canvas, backend) return canvas +def _font_line_metrics(font_path: Path) -> tuple[int, int, int]: + """Return ``(ascent_du, descent_du, upem)`` from OS/2 typo metrics. + + All backends use this single source so row heights match across platforms. + Falls back to ``hhea`` for fonts without an OS/2 table. + """ + with TTFont(font_path) as font: + upem = font["head"].unitsPerEm + os2 = font.get("OS/2") + if os2 is not None: + return os2.sTypoAscender, -os2.sTypoDescender, upem + hhea = font["hhea"] + return hhea.ascender, -hhea.descender, upem + + _BACKEND_DISPLAY = { "coretext": "CoreText", "directwrite": "DirectWrite", diff --git a/Lib/gftools/render_text/coretext_backend.py b/Lib/gftools/render_text/coretext_backend.py index 8eb90d007..97d664864 100644 --- a/Lib/gftools/render_text/coretext_backend.py +++ b/Lib/gftools/render_text/coretext_backend.py @@ -23,24 +23,27 @@ def render_row( text: str, ppem: int, variations: dict[str, float] | None = None, + *, + target_height: int, + baseline_y: int, ) -> Image.Image: ct_font = _make_ct_font(font_path, ppem, variations) line = _make_line(ct_font, text) - width_d, ascent, descent, _leading = _measure(line) + width_d, _ascent, _descent, _leading = _measure(line) width = max(int(width_d) + PADDING * 2, 1) - height = max(int(ascent + descent) + PADDING * 2, 1) - baseline_y = descent + PADDING + # CG's drawing coords are bottom-left origin; convert baseline-from-top. + baseline_y_cg = target_height - baseline_y - ctx = _gray_bitmap_context(width, height) + ctx = _gray_bitmap_context(width, target_height) Quartz.CGContextSetGrayFillColor(ctx, 1.0, 1.0) - Quartz.CGContextFillRect(ctx, ((0, 0), (width, height))) + Quartz.CGContextFillRect(ctx, ((0, 0), (width, target_height))) Quartz.CGContextSetGrayFillColor(ctx, 0.0, 1.0) - Quartz.CGContextSetTextPosition(ctx, PADDING, baseline_y) + Quartz.CGContextSetTextPosition(ctx, PADDING, baseline_y_cg) CoreText.CTLineDraw(line, ctx) - pixels = Quartz.CGBitmapContextGetData(ctx).as_buffer(width * height) - img = Image.frombytes("L", (width, height), bytes(pixels)) + pixels = Quartz.CGBitmapContextGetData(ctx).as_buffer(width * target_height) + img = Image.frombytes("L", (width, target_height), bytes(pixels)) return img.convert("RGB") diff --git a/Lib/gftools/render_text/directwrite_backend.py b/Lib/gftools/render_text/directwrite_backend.py index eb74fd677..eb179ffb7 100644 --- a/Lib/gftools/render_text/directwrite_backend.py +++ b/Lib/gftools/render_text/directwrite_backend.py @@ -46,6 +46,9 @@ def render_row( text: str, ppem: int, variations: dict[str, float] | None = None, + *, + target_height: int, + baseline_y: int, ) -> Image.Image: typeface = skia.Typeface.MakeFromFile(str(font_path)) if typeface is None: @@ -57,16 +60,10 @@ def render_row( font.setSubpixel(True) blob = skia.TextBlob.MakeFromString(text, font) - metrics = font.getMetrics() - ascent = -metrics.fAscent - descent = metrics.fDescent bounds = blob.bounds() - width = max(int(bounds.width()) + PADDING * 2, 1) - height = max(int(ascent + descent) + PADDING, 1) - baseline_y = int(ascent) + PADDING // 2 - surface = skia.Surface(width, height) + surface = skia.Surface(width, target_height) with surface as canvas: canvas.clear(skia.ColorWHITE) paint = skia.Paint() @@ -75,12 +72,12 @@ def render_row( canvas.drawTextBlob(blob, PADDING, baseline_y, paint) info = skia.ImageInfo.Make( - width, height, skia.kRGBA_8888_ColorType, skia.kUnpremul_AlphaType + width, target_height, skia.kRGBA_8888_ColorType, skia.kUnpremul_AlphaType ) - buffer = bytearray(width * height * 4) + buffer = bytearray(width * target_height * 4) if not surface.readPixels(info, buffer, width * 4, 0, 0): raise RuntimeError("Skia surface.readPixels failed") - return Image.frombytes("RGBA", (width, height), bytes(buffer)).convert("RGB") + return Image.frombytes("RGBA", (width, target_height), bytes(buffer)).convert("RGB") def _apply_variations(typeface, variations: dict[str, float]): diff --git a/Lib/gftools/render_text/freetype_backend.py b/Lib/gftools/render_text/freetype_backend.py index 706a31de3..425ec9eb7 100644 --- a/Lib/gftools/render_text/freetype_backend.py +++ b/Lib/gftools/render_text/freetype_backend.py @@ -19,16 +19,13 @@ def render_row( text: str, ppem: int, variations: dict[str, float] | None = None, + *, + target_height: int, + baseline_y: int, ) -> Image.Image: glyph_infos, glyph_positions = _shape(font_path, text, ppem, variations) ft_face = _make_ft_face(font_path, ppem, variations) - metrics = ft_face.size - ascender_px = metrics.ascender // 64 - descender_px = metrics.descender // 64 - line_height = ascender_px - descender_px - baseline_y = ascender_px - pen_x = 0 glyphs: list[tuple[Image.Image, int, int]] = [] for info, pos in zip(glyph_infos, glyph_positions): @@ -43,11 +40,10 @@ def render_row( pen_x += pos.x_advance // 64 width = max(pen_x, 1) + 4 - height = max(line_height, 1) + 4 - canvas = Image.new("L", (width, height), 255) + canvas = Image.new("L", (width, target_height), 255) for glyph_img, x, y in glyphs: ink = Image.new("L", glyph_img.size, 0) - canvas.paste(ink, (x + 2, y + 2), mask=glyph_img) + canvas.paste(ink, (x + 2, y), mask=glyph_img) return canvas.convert("RGB") From a00a1eb2a8cb62291e98d0c2ea930db4a5f5c13e Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 14 May 2026 10:17:55 +0100 Subject: [PATCH 08/16] render-text: move backend deps to [qa] extra --- .github/workflows/render-text.yml | 2 +- pyproject.toml | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml index c18af5560..e77a394a6 100644 --- a/.github/workflows/render-text.yml +++ b/.github/workflows/render-text.yml @@ -31,7 +31,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y libcairo2-dev - name: Install gftools - run: pip install -e . + run: pip install -e '.[qa]' - name: Render Inconsolata waterfall shell: bash diff --git a/pyproject.toml b/pyproject.toml index 60fba13af..61afb2044 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,10 +59,6 @@ dependencies = [ 'jinja2', 'fontFeatures', 'vharfbuzz', - 'freetype-py', - 'pyobjc-framework-CoreText; sys_platform == "darwin"', - 'pyobjc-framework-Quartz; sys_platform == "darwin"', - 'skia-python; sys_platform == "win32"', 'nanoemoji>=0.15.0', 'beautifulsoup4', 'rich', @@ -85,6 +81,11 @@ qa = [ "fontbakery[googlefonts]", "diffenator2>=0.5.0", # earlier versions cap unicodedata2<16 and import pkg_resources "pycairo", # needed for fontTools varLib.interpolatable --pdf + # gftools-render-text backends + "freetype-py", + 'pyobjc-framework-CoreText; sys_platform == "darwin"', + 'pyobjc-framework-Quartz; sys_platform == "darwin"', + 'skia-python; sys_platform == "win32"', ] test = [ "black ==24.10.0", From 064474a6ab78110369a29ae65dc3024fe110163c Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 14 May 2026 10:19:54 +0100 Subject: [PATCH 09/16] render-text: friendlier error message when QA deps are missing --- Lib/gftools/render_text/coretext_backend.py | 13 ++++++++++--- Lib/gftools/render_text/directwrite_backend.py | 11 ++++++----- Lib/gftools/render_text/freetype_backend.py | 9 ++++++++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/Lib/gftools/render_text/coretext_backend.py b/Lib/gftools/render_text/coretext_backend.py index 97d664864..44031e101 100644 --- a/Lib/gftools/render_text/coretext_backend.py +++ b/Lib/gftools/render_text/coretext_backend.py @@ -11,9 +11,16 @@ from PIL import Image -import CoreText -import Quartz -from Foundation import NSData +try: + import CoreText + import Quartz + from Foundation import NSData +except ModuleNotFoundError: + raise ModuleNotFoundError( + "gftools was installed without the QA dependencies. To install the " + "dependencies, see the ReadMe, " + "https://github.com/googlefonts/gftools#installation" + ) PADDING = 2 diff --git a/Lib/gftools/render_text/directwrite_backend.py b/Lib/gftools/render_text/directwrite_backend.py index eb179ffb7..5490b4508 100644 --- a/Lib/gftools/render_text/directwrite_backend.py +++ b/Lib/gftools/render_text/directwrite_backend.py @@ -32,11 +32,12 @@ try: import skia -except ImportError as e: # pragma: no cover - raise ImportError( - "DirectWrite backend requires skia-python. " - "Install with: pip install skia-python" - ) from e +except ModuleNotFoundError: + raise ModuleNotFoundError( + "gftools was installed without the QA dependencies. To install the " + "dependencies, see the ReadMe, " + "https://github.com/googlefonts/gftools#installation" + ) PADDING = 4 diff --git a/Lib/gftools/render_text/freetype_backend.py b/Lib/gftools/render_text/freetype_backend.py index 425ec9eb7..b6279ebd2 100644 --- a/Lib/gftools/render_text/freetype_backend.py +++ b/Lib/gftools/render_text/freetype_backend.py @@ -8,7 +8,14 @@ from pathlib import Path -import freetype +try: + import freetype +except ModuleNotFoundError: + raise ModuleNotFoundError( + "gftools was installed without the QA dependencies. To install the " + "dependencies, see the ReadMe, " + "https://github.com/googlefonts/gftools#installation" + ) import uharfbuzz as hb from fontTools.ttLib import TTFont from PIL import Image From 49cf4d134f9b2f2058693611bc7f7ac95c17f05c Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 14 May 2026 10:23:22 +0100 Subject: [PATCH 10/16] render-text CI: accept font/text inputs on workflow_dispatch with defaults --- .github/workflows/render-text.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml index e77a394a6..38be1d1bd 100644 --- a/.github/workflows/render-text.yml +++ b/.github/workflows/render-text.yml @@ -2,6 +2,15 @@ name: Render Text on: workflow_dispatch: + inputs: + font: + description: "Path to font file (relative to repo root)" + required: false + default: "data/test/Inconsolata[wdth,wght].ttf" + text: + description: "String to render" + required: false + default: "The quick brown fox jumps over the lazy dog" push: paths: - "Lib/gftools/scripts/render_text.py" @@ -33,17 +42,20 @@ jobs: - name: Install gftools run: pip install -e '.[qa]' - - name: Render Inconsolata waterfall + - name: Render waterfall shell: bash + env: + FONT: ${{ github.event.inputs.font || 'data/test/Inconsolata[wdth,wght].ttf' }} + TEXT: ${{ github.event.inputs.text || 'The quick brown fox jumps over the lazy dog' }} run: | gftools render-text \ - -o inconsolata-${{ matrix.os }}.png \ - 'data/test/Inconsolata[wdth,wght].ttf' \ - "The quick brown fox jumps over the lazy dog" + -o "render-${{ matrix.os }}.png" \ + "$FONT" \ + "$TEXT" - name: Upload rendered PNG uses: actions/upload-artifact@v7 with: name: render-text-output-${{ matrix.os }} - path: inconsolata-${{ matrix.os }}.png + path: render-${{ matrix.os }}.png if-no-files-found: error From 95e8960e2249c9503f36ccb331a6547e626ef360 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 14 May 2026 10:26:21 +0100 Subject: [PATCH 11/16] render-text CI: pin GITHUB_TOKEN to contents:read (CodeQL) --- .github/workflows/render-text.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml index 38be1d1bd..09d74961a 100644 --- a/.github/workflows/render-text.yml +++ b/.github/workflows/render-text.yml @@ -17,6 +17,9 @@ on: - "Lib/gftools/render_text/**" - ".github/workflows/render-text.yml" +permissions: + contents: read + jobs: render: runs-on: ${{ matrix.os }} From 46c35cff7d6484aecff36f4c9d14a3c42a3ceab7 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 14 May 2026 10:45:34 +0100 Subject: [PATCH 12/16] render-text: add 'diff' subcommand (before/after/diff/anim.gif) and split CLI into proof/diff --- .github/workflows/render-text.yml | 2 +- Lib/gftools/render_text/__init__.py | 47 +++++++++++- Lib/gftools/scripts/render_text.py | 107 +++++++++++++++++++++++----- 3 files changed, 135 insertions(+), 21 deletions(-) diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml index 09d74961a..3a8e086c7 100644 --- a/.github/workflows/render-text.yml +++ b/.github/workflows/render-text.yml @@ -51,7 +51,7 @@ jobs: FONT: ${{ github.event.inputs.font || 'data/test/Inconsolata[wdth,wght].ttf' }} TEXT: ${{ github.event.inputs.text || 'The quick brown fox jumps over the lazy dog' }} run: | - gftools render-text \ + gftools render-text proof \ -o "render-${{ matrix.os }}.png" \ "$FONT" \ "$TEXT" diff --git a/Lib/gftools/render_text/__init__.py b/Lib/gftools/render_text/__init__.py index 5bf82cd6a..8e47de530 100644 --- a/Lib/gftools/render_text/__init__.py +++ b/Lib/gftools/render_text/__init__.py @@ -18,7 +18,7 @@ from typing import Iterable, Iterator from fontTools.ttLib import TTFont -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageChops, ImageDraw, ImageFont DEFAULT_PPEMS: tuple[int, ...] = (8, 10, 12, 14, 16, 20, 24, 36) @@ -211,3 +211,48 @@ def _compose_waterfall(rows: list[Image.Image]) -> Image.Image: canvas.paste(row, (CANVAS_PADDING, y)) y += row.height + ROW_PADDING return canvas + + +def pad_to_match(images: list[Image.Image]) -> list[Image.Image]: + """Pad each image with white to the max (width, height) of the set.""" + if not images: + return [] + w = max(im.width for im in images) + h = max(im.height for im in images) + return [_pad_white(im, w, h) for im in images] + + +def _pad_white(im: Image.Image, w: int, h: int) -> Image.Image: + if im.size == (w, h): + return im + canvas = Image.new(im.mode, (w, h), "white") + canvas.paste(im, (0, 0)) + return canvas + + +def diff_image(before: Image.Image, after: Image.Image) -> Image.Image: + """Return the absolute per-pixel difference (PIL's 'difference' blend mode). + + The two inputs must be the same size; use :func:`pad_to_match` first. + Identical pixels become black; differing pixels become brighter. + """ + if before.size != after.size: + raise ValueError( + f"diff inputs must match in size; got {before.size} vs {after.size}" + ) + return ImageChops.difference(after, before) + + +def save_animation( + frames: list[Image.Image], path: Path, duration_ms: int = 500 +) -> None: + """Save an animated GIF cycling through ``frames`` (infinite loop).""" + if not frames: + raise ValueError("no frames to animate") + frames[0].save( + path, + save_all=True, + append_images=frames[1:], + duration=duration_ms, + loop=0, + ) diff --git a/Lib/gftools/scripts/render_text.py b/Lib/gftools/scripts/render_text.py index d0544aec3..86a7fa93a 100644 --- a/Lib/gftools/scripts/render_text.py +++ b/Lib/gftools/scripts/render_text.py @@ -13,11 +13,14 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Render a string from a font as a waterfall PNG. +"""Render strings from a font as a waterfall PNG, or diff two fonts. -The rendering backend is selected from the host platform by default -(CoreText on macOS, DirectWrite on Windows, FreeType on Linux). Use -``--backend`` to override. +Subcommands: + proof Render a single font as a waterfall PNG. + diff Render before+after waterfalls plus difference image and animated GIF. + +Backend defaults to the platform-native rasterizer (CoreText on macOS, +DirectWrite on Windows, FreeType on Linux). Override with ``--backend``. """ from __future__ import annotations @@ -28,21 +31,30 @@ from gftools.render_text import ( default_backend, + diff_image, is_variable, iter_fvar_instances, output_dir_for_all, output_path_for, output_path_for_instance, + pad_to_match, parse_variations, render_waterfall, + save_animation, ) +BACKENDS = ("coretext", "directwrite", "freetype") + + def main(args=None): parser = ArgumentParser(description=__doc__) - parser.add_argument("font", type=Path, help="Path to a .ttf/.otf font") - parser.add_argument("text", help="String to render") - parser.add_argument( + subs = parser.add_subparsers(dest="command", required=True) + + proof = subs.add_parser("proof", help="Render a font as a waterfall PNG.") + proof.add_argument("font", type=Path, help="Path to a .ttf/.otf font") + proof.add_argument("text", help="String to render") + proof.add_argument( "-o", "--output", help=( @@ -50,7 +62,7 @@ def main(args=None): "Defaults to .png (or _imgs/ for --all)." ), ) - group = parser.add_mutually_exclusive_group() + group = proof.add_mutually_exclusive_group() group.add_argument( "--variations", help='Variation location, e.g. "wght=400,wdth=75". Default instance if omitted.', @@ -60,26 +72,52 @@ def main(args=None): action="store_true", help="Render one image per fvar instance.", ) - parser.add_argument( + proof.add_argument( "--backend", - choices=("coretext", "directwrite", "freetype"), + choices=BACKENDS, default=None, help="Rendering backend. Defaults to the platform-native backend.", ) + proof.set_defaults(func=_run_proof) + + diff = subs.add_parser( + "diff", + help="Render before+after waterfalls plus a difference image and animated GIF.", + ) + diff.add_argument("before", type=Path, help="Path to the 'before' font") + diff.add_argument("after", type=Path, help="Path to the 'after' font") + diff.add_argument("text", help="String to render") + diff.add_argument( + "-o", + "--output", + help="Output prefix. Default: next to the after font.", + ) + diff.add_argument( + "--variations", + help='Variation location applied to both fonts, e.g. "wght=400".', + ) + diff.add_argument( + "--backend", + choices=BACKENDS, + default=None, + help="Rendering backend. Defaults to the platform-native backend.", + ) + diff.set_defaults(func=_run_diff) opts = parser.parse_args(args) - backend = opts.backend or default_backend() + opts.func(opts) + +def _run_proof(opts) -> None: + backend = opts.backend or default_backend() if opts.all: _render_all(opts.font, opts.text, opts.output, backend) - else: - variations = parse_variations(opts.variations) if opts.variations else None - out = output_path_for(opts.font, variations=variations, output=opts.output) - img = render_waterfall( - opts.font, opts.text, variations=variations, backend=backend - ) - img.save(out) - print(out) + return + variations = parse_variations(opts.variations) if opts.variations else None + out = output_path_for(opts.font, variations=variations, output=opts.output) + img = render_waterfall(opts.font, opts.text, variations=variations, backend=backend) + img.save(out) + print(out) def _render_all(font: Path, text: str, output: str | None, backend: str) -> None: @@ -103,5 +141,36 @@ def _render_all(font: Path, text: str, output: str | None, backend: str) -> None print(out) +def _run_diff(opts) -> None: + backend = opts.backend or default_backend() + variations = parse_variations(opts.variations) if opts.variations else None + + before_img = render_waterfall( + opts.before, opts.text, variations=variations, backend=backend + ) + after_img = render_waterfall( + opts.after, opts.text, variations=variations, backend=backend + ) + before_pad, after_pad = pad_to_match([before_img, after_img]) + diff_img = diff_image(before_pad, after_pad) + + if opts.output: + prefix = Path(opts.output) + else: + prefix = opts.after.parent / opts.after.stem + prefix.parent.mkdir(parents=True, exist_ok=True) + + def _out(suffix: str) -> Path: + return prefix.with_name(prefix.name + suffix) + + before_pad.save(_out("-before.png")) + after_pad.save(_out("-after.png")) + diff_img.save(_out("-diff.png")) + save_animation([before_pad, after_pad], _out("-anim.gif")) + + for suffix in ("-before.png", "-after.png", "-diff.png", "-anim.gif"): + print(_out(suffix)) + + if __name__ == "__main__": main() From c5cc79e2bf12afeafa23fe168b8dbb2f0f9dac08 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 14 May 2026 10:48:44 +0100 Subject: [PATCH 13/16] render-text: update spec for proof/diff subcommands and current implementation --- docs/gftools-render-text/spec.md | 167 +++++++++++++++++++++++++------ 1 file changed, 139 insertions(+), 28 deletions(-) diff --git a/docs/gftools-render-text/spec.md b/docs/gftools-render-text/spec.md index 5bb282c89..7d68921a5 100644 --- a/docs/gftools-render-text/spec.md +++ b/docs/gftools-render-text/spec.md @@ -1,29 +1,44 @@ # gftools render-text — Spec -**Status:** Design — not yet implemented. +**Status:** Implemented. ## Purpose -Render a string from a font as a waterfall PNG, using the platform-native text rendering backend (CoreText on macOS, DirectWrite on Windows, FreeType on Linux). The motivating use case is a GitHub Actions matrix job that produces one image per platform so rendering regressions can be diff'd across backends. +Render strings from a font as waterfall PNGs, using the platform-native text +rendering backend (CoreText on macOS, DirectWrite on Windows via Skia-Python, +FreeType on Linux). The motivating use case is a GitHub Actions matrix job +that produces one image per platform so rendering regressions can be diff'd +across backends. + +The tool has two subcommands: + +- **`proof`** — render a single font as a waterfall PNG. +- **`diff`** — render two fonts (before + after) and emit before/after/diff + images plus an animated GIF for visual comparison. ## Synopsis ``` -gftools render-text FONT TEXT [-o OUTPUT] [--variations AXES | --all] [--backend BACKEND] +gftools render-text proof FONT TEXT [-o OUTPUT] [--variations AXES | --all] [--backend BACKEND] +gftools render-text diff BEFORE AFTER TEXT [-o PREFIX] [--variations AXES] [--backend BACKEND] ``` ## Examples ``` -gftools render-text Roboto-Regular.ttf "The quick brown fox jumps" -gftools render-text Roboto[wght].ttf "Hamburgefonstiv" --variations wght=400,wdth=75 -gftools render-text Roboto[wght].ttf "Hamburgefonstiv" --all -gftools render-text Roboto-Regular.ttf "..." --backend freetype +gftools render-text proof Roboto-Regular.ttf "The quick brown fox jumps" +gftools render-text proof Roboto[wght].ttf "Hamburgefonstiv" --variations wght=400,wdth=75 +gftools render-text proof Roboto[wght].ttf "Hamburgefonstiv" --all +gftools render-text proof Roboto-Regular.ttf "..." --backend freetype + +gftools render-text diff Roboto-old.ttf Roboto-new.ttf "Hamburgefonstiv" +gftools render-text diff Roboto-old.ttf Roboto-new.ttf "..." --variations wght=700 ``` -## Output +## `proof` subcommand -Default output is a waterfall PNG containing the string rendered at the following ppem sizes, stacked vertically: +Default output is a waterfall PNG containing the string rendered at the +following ppem sizes, stacked vertically: ``` 8, 10, 12, 14, 16, 20, 24, 36 @@ -31,7 +46,8 @@ Default output is a waterfall PNG containing the string rendered at the followin ### Output filename -If `-o` is **not** provided, the output filename is derived from the input font: +If `-o` is **not** provided, the output filename is derived from the input +font: | Invocation | Output | |---|---| @@ -39,56 +55,149 @@ If `-o` is **not** provided, the output filename is derived from the input font: | `Roboto[wght].ttf --variations wght=400,wdth=75` | `Roboto-wght400-wdth75.png` | | `Roboto[wght].ttf --all` | `Roboto[wght]_imgs/Roboto-Regular.png`, `Roboto[wght]_imgs/Roboto-Bold.png`, `Roboto[wght]_imgs/Roboto-SemiBoldCondensed.png`, … | -For `--all`, the default output directory is `_imgs/` next to the font. Per-image filenames inside the directory take the suffix from each fvar instance's subfamily name (read from the `fvar` instance records), with non-filesystem-safe characters stripped (spaces removed, slashes etc. replaced). +For `--all`, the default output directory is `_imgs/` next to the +font. Per-image filenames inside the directory take the suffix from each fvar +instance's subfamily name (read from the `fvar` instance records), with +non-filesystem-safe characters stripped (spaces removed, slashes etc. +replaced). -If `-o` **is** provided with `--all`, it is treated as the output directory (created if needed) and per-instance filenames are generated inside it. +If `-o` **is** provided with `--all`, it is treated as the output directory +(created if needed) and per-instance filenames are generated inside it. -## Flags +### Flags -### `-o, --output PATH` +#### `-o, --output PATH` -Optional. Output file path (or directory when used with `--all`). Defaults to `.png` next to the font. +Optional. Output file path (or directory when used with `--all`). Defaults +to `.png` next to the font. -### `--variations AXES` +#### `--variations AXES` -Render at a specific variable-font location. Format: `axis=value` pairs, comma-separated, no spaces: +Render at a specific variable-font location. Format: `axis=value` pairs, +comma-separated, no spaces: ``` --variations wght=400,wdth=75 ``` -Mutually exclusive with `--all`. If the font is static, this flag is an error. +Mutually exclusive with `--all`. -### `--all` +#### `--all` Render one image per fvar instance defined in the font. - Mutually exclusive with `--variations`. -- On a **static** font, prints a warning to stderr ("font is static — rendering default style only") and renders a single default image. Exit code is 0. +- On a **static** font, prints a warning to stderr ("font is static — + rendering default style only") and renders a single default image. Exit + code is 0. + +## `diff` subcommand + +Renders `BEFORE` and `AFTER` fonts independently with the existing waterfall +pipeline, then pads both renders to the maximum of `(width, height)` with +white and emits four artifacts: + +- `-before.png` — the "before" waterfall, padded. +- `-after.png` — the "after" waterfall, padded. +- `-diff.png` — absolute per-pixel difference (PIL's "difference" + blend mode: identical pixels are black, differing pixels are brighter). +- `-anim.gif` — infinite-loop GIF alternating before/after at 500ms. + +### Output prefix + +If `-o` is **not** provided, the prefix is `` placed next to the +`AFTER` font. So: + +``` +gftools render-text diff Roboto-old.ttf Roboto-new.ttf "..." +# Produces (next to Roboto-new.ttf): +# Roboto-new-before.png +# Roboto-new-after.png +# Roboto-new-diff.png +# Roboto-new-anim.gif +``` + +If `-o PATH` is provided, `PATH` is used verbatim as the prefix (it can +include a directory; the directory is created if needed). + +### Flags + +#### `-o, --output PREFIX` + +Output prefix. The four suffixes (`-before.png`, `-after.png`, `-diff.png`, +`-anim.gif`) are appended to it. + +#### `--variations AXES` + +Variation location applied to **both** fonts (same axis values for before +and after, so the diff isolates the font change, not the variation change). +Same format as the `proof` subcommand. + +`--all` is **not** supported with `diff` in v1. + +## Shared flags ### `--backend {coretext,directwrite,freetype}` -Force a specific rendering backend. If omitted, the backend is selected from the host platform: +Force a specific rendering backend. If omitted, the backend is selected +from the host platform: | Platform | Default backend | |---|---| | macOS | CoreText | -| Windows | DirectWrite | +| Windows | DirectWrite (via Skia-Python — see `directwrite_backend.py`) | | Linux | FreeType | -The override is primarily for (a) developing/testing the FreeType path on a Mac, and (b) letting CI assert which backend ran rather than inferring from `runs-on`. +The override is primarily for (a) developing/testing the FreeType path on +a Mac, and (b) letting CI assert which backend ran rather than inferring +from `runs-on`. + +## Cross-backend dimensions + +Row heights are normalised across backends using the font's `OS/2.sTypoAscender` +and `OS/2.sTypoDescender` (with `hhea` as fallback) read via fontTools, so +every backend produces images with identical row heights for a given font and +ppem. Widths remain backend-determined — different shapers will produce +slightly different total advances; that's the platform-rendering signal the +tool is meant to surface. + +For the `diff` subcommand, both renders are padded to the maximum +`(width, height)` of the pair so the difference operation is well-defined. + +## Platform stamp + +Each rendered waterfall has a small grey label in the bottom-left corner +showing the platform and backend used (e.g. `Darwin / CoreText`, +`Linux / FreeType`, `Windows / DirectWrite`). Useful when triaging which +matrix artifact is which. ## Shaping -**Native shaping per backend.** Each backend uses its own shaper: +**Native shaping per backend (mostly).** Each backend uses its own shaper: - CoreText shapes via CoreText. -- DirectWrite shapes via DirectWrite. - FreeType has no shaper, so the FreeType path uses HarfBuzz. +- The DirectWrite backend is implemented via Skia-Python, which uses + HarfBuzz for shaping by default (not DirectWrite's native shaper). See + `directwrite_backend.py` for the rationale (avoiding ~400 lines of + comtypes vtable scaffolding). + +A future `--shaper harfbuzz` flag could force HarfBuzz everywhere for +consistency; out of scope for v1. + +## Installation -This tests the full platform stack (what users actually see end-to-end). The tradeoff is that a shaping bug and a rasterizer bug are indistinguishable in the diff — accepted for the v1 scope. +The render-text backends (`freetype-py` on Linux, `pyobjc-framework-CoreText` ++ `pyobjc-framework-Quartz` on macOS, `skia-python` on Windows) are declared +under the `[qa]` extra in `pyproject.toml`, not in base dependencies. Install +with: + +``` +pip install 'gftools[qa]' +``` -A future `--shaper harfbuzz` flag could force HarfBuzz everywhere to isolate the rasterizer, but is out of scope for v1. +Without the `[qa]` extra, invoking `gftools render-text` raises a +`ModuleNotFoundError` directing the user to the README. ## Out of scope (v1) @@ -96,4 +205,6 @@ A future `--shaper harfbuzz` flag could force HarfBuzz everywhere to isolate the - Custom DPI / device scaling. - Multi-line text wrapping. - PDF or SVG output. -- A `--shaper` override (see above). +- A `--shaper` override. +- `--all` combined with `diff`. +- Native DirectWrite shaping (uses Skia-Python's default HarfBuzz path). From c73138cce0ca26d49c4c40f5ed88cff35abd2da3 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 14 May 2026 11:02:56 +0100 Subject: [PATCH 14/16] render-text: 'diff' -o is now a directory; outputs land as before/after/diff/anim inside --- Lib/gftools/render_text/__init__.py | 8 +++++++ Lib/gftools/scripts/render_text.py | 25 ++++++++------------ docs/gftools-render-text/spec.md | 36 ++++++++++++++--------------- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/Lib/gftools/render_text/__init__.py b/Lib/gftools/render_text/__init__.py index 8e47de530..6ae5b1e61 100644 --- a/Lib/gftools/render_text/__init__.py +++ b/Lib/gftools/render_text/__init__.py @@ -110,6 +110,14 @@ def output_dir_for_all( return font_path.parent / f"{font_path.stem}_imgs" +def output_dir_for_diff( + after_font_path: Path, *, output_dir: str | Path | None = None +) -> Path: + if output_dir is not None: + return Path(output_dir) + return after_font_path.parent / f"{after_font_path.stem}_diff" + + def output_path_for_instance( font_path: Path, instance_name: str, output_dir: Path ) -> Path: diff --git a/Lib/gftools/scripts/render_text.py b/Lib/gftools/scripts/render_text.py index 86a7fa93a..7011d3e9a 100644 --- a/Lib/gftools/scripts/render_text.py +++ b/Lib/gftools/scripts/render_text.py @@ -35,6 +35,7 @@ is_variable, iter_fvar_instances, output_dir_for_all, + output_dir_for_diff, output_path_for, output_path_for_instance, pad_to_match, @@ -90,7 +91,7 @@ def main(args=None): diff.add_argument( "-o", "--output", - help="Output prefix. Default: next to the after font.", + help="Output directory. Default: _diff/ next to the after font.", ) diff.add_argument( "--variations", @@ -154,22 +155,16 @@ def _run_diff(opts) -> None: before_pad, after_pad = pad_to_match([before_img, after_img]) diff_img = diff_image(before_pad, after_pad) - if opts.output: - prefix = Path(opts.output) - else: - prefix = opts.after.parent / opts.after.stem - prefix.parent.mkdir(parents=True, exist_ok=True) - - def _out(suffix: str) -> Path: - return prefix.with_name(prefix.name + suffix) + out_dir = output_dir_for_diff(opts.after, output_dir=opts.output) + out_dir.mkdir(parents=True, exist_ok=True) - before_pad.save(_out("-before.png")) - after_pad.save(_out("-after.png")) - diff_img.save(_out("-diff.png")) - save_animation([before_pad, after_pad], _out("-anim.gif")) + before_pad.save(out_dir / "before.png") + after_pad.save(out_dir / "after.png") + diff_img.save(out_dir / "diff.png") + save_animation([before_pad, after_pad], out_dir / "anim.gif") - for suffix in ("-before.png", "-after.png", "-diff.png", "-anim.gif"): - print(_out(suffix)) + for name in ("before.png", "after.png", "diff.png", "anim.gif"): + print(out_dir / name) if __name__ == "__main__": diff --git a/docs/gftools-render-text/spec.md b/docs/gftools-render-text/spec.md index 7d68921a5..6760269bb 100644 --- a/docs/gftools-render-text/spec.md +++ b/docs/gftools-render-text/spec.md @@ -95,37 +95,37 @@ Render one image per fvar instance defined in the font. Renders `BEFORE` and `AFTER` fonts independently with the existing waterfall pipeline, then pads both renders to the maximum of `(width, height)` with -white and emits four artifacts: +white and emits four artifacts into an output directory: -- `-before.png` — the "before" waterfall, padded. -- `-after.png` — the "after" waterfall, padded. -- `-diff.png` — absolute per-pixel difference (PIL's "difference" - blend mode: identical pixels are black, differing pixels are brighter). -- `-anim.gif` — infinite-loop GIF alternating before/after at 500ms. +- `before.png` — the "before" waterfall, padded. +- `after.png` — the "after" waterfall, padded. +- `diff.png` — absolute per-pixel difference (PIL's "difference" blend + mode: identical pixels are black, differing pixels are brighter). +- `anim.gif` — infinite-loop GIF alternating before/after at 500ms. -### Output prefix +### Output directory -If `-o` is **not** provided, the prefix is `` placed next to the -`AFTER` font. So: +If `-o` is **not** provided, the directory is `_diff/` next to +the `AFTER` font (mirroring the `_imgs/` convention used by +`proof --all`): ``` gftools render-text diff Roboto-old.ttf Roboto-new.ttf "..." # Produces (next to Roboto-new.ttf): -# Roboto-new-before.png -# Roboto-new-after.png -# Roboto-new-diff.png -# Roboto-new-anim.gif +# Roboto-new_diff/before.png +# Roboto-new_diff/after.png +# Roboto-new_diff/diff.png +# Roboto-new_diff/anim.gif ``` -If `-o PATH` is provided, `PATH` is used verbatim as the prefix (it can -include a directory; the directory is created if needed). +If `-o PATH` is provided, `PATH` is used verbatim as the output directory +(created if needed) and the same four filenames are written inside it. ### Flags -#### `-o, --output PREFIX` +#### `-o, --output PATH` -Output prefix. The four suffixes (`-before.png`, `-after.png`, `-diff.png`, -`-anim.gif`) are appended to it. +Output directory. Default: `_diff/` next to the after font. #### `--variations AXES` From 1d6ccb6b1eea88275436165199d077db6cb39429 Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 14 May 2026 11:12:14 +0100 Subject: [PATCH 15/16] render-text: support --all on 'diff' (one bundle per fvar instance) --- Lib/gftools/render_text/__init__.py | 5 +++ Lib/gftools/scripts/render_text.py | 47 ++++++++++++++++++++++++----- docs/gftools-render-text/spec.md | 25 +++++++++++++-- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/Lib/gftools/render_text/__init__.py b/Lib/gftools/render_text/__init__.py index 6ae5b1e61..375fc28dc 100644 --- a/Lib/gftools/render_text/__init__.py +++ b/Lib/gftools/render_text/__init__.py @@ -124,6 +124,11 @@ def output_path_for_instance( return output_dir / f"{font_path.stem}-{_filename_safe(instance_name)}.png" +def output_subdir_for_instance(out_dir: Path, instance_name: str) -> Path: + """Return a per-instance subdirectory inside ``out_dir`` (used by ``diff --all``).""" + return out_dir / _filename_safe(instance_name) + + def render_waterfall( font_path: Path, text: str, diff --git a/Lib/gftools/scripts/render_text.py b/Lib/gftools/scripts/render_text.py index 7011d3e9a..ac9cc1720 100644 --- a/Lib/gftools/scripts/render_text.py +++ b/Lib/gftools/scripts/render_text.py @@ -38,6 +38,7 @@ output_dir_for_diff, output_path_for, output_path_for_instance, + output_subdir_for_instance, pad_to_match, parse_variations, render_waterfall, @@ -93,10 +94,16 @@ def main(args=None): "--output", help="Output directory. Default: _diff/ next to the after font.", ) - diff.add_argument( + diff_group = diff.add_mutually_exclusive_group() + diff_group.add_argument( "--variations", help='Variation location applied to both fonts, e.g. "wght=400".', ) + diff_group.add_argument( + "--all", + action="store_true", + help="Produce a diff bundle per fvar instance of the after font.", + ) diff.add_argument( "--backend", choices=BACKENDS, @@ -144,20 +151,46 @@ def _render_all(font: Path, text: str, output: str | None, backend: str) -> None def _run_diff(opts) -> None: backend = opts.backend or default_backend() - variations = parse_variations(opts.variations) if opts.variations else None + out_dir = output_dir_for_diff(opts.after, output_dir=opts.output) + out_dir.mkdir(parents=True, exist_ok=True) + if opts.all and not is_variable(opts.after): + print( + f"warning: {opts.after} is a static font — rendering default style only.", + file=sys.stderr, + ) + + if opts.all and is_variable(opts.after): + for instance_name, location in iter_fvar_instances(opts.after): + subdir = output_subdir_for_instance(out_dir, instance_name) + subdir.mkdir(parents=True, exist_ok=True) + _emit_diff_bundle( + opts.before, opts.after, opts.text, location, backend, subdir + ) + else: + variations = parse_variations(opts.variations) if opts.variations else None + _emit_diff_bundle( + opts.before, opts.after, opts.text, variations, backend, out_dir + ) + + +def _emit_diff_bundle( + before_path: Path, + after_path: Path, + text: str, + variations: dict | None, + backend: str, + out_dir: Path, +) -> None: before_img = render_waterfall( - opts.before, opts.text, variations=variations, backend=backend + before_path, text, variations=variations, backend=backend ) after_img = render_waterfall( - opts.after, opts.text, variations=variations, backend=backend + after_path, text, variations=variations, backend=backend ) before_pad, after_pad = pad_to_match([before_img, after_img]) diff_img = diff_image(before_pad, after_pad) - out_dir = output_dir_for_diff(opts.after, output_dir=opts.output) - out_dir.mkdir(parents=True, exist_ok=True) - before_pad.save(out_dir / "before.png") after_pad.save(out_dir / "after.png") diff_img.save(out_dir / "diff.png") diff --git a/docs/gftools-render-text/spec.md b/docs/gftools-render-text/spec.md index 6760269bb..354676a6c 100644 --- a/docs/gftools-render-text/spec.md +++ b/docs/gftools-render-text/spec.md @@ -131,9 +131,29 @@ Output directory. Default: `_diff/` next to the after font. Variation location applied to **both** fonts (same axis values for before and after, so the diff isolates the font change, not the variation change). -Same format as the `proof` subcommand. +Same format as the `proof` subcommand. Mutually exclusive with `--all`. -`--all` is **not** supported with `diff` in v1. +#### `--all` + +Produce a diff bundle per fvar instance of the **after** font. The four +files for each instance land in a subdirectory of the output dir named +after the sanitised subfamily name: + +``` +gftools render-text diff Roboto-old.ttf Roboto-new.ttf "..." --all +# Produces: +# Roboto-new_diff/Regular/{before,after,diff}.png + anim.gif +# Roboto-new_diff/Bold/{before,after,diff}.png + anim.gif +# Roboto-new_diff/SemiBoldCondensed/... +# ... +``` + +The axis location of each after-font instance is applied to **both** fonts +(so the before font is sampled at the same point in its variation space, +even if its named-instance set doesn't include that exact name). If the +after font is **static**, prints a stderr warning ("font is static — +rendering default style only") and falls back to a single bundle at the +top level. Mutually exclusive with `--variations`. ## Shared flags @@ -206,5 +226,4 @@ Without the `[qa]` extra, invoking `gftools render-text` raises a - Multi-line text wrapping. - PDF or SVG output. - A `--shaper` override. -- `--all` combined with `diff`. - Native DirectWrite shaping (uses Skia-Python's default HarfBuzz path). From de6872d59f5a56ddbaf8be56b0399e7fba415e6d Mon Sep 17 00:00:00 2001 From: Marc Foley Date: Thu, 14 May 2026 11:27:01 +0100 Subject: [PATCH 16/16] render-text: stamp 'before'/'after' labels in top-right of animated diff frames --- Lib/gftools/render_text/__init__.py | 29 +++++++++++++++++++++++++++-- Lib/gftools/scripts/render_text.py | 6 +++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Lib/gftools/render_text/__init__.py b/Lib/gftools/render_text/__init__.py index 375fc28dc..3e95a5b18 100644 --- a/Lib/gftools/render_text/__init__.py +++ b/Lib/gftools/render_text/__init__.py @@ -257,11 +257,23 @@ def diff_image(before: Image.Image, after: Image.Image) -> Image.Image: def save_animation( - frames: list[Image.Image], path: Path, duration_ms: int = 500 + frames: list[Image.Image], + path: Path, + duration_ms: int = 500, + labels: list[str] | None = None, ) -> None: - """Save an animated GIF cycling through ``frames`` (infinite loop).""" + """Save an animated GIF cycling through ``frames`` (infinite loop). + + If ``labels`` is given (one per frame), each frame is stamped with its + label in the top-right corner so the active frame is identifiable + while the GIF plays. + """ if not frames: raise ValueError("no frames to animate") + if labels is not None: + if len(labels) != len(frames): + raise ValueError("labels must be the same length as frames") + frames = [_stamp_top_right(f, label) for f, label in zip(frames, labels)] frames[0].save( path, save_all=True, @@ -269,3 +281,16 @@ def save_animation( duration=duration_ms, loop=0, ) + + +def _stamp_top_right(im: Image.Image, label: str) -> Image.Image: + out = im.copy() + font = ImageFont.load_default(size=14) + draw = ImageDraw.Draw(out) + bbox = draw.textbbox((0, 0), label, font=font) + text_w = bbox[2] - bbox[0] + pad = 8 + x = out.width - text_w - pad + y = pad + draw.text((x, y), label, fill=(180, 30, 30), font=font) + return out diff --git a/Lib/gftools/scripts/render_text.py b/Lib/gftools/scripts/render_text.py index ac9cc1720..aa2e0162d 100644 --- a/Lib/gftools/scripts/render_text.py +++ b/Lib/gftools/scripts/render_text.py @@ -194,7 +194,11 @@ def _emit_diff_bundle( before_pad.save(out_dir / "before.png") after_pad.save(out_dir / "after.png") diff_img.save(out_dir / "diff.png") - save_animation([before_pad, after_pad], out_dir / "anim.gif") + save_animation( + [before_pad, after_pad], + out_dir / "anim.gif", + labels=["before", "after"], + ) for name in ("before.png", "after.png", "diff.png", "anim.gif"): print(out_dir / name)