diff --git a/.github/workflows/render-text.yml b/.github/workflows/render-text.yml new file mode 100644 index 000000000..3a8e086c7 --- /dev/null +++ b/.github/workflows/render-text.yml @@ -0,0 +1,64 @@ +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" + - "Lib/gftools/render_text/**" + - ".github/workflows/render-text.yml" + +permissions: + contents: read + +jobs: + render: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - 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 '.[qa]' + + - 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 proof \ + -o "render-${{ matrix.os }}.png" \ + "$FONT" \ + "$TEXT" + + - name: Upload rendered PNG + uses: actions/upload-artifact@v7 + with: + name: render-text-output-${{ matrix.os }} + path: render-${{ matrix.os }}.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..3e95a5b18 --- /dev/null +++ b/Lib/gftools/render_text/__init__.py @@ -0,0 +1,296 @@ +"""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 platform +import re +import sys +from pathlib import Path +from typing import Iterable, Iterator + +from fontTools.ttLib import TTFont +from PIL import Image, ImageChops, ImageDraw, ImageFont + + +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: + 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_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: + 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, + *, + 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) + 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", + "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): + 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 + + +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, + labels: list[str] | None = None, +) -> None: + """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, + append_images=frames[1:], + 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/render_text/coretext_backend.py b/Lib/gftools/render_text/coretext_backend.py new file mode 100644 index 000000000..44031e101 --- /dev/null +++ b/Lib/gftools/render_text/coretext_backend.py @@ -0,0 +1,105 @@ +"""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 + +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 + + +def render_row( + font_path: Path, + 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 = max(int(width_d) + PADDING * 2, 1) + # CG's drawing coords are bottom-left origin; convert baseline-from-top. + baseline_y_cg = target_height - baseline_y + + ctx = _gray_bitmap_context(width, target_height) + Quartz.CGContextSetGrayFillColor(ctx, 1.0, 1.0) + Quartz.CGContextFillRect(ctx, ((0, 0), (width, target_height))) + Quartz.CGContextSetGrayFillColor(ctx, 0.0, 1.0) + Quartz.CGContextSetTextPosition(ctx, PADDING, baseline_y_cg) + CoreText.CTLineDraw(line, ctx) + + pixels = Quartz.CGBitmapContextGetData(ctx).as_buffer(width * target_height) + img = Image.frombytes("L", (width, target_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..5490b4508 --- /dev/null +++ b/Lib/gftools/render_text/directwrite_backend.py @@ -0,0 +1,99 @@ +"""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 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 + + +def render_row( + font_path: Path, + 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: + 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) + bounds = blob.bounds() + width = max(int(bounds.width()) + PADDING * 2, 1) + + surface = skia.Surface(width, target_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, target_height, skia.kRGBA_8888_ColorType, skia.kUnpremul_AlphaType + ) + 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, target_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..b6279ebd2 --- /dev/null +++ b/Lib/gftools/render_text/freetype_backend.py @@ -0,0 +1,97 @@ +"""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 + +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 + + +def render_row( + font_path: Path, + 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) + + 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 + 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), 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..aa2e0162d --- /dev/null +++ b/Lib/gftools/scripts/render_text.py @@ -0,0 +1,208 @@ +#!/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 strings from a font as a waterfall PNG, or diff two fonts. + +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 + +import sys +from argparse import ArgumentParser +from pathlib import Path + +from gftools.render_text import ( + default_backend, + diff_image, + is_variable, + iter_fvar_instances, + output_dir_for_all, + output_dir_for_diff, + output_path_for, + output_path_for_instance, + output_subdir_for_instance, + pad_to_match, + parse_variations, + render_waterfall, + save_animation, +) + + +BACKENDS = ("coretext", "directwrite", "freetype") + + +def main(args=None): + parser = ArgumentParser(description=__doc__) + 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=( + "Output PNG path. With --all, treated as an output directory. " + "Defaults to .png (or _imgs/ for --all)." + ), + ) + group = proof.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.", + ) + proof.add_argument( + "--backend", + 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 directory. Default: _diff/ next to the after font.", + ) + 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, + default=None, + help="Rendering backend. Defaults to the platform-native backend.", + ) + diff.set_defaults(func=_run_diff) + + opts = parser.parse_args(args) + 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) + 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: + 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) + + +def _run_diff(opts) -> None: + backend = opts.backend or default_backend() + 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( + before_path, text, variations=variations, backend=backend + ) + after_img = render_waterfall( + 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) + + 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", + labels=["before", "after"], + ) + + for name in ("before.png", "after.png", "diff.png", "anim.gif"): + print(out_dir / name) + + +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..354676a6c --- /dev/null +++ b/docs/gftools-render-text/spec.md @@ -0,0 +1,229 @@ +# gftools render-text — Spec + +**Status:** Implemented. + +## Purpose + +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 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 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 +``` + +## `proof` subcommand + +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`. + +#### `--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. + +## `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 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. + +### Output directory + +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_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 output directory +(created if needed) and the same four filenames are written inside it. + +### Flags + +#### `-o, --output PATH` + +Output directory. Default: `_diff/` next to the after font. + +#### `--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. Mutually exclusive with `--all`. + +#### `--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 + +### `--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 (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`. + +## 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 (mostly).** Each backend uses its own shaper: + +- CoreText shapes via CoreText. +- 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 + +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]' +``` + +Without the `[qa]` extra, invoking `gftools render-text` raises a +`ModuleNotFoundError` directing the user to the README. + +## 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. +- Native DirectWrite shaping (uses Skia-Python's default HarfBuzz path). diff --git a/pyproject.toml b/pyproject.toml index bd9cc2f33..61afb2044 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,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", @@ -151,6 +156,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"