From 5981c96cffd76d422b9e7dcf23a0a4d11ddabe96 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 19 Oct 2026 09:58:12 -0300 Subject: [PATCH 1/5] fix(native): open the code and diff views without fontconfig on Windows and macOS godemirror resolves a family name only through fc-match, which Windows and macOS do not ship, so the native window exited at startup there. Build the views from the files fonts.Locate picks on those systems instead. native.yml now runs the native tests on a real Windows runner and opens the window once against a backend, as the macOS job does. --- .github/scripts/win-native-smoke.sh | 52 +++++++++++ .github/workflows/native.yml | 88 ++++++++++++++++++- CHANGELOG.md | 5 ++ .../cmd/lich-native/code_view_fontconfig.go | 19 ++++ native/cmd/lich-native/code_view_fontscan.go | 68 ++++++++++++++ native/cmd/lich-native/code_view_test.go | 17 ++++ native/cmd/lich-native/dock_code.go | 2 +- native/cmd/lich-native/dock_code_preview.go | 2 +- native/cmd/lich-native/dock_review_file.go | 2 +- 9 files changed, 249 insertions(+), 6 deletions(-) create mode 100755 .github/scripts/win-native-smoke.sh create mode 100644 native/cmd/lich-native/code_view_fontconfig.go create mode 100644 native/cmd/lich-native/code_view_fontscan.go create mode 100644 native/cmd/lich-native/code_view_test.go diff --git a/.github/scripts/win-native-smoke.sh b/.github/scripts/win-native-smoke.sh new file mode 100755 index 00000000..c14a6e4f --- /dev/null +++ b/.github/scripts/win-native-smoke.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Runs the Windows native window once against a real backend, the check +# mac-native-smoke.sh is for macOS: a windowless lich, one shell session, and +# lich-native on top of it. The window has to come up, connect and draw (-shot, +# the frame it renders), still be alive afterwards and close when lich quits; +# the shot and the logs land in $RUNNER_TEMP/diag for the workflow to upload. +# Runs in git-bash. +# +# usage: win-native-smoke.sh +set -eu + +lich="$1" +native="$2" +export LICH_LISTEN_PORT=47898 +config="$(cygpath -u "$APPDATA")/lich" +runtime="$config/runtime.json" +diag="$RUNNER_TEMP/diag" +proj="$RUNNER_TEMP/proj" +mkdir -p "$diag" "$proj" + +fail() { + echo "::error::$1" + cat "$diag/native.out" 2>/dev/null || true + cp "$config/lich.log" "$config/lich-native.log" "$diag/" 2>/dev/null || true + cat "$config/lich.log" "$config/lich-native.log" 2>/dev/null || true + exit 1 +} + +"$lich" --no-window > "$diag/backend.out" 2>&1 & +for _ in $(seq 1 30); do + sleep 1 + test -s "$runtime" && break +done +test -s "$runtime" || fail "lich never wrote runtime.json" +"$lich" open --project "$(cygpath -w "$proj")" --kind shell || fail "lich open exited $?" + +"$native" -runtime "$(cygpath -w "$runtime")" -shot "$(cygpath -w "$diag/shot.png")" -shot-after 8s > "$diag/native.out" 2>&1 & +pid=$! +for _ in $(seq 1 40); do + sleep 1 + test -s "$diag/shot.png" && break + kill -0 "$pid" 2>/dev/null || fail "lich-native exited before it drew" +done +test -s "$diag/shot.png" || fail "lich-native never wrote its shot" +sleep 2 +kill -0 "$pid" 2>/dev/null || fail "lich-native died after its shot" +cp "$config/lich-native.log" "$diag/" 2>/dev/null || true + +"$lich" quit || fail "lich quit exited $?" +for _ in $(seq 1 10); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done +kill -0 "$pid" 2>/dev/null && fail "lich-native outlived lich quit" +echo "lich-native came up, connected, drew and closed with lich; see the uploaded shot" diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 50719a9e..0281430d 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -100,8 +100,8 @@ jobs: fi # The Windows window is cross-built from Linux with Zig as cgo's toolchain - # (native/build-windows.sh); this proves it still links. Running it is left - # to a real Windows machine. + # (native/build-windows.sh), and so are its tests and the backend; the + # windows job below runs them on a real Windows machine. windows-build: runs-on: ubuntu-latest defaults: @@ -132,7 +132,89 @@ jobs: key: libghostty-vt-windows-${{ steps.ghostty.outputs.commit }} - name: Build lich-native.exe - run: LIBGHOSTTY_VT_WINDOWS="$HOME/libghostty-vt-windows" ./build-windows.sh "$RUNNER_TEMP/lich-native.exe" + run: LIBGHOSTTY_VT_WINDOWS="$HOME/libghostty-vt-windows" ./build-windows.sh "$RUNNER_TEMP/out/lich-native.exe" + + # One test binary per package, at the package's own path, so the windows + # job runs each from its package directory the way go test does. The + # toolchain is build-windows.sh's. + - name: Build the tests for Windows + env: + GOOS: windows + GOARCH: amd64 + CGO_ENABLED: "1" + CC: zig cc -target x86_64-windows-gnu + CXX: zig c++ -target x86_64-windows-gnu + CGO_LDFLAGS_ALLOW: '.*\.lib' + run: | + export PKG_CONFIG_PATH="$HOME/libghostty-vt-windows/share/pkgconfig" + for dir in $(go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.Dir}}{{end}}' ./...); do + rel="${dir#"$PWD"/}" + go test -c -o "$RUNNER_TEMP/out/tests/$rel/pkg.test.exe" "./$rel" + done + + # The native window reads nothing from the embedded frontend, so an empty + # dist is enough for go:embed. + - name: Build lich.exe + working-directory: . + run: | + mkdir -p frontend/dist + touch frontend/dist/index.html + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o "$RUNNER_TEMP/out/lich.exe" . + + - uses: actions/upload-artifact@v7 + with: + name: native-windows-build + path: ${{ runner.temp }}/out + retention-days: 1 + + # Runs on Windows what windows-build made there: the native tests, then the + # window itself once against a real backend, which has to come up and draw. + windows: + needs: windows-build + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + name: native-windows-build + path: ${{ runner.temp }}/out + + # Like the Linux job, a render test that skips for want of a GPU fails + # the job: Windows always has one, WARP's software one at least. + - name: go test + working-directory: native + run: | + out="$(cygpath -u "$RUNNER_TEMP")/out" + status=0 + for exe in $(cd "$out/tests" && find . -name pkg.test.exe); do + rel="$(dirname "$exe")" + echo "### $rel" >> test.log + (cd "$rel" && "$out/tests/$exe" -test.v) >> test.log 2>&1 || status=1 + done + [ "$status" = 0 ] || { cat test.log; exit 1; } + grep -E '^(### |--- FAIL|--- SKIP|PASS|FAIL)' test.log + if grep -q 'no headless GPU' test.log; then + echo "::error::render tests skipped: no headless GPU" + grep -B1 'no headless GPU' test.log + exit 1 + fi + + - name: Run the window against the backend + run: | + out="$(cygpath -u "$RUNNER_TEMP")/out" + bash .github/scripts/win-native-smoke.sh "$out/lich.exe" "$out/lich-native.exe" + + - name: Upload what the window drew + if: always() + uses: actions/upload-artifact@v7 + with: + name: lich-native-windows + path: ${{ runner.temp }}/diag + if-no-files-found: ignore # Gio's Cocoa and Metal need Apple's SDK, so the macOS window builds on a Mac # and nowhere else. Apple Silicon only on a PR: release.yml builds Intel too. diff --git a/CHANGELOG.md b/CHANGELOG.md index b14249bc..38bc597e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The native window opens again on Windows and macOS, where it closed right + away looking for fontconfig to draw the code and diff views. + ## [0.71.1] - 2026-10-18 ### Fixed diff --git a/native/cmd/lich-native/code_view_fontconfig.go b/native/cmd/lich-native/code_view_fontconfig.go new file mode 100644 index 00000000..f1a2deb1 --- /dev/null +++ b/native/cmd/lich-native/code_view_fontconfig.go @@ -0,0 +1,19 @@ +//go:build !windows && !darwin + +package main + +import ( + "gioui.org/unit" + + "github.com/lichdotdev/godemirror" +) + +// newCodeView builds the code view in family by its fontconfig name, which +// keeps godemirror's own fallback for characters the family lacks. +func newCodeView(family string, size unit.Sp) (*godemirror.View, error) { + return godemirror.New(family, size) +} + +func newDiffView(family string, size unit.Sp, opts godemirror.DiffOptions) (*godemirror.DiffView, error) { + return godemirror.NewDiffView(family, size, opts) +} diff --git a/native/cmd/lich-native/code_view_fontscan.go b/native/cmd/lich-native/code_view_fontscan.go new file mode 100644 index 00000000..cd40160a --- /dev/null +++ b/native/cmd/lich-native/code_view_fontscan.go @@ -0,0 +1,68 @@ +//go:build windows || darwin + +package main + +import ( + "os" + + "gioui.org/font" + "gioui.org/unit" + + "github.com/lichdotdev/godemirror" + "github.com/lichdotdev/lich/native/fonts" +) + +// newCodeView builds the code view from the files fonts.Locate picks for +// family: godemirror resolves a family name only through fontconfig's fc-match, +// which Windows and macOS do not ship. A character the family lacks is drawn +// as a box. +func newCodeView(family string, size unit.Sp) (*godemirror.View, error) { + files, err := codeFonts(family) + if err != nil { + return nil, err + } + return godemirror.NewFromFonts(files, size) +} + +func newDiffView(family string, size unit.Sp, opts godemirror.DiffOptions) (*godemirror.DiffView, error) { + files, err := codeFonts(family) + if err != nil { + return nil, err + } + return godemirror.NewDiffViewFromFonts(files, size, opts) +} + +// codeFonts reads family's four styles. godemirror draws a collection's first +// face, so a style fonts.Locate finds deeper in a collection is drawn with +// that one, as the terminal's rasterizer does. +func codeFonts(family string) (godemirror.Fonts, error) { + read := map[string][]byte{} + load := func(weight font.Weight, style font.Style) ([]byte, error) { + loc, err := fonts.Locate(font.Font{Typeface: font.Typeface(family), Weight: weight, Style: style}) + if err != nil { + return nil, err + } + if data, ok := read[loc.Path]; ok { + return data, nil + } + data, err := os.ReadFile(loc.Path) + if err != nil { + return nil, err + } + read[loc.Path] = data + return data, nil + } + var files godemirror.Fonts + var err error + if files.Regular, err = load(font.Normal, font.Regular); err != nil { + return files, err + } + if files.Bold, err = load(font.Bold, font.Regular); err != nil { + return files, err + } + if files.Italic, err = load(font.Normal, font.Italic); err != nil { + return files, err + } + files.BoldItalic, err = load(font.Bold, font.Italic) + return files, err +} diff --git a/native/cmd/lich-native/code_view_test.go b/native/cmd/lich-native/code_view_test.go new file mode 100644 index 00000000..82cb6129 --- /dev/null +++ b/native/cmd/lich-native/code_view_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "testing" + + "github.com/lichdotdev/godemirror" + "github.com/lichdotdev/lich/native/fonts" +) + +func TestTheCodeAndDiffViewsOpenInTheSystemMonospace(t *testing.T) { + if _, err := newCodeView(fonts.SystemMono, 13); err != nil { + t.Fatalf("code view: %v", err) + } + if _, err := newDiffView(fonts.SystemMono, 13, godemirror.DiffOptions{}); err != nil { + t.Fatalf("diff view: %v", err) + } +} diff --git a/native/cmd/lich-native/dock_code.go b/native/cmd/lich-native/dock_code.go index cb7d5b9a..f5c8e066 100644 --- a/native/cmd/lich-native/dock_code.go +++ b/native/cmd/lich-native/dock_code.go @@ -77,7 +77,7 @@ type codeTab struct { } func newCodeTab(client *lichclient.Client, invalidate func(), family string, size unit.Sp, comments *commentStore, notify func(ui.Toast)) (*codeTab, error) { - view, err := godemirror.New(family, size) + view, err := newCodeView(family, size) if err != nil { return nil, err } diff --git a/native/cmd/lich-native/dock_code_preview.go b/native/cmd/lich-native/dock_code_preview.go index 6bd97ee8..89855fd4 100644 --- a/native/cmd/lich-native/dock_code_preview.go +++ b/native/cmd/lich-native/dock_code_preview.go @@ -264,7 +264,7 @@ func (p *preview) setFont(family string, size unit.Sp) error { if family == p.family && size == p.size { return nil } - view, err := godemirror.New(family, size) + view, err := newCodeView(family, size) if err != nil { return err } diff --git a/native/cmd/lich-native/dock_review_file.go b/native/cmd/lich-native/dock_review_file.go index 1d98f6e2..bf299bce 100644 --- a/native/cmd/lich-native/dock_review_file.go +++ b/native/cmd/lich-native/dock_review_file.go @@ -280,7 +280,7 @@ func (c *fileCard) ensureView(p *reviewPanel) *godemirror.DiffView { if c.git.status != statusRenamed && p.pr == nil { opts.Revert = func(ch godemirror.Chunk) { p.revert(c.git.path, lichdiff.RevertLines(ch)) } } - view, err := godemirror.NewDiffView(p.family, p.size, opts) + view, err := newDiffView(p.family, p.size, opts) if err != nil { c.viewFail = true log.Printf("review %s: %v", c.git.path, err) From deb1beb0f79a827537d9409c31900fe286c03951 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 19 Oct 2026 10:20:55 -0300 Subject: [PATCH 2/5] fix(native): draw COLR emoji in color on Windows Segoe UI Emoji stores its glyphs as COLR layers, not the PNG bitmaps (CBDT, sbix) the terminal's rasterizer drew, so emoji came out as plain outlines on Windows. Draw COLR version 0 layers and version 1 paint graphs: solid and gradient fills, transforms, glyph clips and composite modes. The Windows smoke now runs even when the native tests fail. --- .github/workflows/native.yml | 3 + CHANGELOG.md | 2 + native/glyphs/colr.go | 307 +++++++++++++++++++++++++++++ native/glyphs/colr_fill.go | 370 +++++++++++++++++++++++++++++++++++ native/glyphs/colr_test.go | 146 ++++++++++++++ native/glyphs/files.go | 14 +- native/glyphs/raster.go | 19 +- 7 files changed, 851 insertions(+), 10 deletions(-) create mode 100644 native/glyphs/colr.go create mode 100644 native/glyphs/colr_fill.go create mode 100644 native/glyphs/colr_test.go diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 0281430d..1175c3a6 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -203,7 +203,10 @@ jobs: exit 1 fi + # Runs after a failed go test too: whether the window comes up at all is + # its own answer. - name: Run the window against the backend + if: success() || failure() run: | out="$(cygpath -u "$RUNNER_TEMP")/out" bash .github/scripts/win-native-smoke.sh "$out/lich.exe" "$out/lich-native.exe" diff --git a/CHANGELOG.md b/CHANGELOG.md index 46109df6..b00a630d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The native window opens again on Windows and macOS, where it closed right away looking for fontconfig to draw the code and diff views. +- Emoji in the native window's terminal are drawn in color on Windows, where + they came out as plain outlines. - In the native window, "Unfold long pastes" now unfolds Claude Code and Kiro CLI pastes: the clipboard, a batch of review comments, and the prompt a pull request or issue opens a session with. ## [0.71.1] - 2026-10-18 diff --git a/native/glyphs/colr.go b/native/glyphs/colr.go new file mode 100644 index 00000000..50aef919 --- /dev/null +++ b/native/glyphs/colr.go @@ -0,0 +1,307 @@ +package glyphs + +import ( + "errors" + "fmt" + "image" + "math" + + gotext "github.com/go-text/typesetting/font" + ot "github.com/go-text/typesetting/font/opentype" + "github.com/go-text/typesetting/font/opentype/tables" + "golang.org/x/image/vector" +) + +// maxPaintDepth bounds a COLR paint graph, which the format lets a broken font +// make cyclic (a PaintColrGlyph reaching itself). +const maxPaintDepth = 64 + +// foregroundIndex is the palette index COLR reserves for the text's own color. +const foregroundIndex = 0xFFFF + +var errPaintTooDeep = errors.New("COLR paint graph deeper than allowed, a cycle") + +// colorLayers draws c's COLR color glyph, the format Windows' Segoe UI Emoji +// uses (version 0 layers, or a version 1 paint graph), into the two cells +// colorBitmap scales a PNG into, centered in them. It returns nil when f has no +// color glyph for c. +func (r *Rasterizer) colorLayers(f *fontFace, c rune) (*image.RGBA, image.Point, error) { + if f.bitmaps == nil || f.bitmaps.COLR == nil { + return nil, image.Point{}, nil + } + gid, ok := f.bitmaps.NominalGlyph(c) + if !ok { + return nil, image.Point{}, nil + } + root, ok := f.bitmaps.COLR.Search(tables.GlyphID(gid)) + if !ok { + return nil, image.Point{}, nil + } + ext, _ := f.bitmaps.FontHExtents() + emW, emH := float64(f.bitmaps.HorizontalAdvance(gid)), float64(ext.Ascender-ext.Descender) + if emW <= 0 || emH <= 0 { + return nil, image.Point{}, nil + } + boxW, boxH := 2*r.cellW, r.cellH + scale := min(float64(boxW)/emW, float64(boxH)/emH) + w, h := max(int(math.Ceil(emW*scale)), 1), max(int(math.Ceil(emH*scale)), 1) + p := colrPainter{face: f.bitmaps, bounds: image.Rect(0, 0, w, h)} + if len(f.bitmaps.CPAL) > 0 { + p.palette = f.bitmaps.CPAL[0] + } + // Font units to pixels: y grows down, and the ascender is the top row. + fontToPixels := affine{xx: scale, yy: -scale, dy: float64(ext.Ascender) * scale} + dst := image.NewRGBA(p.bounds) + if err := p.paint(dst, root, fontToPixels, p.opaque(), 0); err != nil { + return nil, image.Point{}, fmt.Errorf("draw COLR glyph of %U: %w", c, err) + } + return dst, image.Pt((boxW-w)/2, (boxH-h)/2), nil +} + +// colrPainter walks one COLR glyph's paint graph onto RGBA layers. +type colrPainter struct { + face *gotext.Face + palette []tables.ColorRecord + bounds image.Rectangle +} + +// paint draws p, placed by m (font units to pixels) and masked by clip, over +// dst. +func (cp *colrPainter) paint(dst *image.RGBA, p tables.PaintTable, m affine, clip *image.Alpha, depth int) error { + if depth > maxPaintDepth { + return errPaintTooDeep + } + if t, child, ok := transformOf(p); ok { + return cp.paint(dst, child, m.then(t), clip, depth+1) + } + switch p := p.(type) { + case tables.PaintColrLayersResolved: + for _, layer := range p { + fillSolid(dst, intersect(clip, cp.cover(layer.GlyphID, m)), cp.color(layer.PaletteIndex, 1)) + } + case tables.PaintColrLayers: + layers, err := cp.face.COLR.LayerList.Resolve(p) + if err != nil { + return err + } + for _, layer := range layers { + if err := cp.paint(dst, layer, m, clip, depth+1); err != nil { + return err + } + } + case tables.PaintGlyph: + return cp.paint(dst, p.Paint, m, intersect(clip, cp.cover(tables.GlyphID(p.GlyphID), m)), depth+1) + case tables.PaintColrGlyph: + if child, ok := cp.face.COLR.Search(tables.GlyphID(p.GlyphID)); ok { + return cp.paint(dst, child, m, clip, depth+1) + } + case tables.PaintComposite: + return cp.composite(dst, p, m, clip, depth) + default: + return cp.fill(dst, p, m, clip) + } + return nil +} + +// composite draws p's backdrop and source on layers of their own, merges them +// by p's mode and lays the result over dst. +func (cp *colrPainter) composite(dst *image.RGBA, p tables.PaintComposite, m affine, clip *image.Alpha, depth int) error { + backdrop, source := image.NewRGBA(cp.bounds), image.NewRGBA(cp.bounds) + if err := cp.paint(backdrop, p.BackdropPaint, m, clip, depth+1); err != nil { + return err + } + if err := cp.paint(source, p.SourcePaint, m, clip, depth+1); err != nil { + return err + } + compositeLayers(source, backdrop, p.CompositeMode) + over(dst, backdrop) + return nil +} + +// cover is the coverage of glyph gid's outline placed by m. +func (cp *colrPainter) cover(gid tables.GlyphID, m affine) *image.Alpha { + mask := image.NewAlpha(cp.bounds) + outline, ok := cp.face.GlyphDataOutline(gotext.GID(gid)) + if !ok || len(outline.Segments) == 0 { + return mask + } + z := vector.NewRasterizer(cp.bounds.Dx(), cp.bounds.Dy()) + at := func(p ot.SegmentPoint) (float32, float32) { + x, y := m.apply(float64(p.X), float64(p.Y)) + return float32(x), float32(y) + } + started := false + for _, s := range outline.Segments { + switch s.Op { + case ot.SegmentOpMoveTo: + if started { + z.ClosePath() + } + started = true + z.MoveTo(at(s.Args[0])) + case ot.SegmentOpLineTo: + z.LineTo(at(s.Args[0])) + case ot.SegmentOpQuadTo: + bx, by := at(s.Args[0]) + cx, cy := at(s.Args[1]) + z.QuadTo(bx, by, cx, cy) + case ot.SegmentOpCubeTo: + bx, by := at(s.Args[0]) + cx, cy := at(s.Args[1]) + dx, dy := at(s.Args[2]) + z.CubeTo(bx, by, cx, cy, dx, dy) + } + } + z.ClosePath() + z.Draw(mask, cp.bounds, image.Opaque, image.Point{}) + return mask +} + +func (cp *colrPainter) opaque() *image.Alpha { + mask := image.NewAlpha(cp.bounds) + for i := range mask.Pix { + mask.Pix[i] = 0xff + } + return mask +} + +// color is palette entry index at alpha, premultiplied, in [0, 1]. The text's +// own color is not known here, the mask being drawn untinted, so foregroundIndex +// draws black. +func (cp *colrPainter) color(index uint16, alpha float64) rgba { + if index == foregroundIndex || int(index) >= len(cp.palette) { + return rgba{a: alpha} + } + c := cp.palette[index] + a := float64(c.Alpha) / 0xff * alpha + return rgba{r: float64(c.Red) / 0xff * a, g: float64(c.Green) / 0xff * a, b: float64(c.Blue) / 0xff * a, a: a} +} + +// intersect is the coverage both masks give each pixel. +func intersect(a, b *image.Alpha) *image.Alpha { + out := image.NewAlpha(a.Rect) + for i := range out.Pix { + out.Pix[i] = uint8(uint16(a.Pix[i]) * uint16(b.Pix[i]) / 0xff) + } + return out +} + +func f214(c tables.Coord) float64 { return float64(c) / (1 << 14) } + +// affine maps (x, y) to (xx·x + xy·y + dx, yx·x + yy·y + dy). +type affine struct{ xx, yx, xy, yy, dx, dy float64 } + +func (m affine) apply(x, y float64) (float64, float64) { + return m.xx*x + m.xy*y + m.dx, m.yx*x + m.yy*y + m.dy +} + +// then is m applied after t: what a paint transformed by t inside m draws with. +func (m affine) then(t affine) affine { + return affine{ + xx: m.xx*t.xx + m.xy*t.yx, yx: m.yx*t.xx + m.yy*t.yx, + xy: m.xx*t.xy + m.xy*t.yy, yy: m.yx*t.xy + m.yy*t.yy, + dx: m.xx*t.dx + m.xy*t.dy + m.dx, dy: m.yx*t.dx + m.yy*t.dy + m.dy, + } +} + +func (m affine) invert() (affine, bool) { + det := m.xx*m.yy - m.xy*m.yx + if math.Abs(det) < 1e-12 { + return affine{}, false + } + inv := affine{xx: m.yy / det, yx: -m.yx / det, xy: -m.xy / det, yy: m.xx / det} + inv.dx, inv.dy = -(inv.xx*m.dx + inv.xy*m.dy), -(inv.yx*m.dx + inv.yy*m.dy) + return inv, true +} + +func translate(dx, dy float64) affine { return affine{xx: 1, yy: 1, dx: dx, dy: dy} } + +func scaleBy(sx, sy float64) affine { return affine{xx: sx, yy: sy} } + +// rotate turns counter-clockwise by turns·180°, COLR's unit. +func rotate(turns float64) affine { + sin, cos := math.Sincos(turns * math.Pi) + return affine{xx: cos, yx: sin, xy: -sin, yy: cos} +} + +func skew(xTurns, yTurns float64) affine { + return affine{xx: 1, yy: 1, xy: -math.Tan(xTurns * math.Pi), yx: math.Tan(yTurns * math.Pi)} +} + +// around is t applied about (cx, cy) instead of the origin. +func around(t affine, cx, cy int16) affine { + return translate(float64(cx), float64(cy)).then(t).then(translate(-float64(cx), -float64(cy))) +} + +// transformOf reads p as a transform of a child paint. +func transformOf(p tables.PaintTable) (affine, tables.PaintTable, bool) { + switch p := static(p).(type) { + case tables.PaintTransform: + t := p.Transform + return affine{xx: float64(t.Xx), yx: float64(t.Yx), xy: float64(t.Xy), yy: float64(t.Yy), dx: float64(t.Dx), dy: float64(t.Dy)}, p.Paint, true + case tables.PaintTranslate: + return translate(float64(p.Dx), float64(p.Dy)), p.Paint, true + case tables.PaintScale: + return scaleBy(f214(p.ScaleX), f214(p.ScaleY)), p.Paint, true + case tables.PaintScaleAroundCenter: + return around(scaleBy(f214(p.ScaleX), f214(p.ScaleY)), p.CenterX, p.CenterY), p.Paint, true + case tables.PaintScaleUniform: + return scaleBy(f214(p.Scale), f214(p.Scale)), p.Paint, true + case tables.PaintScaleUniformAroundCenter: + return around(scaleBy(f214(p.Scale), f214(p.Scale)), p.CenterX, p.CenterY), p.Paint, true + case tables.PaintRotate: + return rotate(f214(p.Angle)), p.Paint, true + case tables.PaintRotateAroundCenter: + return around(rotate(f214(p.Angle)), p.CenterX, p.CenterY), p.Paint, true + case tables.PaintSkew: + return skew(f214(p.XSkewAngle), f214(p.YSkewAngle)), p.Paint, true + case tables.PaintSkewAroundCenter: + return around(skew(f214(p.XSkewAngle), f214(p.YSkewAngle)), p.CenterX, p.CenterY), p.Paint, true + } + return affine{}, nil, false +} + +// static reads a variable paint as its fixed twin at the font's default +// instance, lich setting no variation axis; any other paint comes back as is. +func static(p tables.PaintTable) tables.PaintTable { + switch p := p.(type) { + case tables.PaintVarSolid: + return tables.PaintSolid{PaletteIndex: p.PaletteIndex, Alpha: p.Alpha} + case tables.PaintVarLinearGradient: + return tables.PaintLinearGradient{ColorLine: fixedLine(p.ColorLine), X0: p.X0, Y0: p.Y0, X1: p.X1, Y1: p.Y1, X2: p.X2, Y2: p.Y2} + case tables.PaintVarRadialGradient: + return tables.PaintRadialGradient{ColorLine: fixedLine(p.ColorLine), X0: p.X0, Y0: p.Y0, Radius0: p.Radius0, X1: p.X1, Y1: p.Y1, Radius1: p.Radius1} + case tables.PaintVarSweepGradient: + return tables.PaintSweepGradient{ColorLine: fixedLine(p.ColorLine), CenterX: p.CenterX, CenterY: p.CenterY, StartAngle: p.StartAngle, EndAngle: p.EndAngle} + case tables.PaintVarTransform: + t := p.Transform + return tables.PaintTransform{Paint: p.Paint, Transform: tables.Affine2x3{Xx: t.Xx, Yx: t.Yx, Xy: t.Xy, Yy: t.Yy, Dx: t.Dx, Dy: t.Dy}} + case tables.PaintVarTranslate: + return tables.PaintTranslate{Paint: p.Paint, Dx: p.Dx, Dy: p.Dy} + case tables.PaintVarScale: + return tables.PaintScale{Paint: p.Paint, ScaleX: p.ScaleX, ScaleY: p.ScaleY} + case tables.PaintVarScaleAroundCenter: + return tables.PaintScaleAroundCenter{Paint: p.Paint, ScaleX: p.ScaleX, ScaleY: p.ScaleY, CenterX: p.CenterX, CenterY: p.CenterY} + case tables.PaintVarScaleUniform: + return tables.PaintScaleUniform{Paint: p.Paint, Scale: p.Scale} + case tables.PaintVarScaleUniformAroundCenter: + return tables.PaintScaleUniformAroundCenter{Paint: p.Paint, Scale: p.Scale, CenterX: p.CenterX, CenterY: p.CenterY} + case tables.PaintVarRotate: + return tables.PaintRotate{Paint: p.Paint, Angle: p.Angle} + case tables.PaintVarRotateAroundCenter: + return tables.PaintRotateAroundCenter{Paint: p.Paint, Angle: p.Angle, CenterX: p.CenterX, CenterY: p.CenterY} + case tables.PaintVarSkew: + return tables.PaintSkew{Paint: p.Paint, XSkewAngle: p.XSkewAngle, YSkewAngle: p.YSkewAngle} + case tables.PaintVarSkewAroundCenter: + return tables.PaintSkewAroundCenter{Paint: p.Paint, XSkewAngle: p.XSkewAngle, YSkewAngle: p.YSkewAngle, CenterX: p.CenterX, CenterY: p.CenterY} + } + return p +} + +func fixedLine(l tables.VarColorLine) tables.ColorLine { + stops := make([]tables.ColorStop, len(l.ColorStops)) + for i, s := range l.ColorStops { + stops[i] = tables.ColorStop{StopOffset: s.StopOffset, PaletteIndex: s.PaletteIndex, Alpha: s.Alpha} + } + return tables.ColorLine{Extend: l.Extend, ColorStops: stops} +} diff --git a/native/glyphs/colr_fill.go b/native/glyphs/colr_fill.go new file mode 100644 index 00000000..96471e74 --- /dev/null +++ b/native/glyphs/colr_fill.go @@ -0,0 +1,370 @@ +package glyphs + +import ( + "fmt" + "image" + "math" + "slices" + + "github.com/go-text/typesetting/font/opentype/tables" +) + +// rgba is a premultiplied color, each channel in [0, 1]. +type rgba struct{ r, g, b, a float64 } + +func (c rgba) scaled(k float64) rgba { return rgba{c.r * k, c.g * k, c.b * k, c.a * k} } + +func (c rgba) plus(d rgba) rgba { return rgba{c.r + d.r, c.g + d.g, c.b + d.b, c.a + d.a} } + +// fill draws a solid or gradient paint, a leaf of the graph, over dst through +// clip. A gradient's geometry is in font units, mapped to pixels by m. +func (cp *colrPainter) fill(dst *image.RGBA, p tables.PaintTable, m affine, clip *image.Alpha) error { + if solid, ok := static(p).(tables.PaintSolid); ok { + fillSolid(dst, clip, cp.color(solid.PaletteIndex, f214(solid.Alpha))) + return nil + } + at, line, ok := cp.gradient(static(p)) + if !ok { + return fmt.Errorf("unknown COLR paint %T", p) + } + toFont, ok := m.invert() + if !ok { + return nil + } + for y := range cp.bounds.Dy() { + for x := range cp.bounds.Dx() { + cover := clip.AlphaAt(x, y).A + if cover == 0 { + continue + } + fx, fy := toFont.apply(float64(x)+0.5, float64(y)+0.5) + t, painted := at(fx, fy) + if painted { + blend(dst, x, y, line.at(t).scaled(float64(cover)/0xff)) + } + } + } + return nil +} + +// gradient returns where on its color line a gradient paint puts each point of +// font space, false where it paints nothing. +func (cp *colrPainter) gradient(p tables.PaintTable) (func(x, y float64) (float64, bool), colorLine, bool) { + switch p := p.(type) { + case tables.PaintLinearGradient: + return linearAt(p), cp.line(p.ColorLine), true + case tables.PaintRadialGradient: + return radialAt(p), cp.line(p.ColorLine), true + case tables.PaintSweepGradient: + return sweepAt(p), cp.line(p.ColorLine), true + } + return nil, colorLine{}, false +} + +// linearAt projects onto p0→p3, the gradient vector p0→p1 turned to run +// perpendicular to p0→p2, as the COLR spec draws a linear gradient. +func linearAt(p tables.PaintLinearGradient) func(x, y float64) (float64, bool) { + x0, y0 := float64(p.X0), float64(p.Y0) + ax, ay := float64(p.X1)-x0, float64(p.Y1)-y0 + nx, ny := float64(p.Y2-p.Y0), -float64(p.X2-p.X0) + if n := nx*nx + ny*ny; n != 0 { + k := (ax*nx + ay*ny) / n + ax, ay = k*nx, k*ny + } + length := ax*ax + ay*ay + return func(x, y float64) (float64, bool) { + if length == 0 { + return 0, false + } + return ((x-x0)*ax + (y-y0)*ay) / length, true + } +} + +// radialAt is the two-circle gradient: the largest t whose circle, interpolated +// between the start and end circles, passes through the point with a radius +// that is not negative. +func radialAt(p tables.PaintRadialGradient) func(x, y float64) (float64, bool) { + cx, cy, r0 := float64(p.X0), float64(p.Y0), float64(p.Radius0) + dx, dy, dr := float64(p.X1)-cx, float64(p.Y1)-cy, float64(p.Radius1)-r0 + a := dx*dx + dy*dy - dr*dr + return func(x, y float64) (float64, bool) { + px, py := x-cx, y-cy + b := px*dx + py*dy + r0*dr + c := px*px + py*py - r0*r0 + if math.Abs(a) < 1e-9 { + if b == 0 { + return 0, false + } + t := c / (2 * b) + return t, r0+t*dr >= 0 + } + disc := b*b - a*c + if disc < 0 { + return 0, false + } + root := math.Sqrt(disc) + lo, hi := (b-root)/a, (b+root)/a + if lo > hi { + lo, hi = hi, lo + } + for _, t := range [2]float64{hi, lo} { + if r0+t*dr >= 0 { + return t, true + } + } + return 0, false + } +} + +// sweepAt is the counter-clockwise angle around the center, from the start +// angle to the end one, each stored as (degrees/180 - 1). +func sweepAt(p tables.PaintSweepGradient) func(x, y float64) (float64, bool) { + cx, cy := float64(p.CenterX), float64(p.CenterY) + start, end := (f214(p.StartAngle)+1)*180, (f214(p.EndAngle)+1)*180 + return func(x, y float64) (float64, bool) { + if end == start { + return 0, false + } + angle := math.Atan2(y-cy, x-cx) * 180 / math.Pi + if angle < 0 { + angle += 360 + } + return (angle - start) / (end - start), true + } +} + +type colorStop struct { + offset float64 + color rgba +} + +// colorLine is a gradient's stops, sorted, and what happens past either end. +type colorLine struct { + stops []colorStop + extend tables.Extend +} + +func (cp *colrPainter) line(l tables.ColorLine) colorLine { + stops := make([]colorStop, len(l.ColorStops)) + for i, s := range l.ColorStops { + stops[i] = colorStop{offset: f214(s.StopOffset), color: cp.color(s.PaletteIndex, f214(s.Alpha))} + } + slices.SortStableFunc(stops, func(a, b colorStop) int { return cmpFloat(a.offset, b.offset) }) + return colorLine{stops: stops, extend: l.Extend} +} + +func cmpFloat(a, b float64) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + } + return 0 +} + +// at is the color at t, with the line extended past its stops the way it says. +func (l colorLine) at(t float64) rgba { + if len(l.stops) == 0 { + return rgba{} + } + first, last := l.stops[0].offset, l.stops[len(l.stops)-1].offset + if span := last - first; span > 0 && (t < first || t > last) { + t = extendInto(t, first, span, l.extend) + } + if t <= first { + return l.stops[0].color + } + for i := 1; i < len(l.stops); i++ { + a, b := l.stops[i-1], l.stops[i] + if t <= b.offset { + if b.offset == a.offset { + return b.color + } + k := (t - a.offset) / (b.offset - a.offset) + return a.color.scaled(1 - k).plus(b.color.scaled(k)) + } + } + return l.stops[len(l.stops)-1].color +} + +// extendInto brings t, outside [first, first+span], back onto the stops: +// repeated, mirrored, or left for the nearest stop to pad. +func extendInto(t, first, span float64, extend tables.Extend) float64 { + u := (t - first) / span + switch extend { + case tables.ExtendRepeat: + u -= math.Floor(u) + case tables.ExtendReflect: + u = math.Abs(u - 2*math.Floor(u/2)) + if u > 1 { + u = 2 - u + } + default: + return t + } + return first + u*span +} + +func fillSolid(dst *image.RGBA, clip *image.Alpha, c rgba) { + for i, cover := range clip.Pix { + if cover != 0 { + blendAt(dst, 4*i, c.scaled(float64(cover)/0xff)) + } + } +} + +func blend(dst *image.RGBA, x, y int, c rgba) { blendAt(dst, dst.PixOffset(x, y), c) } + +// blendAt lays c over the pixel at offset i of dst. +func blendAt(dst *image.RGBA, i int, c rgba) { + d := pixel(dst, i) + setPixel(dst, i, c.plus(d.scaled(1-c.a))) +} + +// over lays src over dst, both the same size. +func over(dst, src *image.RGBA) { + for i := 0; i < len(src.Pix); i += 4 { + blendAt(dst, i, pixel(src, i)) + } +} + +func pixel(img *image.RGBA, i int) rgba { + p := img.Pix[i : i+4 : i+4] + return rgba{float64(p[0]) / 0xff, float64(p[1]) / 0xff, float64(p[2]) / 0xff, float64(p[3]) / 0xff} +} + +func setPixel(img *image.RGBA, i int, c rgba) { + p := img.Pix[i : i+4 : i+4] + for j, v := range [4]float64{c.r, c.g, c.b, c.a} { + p[j] = uint8(math.Round(math.Max(0, math.Min(1, v)) * 0xff)) + } +} + +// compositeLayers merges source onto backdrop, in place, by mode: the +// Porter-Duff operators, and the separable blend modes laid source-over. +func compositeLayers(source, backdrop *image.RGBA, mode tables.CompositeMode) { + for i := 0; i < len(source.Pix); i += 4 { + setPixel(backdrop, i, compositePixel(pixel(source, i), pixel(backdrop, i), mode)) + } +} + +func compositePixel(s, d rgba, mode tables.CompositeMode) rgba { + if fa, fb, ok := porterDuff(mode, s.a, d.a); ok { + return s.scaled(fa).plus(d.scaled(fb)) + } + mix := blendChannel(mode) + // The W3C compositing formula for a separable blend mode, premultiplied. + ch := func(sc, dc float64) float64 { + var b float64 + if s.a > 0 && d.a > 0 { + b = mix(sc/s.a, dc/d.a) + } + return sc*(1-d.a) + dc*(1-s.a) + s.a*d.a*b + } + return rgba{ch(s.r, d.r), ch(s.g, d.g), ch(s.b, d.b), s.a + d.a - s.a*d.a} +} + +// porterDuff is the factors source and backdrop are weighted by in mode, +// false for a blend mode. +func porterDuff(mode tables.CompositeMode, sa, da float64) (float64, float64, bool) { + switch mode { + case tables.CompositeClear: + return 0, 0, true + case tables.CompositeSrc: + return 1, 0, true + case tables.CompositeDest: + return 0, 1, true + case tables.CompositeSrcOver: + return 1, 1 - sa, true + case tables.CompositeDestOver: + return 1 - da, 1, true + case tables.CompositeSrcIn: + return da, 0, true + case tables.CompositeDestIn: + return 0, sa, true + case tables.CompositeSrcOut: + return 1 - da, 0, true + case tables.CompositeDestOut: + return 0, 1 - sa, true + case tables.CompositeSrcAtop: + return da, 1 - sa, true + case tables.CompositeDestAtop: + return 1 - da, sa, true + case tables.CompositeXor: + return 1 - da, 1 - sa, true + case tables.CompositePlus: + return 1, 1, true + } + return 0, 0, false +} + +// blendChannel is mode's mix of one unpremultiplied channel. +// ponytail: the non-separable modes (hue, saturation, color, luminosity) mix +// as normal, the source showing as is; give them their HSL math once an emoji +// font is seen using one. +func blendChannel(mode tables.CompositeMode) func(s, d float64) float64 { + switch mode { + case tables.CompositeScreen: + return func(s, d float64) float64 { return s + d - s*d } + case tables.CompositeMultiply: + return func(s, d float64) float64 { return s * d } + case tables.CompositeDarken: + return math.Min + case tables.CompositeLighten: + return math.Max + case tables.CompositeDifference: + return func(s, d float64) float64 { return math.Abs(s - d) } + case tables.CompositeExclusion: + return func(s, d float64) float64 { return s + d - 2*s*d } + case tables.CompositeOverlay: + return func(s, d float64) float64 { return hardLight(d, s) } + case tables.CompositeHardLight: + return hardLight + case tables.CompositeSoftLight: + return softLight + case tables.CompositeColorDodge: + return colorDodge + case tables.CompositeColorBurn: + return colorBurn + } + return func(s, _ float64) float64 { return s } +} + +func hardLight(s, d float64) float64 { + if s <= 0.5 { + return 2 * s * d + } + return 1 - 2*(1-s)*(1-d) +} + +func softLight(s, d float64) float64 { + if s <= 0.5 { + return d - (1-2*s)*d*(1-d) + } + g := math.Sqrt(d) + if d <= 0.25 { + g = ((16*d-12)*d + 4) * d + } + return d + (2*s-1)*(g-d) +} + +func colorDodge(s, d float64) float64 { + switch { + case d == 0: + return 0 + case s >= 1: + return 1 + } + return math.Min(1, d/(1-s)) +} + +func colorBurn(s, d float64) float64 { + switch { + case d >= 1: + return 1 + case s <= 0: + return 0 + } + return 1 - math.Min(1, (1-d)/s) +} diff --git a/native/glyphs/colr_test.go b/native/glyphs/colr_test.go new file mode 100644 index 00000000..ac049ccc --- /dev/null +++ b/native/glyphs/colr_test.go @@ -0,0 +1,146 @@ +package glyphs + +import ( + "bytes" + "image" + "math" + "testing" + + gotext "github.com/go-text/typesetting/font" + "github.com/go-text/typesetting/font/opentype/tables" +) + +func near(a, b float64) bool { return math.Abs(a-b) < 1e-6 } + +func turns(degrees float64) tables.Coord { return tables.Coord(math.Round(degrees / 180 * (1 << 14))) } + +func TestColorLineExtendsPastItsStops(t *testing.T) { + black, white := rgba{a: 1}, rgba{1, 1, 1, 1} + line := func(extend tables.Extend) colorLine { + return colorLine{stops: []colorStop{{0, black}, {1, white}}, extend: extend} + } + cases := []struct { + name string + extend tables.Extend + t float64 + want float64 + }{ + {"between the stops", tables.ExtendPad, 0.25, 0.25}, + {"pad keeps the nearest stop", tables.ExtendPad, 1.5, 1}, + {"repeat starts over", tables.ExtendRepeat, 1.25, 0.25}, + {"reflect runs back", tables.ExtendReflect, 1.25, 0.75}, + {"reflect below the first stop", tables.ExtendReflect, -0.25, 0.25}, + } + for _, c := range cases { + if got := line(c.extend).at(c.t).r; !near(got, c.want) { + t.Errorf("%s: red at %v = %v, want %v", c.name, c.t, got, c.want) + } + } +} + +func TestLinearGradientRunsPerpendicularToItsRotationPoint(t *testing.T) { + // p2 straight above p0 keeps p0→p1 as is; p1 off the axis is projected + // onto it, so its own y does not count. + at := linearAt(tables.PaintLinearGradient{X0: 0, Y0: 0, X1: 100, Y1: 40, X2: 0, Y2: 100}) + if got, ok := at(50, 30); !ok || !near(got, 0.5) { + t.Errorf("t at (50, 30) = %v, %v; want 0.5", got, ok) + } +} + +func TestRadialGradientGrowsFromItsStartCircle(t *testing.T) { + at := radialAt(tables.PaintRadialGradient{X0: 0, Y0: 0, Radius0: 0, X1: 0, Y1: 0, Radius1: 100}) + if got, ok := at(30, 40); !ok || !near(got, 0.5) { + t.Errorf("t at distance 50 = %v, %v; want 0.5", got, ok) + } +} + +func TestSweepGradientTurnsCounterClockwise(t *testing.T) { + // Angles are stored as degrees/180 - 1: -1 is 0°, 1 is 360°. + at := sweepAt(tables.PaintSweepGradient{StartAngle: turns(-180), EndAngle: turns(180)}) + if got, ok := at(0, 10); !ok || !near(got, 0.25) { + t.Errorf("t straight above the center = %v, %v; want 0.25", got, ok) + } +} + +func TestCompositeModes(t *testing.T) { + red, halfBlue := rgba{r: 1, a: 1}, rgba{b: 0.5, a: 0.5} + if got := compositePixel(red, halfBlue, tables.CompositeSrcIn); got != (rgba{r: 0.5, a: 0.5}) { + t.Errorf("src-in = %+v, want the source at the backdrop's alpha", got) + } + if got := compositePixel(red, halfBlue, tables.CompositeDestOut); got != (rgba{}) { + t.Errorf("dest-out under an opaque source = %+v, want nothing", got) + } + gray := rgba{0.5, 0.5, 0.5, 1} + if got := compositePixel(gray, gray, tables.CompositeMultiply); !near(got.r, 0.25) || got.a != 1 { + t.Errorf("multiply = %+v, want 0.25 gray", got) + } +} + +func TestAffineThenAppliesTheInnerTransformFirst(t *testing.T) { + m := scaleBy(2, 2).then(translate(10, 0)) + if x, y := m.apply(1, 1); !near(x, 22) || !near(y, 2) { + t.Errorf("scale after translate maps (1, 1) to (%v, %v), want (22, 2)", x, y) + } + inv, ok := m.invert() + if x, y := inv.apply(22, 2); !ok || !near(x, 1) || !near(y, 1) { + t.Errorf("inverse maps (22, 2) to (%v, %v), want (1, 1)", x, y) + } + if x, y := rotate(0.5).apply(1, 0); !near(x, 0) || !near(y, 1) { + t.Errorf("a quarter turn maps (1, 0) to (%v, %v), want (0, 1)", x, y) + } +} + +// A PaintGlyph fills its glyph's outline, placed by the transforms above it, +// and nothing outside it. +func TestPainterFillsAGlyphWhereItsTransformsPutIt(t *testing.T) { + r := sizedRasterizer(t) + face, err := gotext.ParseTTF(bytes.NewReader(r.styles[0].file.data)) + if err != nil { + t.Fatal(err) + } + gid, ok := face.NominalGlyph('I') + if !ok { + t.Fatal("the monospace face has no I") + } + cp := colrPainter{ + face: face, + palette: []tables.ColorRecord{{Red: 0xff, Alpha: 0xff}}, + bounds: image.Rect(0, 0, 64, 64), + } + upem := float64(face.Upem()) + toPixels := affine{xx: 32 / upem, yy: -32 / upem, dy: 32} + glyph := tables.PaintGlyph{GlyphID: uint16(gid), Paint: tables.PaintSolid{PaletteIndex: 0, Alpha: 1 << 14}} + moved := tables.PaintTranslate{Paint: glyph, Dx: int16(upem), Dy: -int16(upem / 2)} + + at := image.NewRGBA(cp.bounds) + if err := cp.paint(at, moved, toPixels, cp.opaque(), 0); err != nil { + t.Fatal(err) + } + painted := image.Rectangle{} + for y := range 64 { + for x := range 64 { + if p := at.RGBAAt(x, y); p.A > 0 { + if p.G != 0 || p.B != 0 { + t.Fatalf("pixel (%d, %d) = %v, want palette red", x, y, p) + } + painted = painted.Union(image.Rect(x, y, x+1, y+1)) + } + } + } + // The I sits on the baseline at y 32 before the move; one em right and + // half an em down puts it between x 32 and 64, under y 48. + if painted.Empty() || painted.Min.X < 32 || painted.Max.Y > 49 || painted.Min.Y < 16 { + t.Errorf("the glyph painted %v, want it right of x 32 and resting on y 48", painted) + } +} + +func TestPainterStopsOnACycle(t *testing.T) { + cp := colrPainter{bounds: image.Rect(0, 0, 1, 1)} + var cycle tables.PaintTable = tables.PaintSolid{} + for range maxPaintDepth + 2 { + cycle = tables.PaintTranslate{Paint: cycle} + } + if err := cp.paint(image.NewRGBA(cp.bounds), cycle, affine{xx: 1, yy: 1}, cp.opaque(), 0); err == nil { + t.Error("a paint graph deeper than maxPaintDepth drew instead of failing") + } +} diff --git a/native/glyphs/files.go b/native/glyphs/files.go index 81a3aeaf..630d8d64 100644 --- a/native/glyphs/files.go +++ b/native/glyphs/files.go @@ -19,8 +19,9 @@ type fontFile struct { // sf is the file's first face; sfnt keeps reading from data. sf *sfnt.Font data []byte - // color is the first face as go-text reads it, for the color bitmaps - // sfnt cannot (CBDT, sbix); nil when the file has none. + // color is the first face as go-text reads it, for the color glyphs sfnt + // cannot draw (CBDT and sbix bitmaps, COLR layers); nil when the file has + // none. color *gotext.Font } @@ -29,8 +30,9 @@ var fontFiles = struct { byPath map[string]*fontFile }{byPath: map[string]*fontFile{}} -// colorTables are the tables that hold PNG glyphs. -var colorTables = [...]ot.Tag{ot.MustNewTag("CBDT"), ot.MustNewTag("sbix")} +// colorTables are the tables that hold color glyphs: PNG bitmaps (CBDT, sbix) +// or layered outlines (COLR). +var colorTables = [...]ot.Tag{ot.MustNewTag("CBDT"), ot.MustNewTag("sbix"), ot.MustNewTag("COLR")} func openFontFile(path string) (*fontFile, error) { fontFiles.Lock() @@ -52,14 +54,14 @@ func openFontFile(path string) (*fontFile, error) { } color, err := parseColor(data) if err != nil { - return nil, fmt.Errorf("parse color bitmaps of %s: %w", path, err) + return nil, fmt.Errorf("parse color glyphs of %s: %w", path, err) } f := &fontFile{sf: sf, data: data, color: color} fontFiles.byPath[path] = f return f, nil } -// parseColor parses the first face of data when it carries PNG glyphs, and +// parseColor parses the first face of data when it carries color glyphs, and // only that face: reading the headers of a collection costs nothing, parsing // all of its faces is what a CJK collection makes expensive. func parseColor(data []byte) (*gotext.Font, error) { diff --git a/native/glyphs/raster.go b/native/glyphs/raster.go index 3b4dfb16..202bce26 100644 --- a/native/glyphs/raster.go +++ b/native/glyphs/raster.go @@ -51,7 +51,7 @@ type Key struct { type fontFace struct { file *fontFile face font.Face - // bitmaps reads file's color bitmaps; nil when it has none. + // bitmaps reads file's color glyphs; nil when it has none. bitmaps *gotext.Face } @@ -198,10 +198,11 @@ func (r *Rasterizer) Mask(k Key) (*Mask, error) { if err != nil { return nil, err } - // The color bitmap wins over the outline: Apple Color Emoji carries both, - // and its outline is a monochrome stand-in. + // A color glyph wins over the outline: Apple Color Emoji carries both, and + // its outline is a monochrome stand-in, as Segoe UI Emoji's base glyph is + // for its COLR layers. if f != nil { - if m.Color, m.Off, err = r.colorBitmap(f, k.R); err != nil { + if m.Color, m.Off, err = r.colorGlyph(f, k.R); err != nil { return nil, err } } @@ -217,6 +218,16 @@ func (r *Rasterizer) Mask(k Key) (*Mask, error) { return m, nil } +// colorGlyph draws c in its own colors from f's PNG bitmap or, failing one, +// its COLR layers; nil when f has neither for c. +func (r *Rasterizer) colorGlyph(f *fontFace, c rune) (*image.RGBA, image.Point, error) { + img, off, err := r.colorBitmap(f, c) + if img != nil || err != nil { + return img, off, err + } + return r.colorLayers(f, c) +} + // colorBitmap scales c's PNG bitmap to fit two cells, the width of an emoji, // centered in them. It returns nil when f has no PNG bitmap for c. The grid // carries no width per cell, so an emoji that is narrow (text presentation, From 753841f934c03339e07d7ae21d99b9d3d63d9a5b Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 19 Oct 2026 10:25:31 -0300 Subject: [PATCH 3/5] test(native): read the monospace face as a collection in the COLR painter test macOS's Menlo is a .ttc, which ParseTTF refuses. --- native/glyphs/colr_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/native/glyphs/colr_test.go b/native/glyphs/colr_test.go index ac049ccc..8cf379f7 100644 --- a/native/glyphs/colr_test.go +++ b/native/glyphs/colr_test.go @@ -94,10 +94,12 @@ func TestAffineThenAppliesTheInnerTransformFirst(t *testing.T) { // and nothing outside it. func TestPainterFillsAGlyphWhereItsTransformsPutIt(t *testing.T) { r := sizedRasterizer(t) - face, err := gotext.ParseTTF(bytes.NewReader(r.styles[0].file.data)) + // The rasterizer reads a collection's first face, macOS's Menlo is one. + faces, err := gotext.ParseTTC(bytes.NewReader(r.styles[0].file.data)) if err != nil { t.Fatal(err) } + face := faces[0] gid, ok := face.NominalGlyph('I') if !ok { t.Fatal("the monospace face has no I") From 6d9031e1198084e5b974c94cd250cfae32c22e32 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 19 Oct 2026 10:32:15 -0300 Subject: [PATCH 4/5] test(project): keep git's detached maintenance out of the repository template Since git 2.46 a commit starts `git maintenance run --auto --detach`, which outlives it holding objects/maintenance.lock. initRepo copying the template right after listed the lock and failed to open it once it was gone: "copy repository template: open .git/objects/maintenance.lock: no such file or directory". Measured with the template's build-then-copy under two CPUs of contention: 3 failures in 4800 before, 0 in 4800 with maintenance.auto off, and the commit no longer spawns maintenance at all. --- internal/project/worktree_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/project/worktree_test.go b/internal/project/worktree_test.go index e83d8bcd..16740648 100644 --- a/internal/project/worktree_test.go +++ b/internal/project/worktree_test.go @@ -65,6 +65,12 @@ func commitTemplate(dir string, run func(args ...string) error) error { if err := run("config", "core.autocrlf", "false"); err != nil { return err } + // A commit starts `git maintenance run --auto --detach` (git 2.46+), which + // outlives it holding objects/maintenance.lock: initRepo's copy of the + // template then lists the lock and finds it gone when it opens it. + if err := run("config", "maintenance.auto", "false"); err != nil { + return err + } if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\n"), 0o644); err != nil { return err } From 0c6d9ab0c09afd9e61c912fe9148129e1249e0da Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 19 Oct 2026 10:39:15 -0300 Subject: [PATCH 5/5] test(project): release the waitdelay grandchild before the test ends TestSubprocessBudgetOutlivesAGrandchild left its grandchild, a re-run of project.test, asleep for ten seconds past the test. A package that finished sooner left it running the binary go test then deletes, which Windows refuses: "go: unlinkat ...\\project.test.exe: Access is denied" (os-tests on #829, run 39256549322). The grandchild now holds the pipe until the test's cleanup releases it, and the cleanup waits for it to exit. Measured by running the two TestSubprocess tests 20 times and checking for a project.test process right after each run: 20/20 left one before, 0/20 after. --- internal/project/waitdelay_test.go | 55 ++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/internal/project/waitdelay_test.go b/internal/project/waitdelay_test.go index 2f8260f4..a3f25f43 100644 --- a/internal/project/waitdelay_test.go +++ b/internal/project/waitdelay_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "os/exec" + "path/filepath" "testing" "time" ) @@ -11,12 +12,31 @@ import ( // helperEnv selects which half of the process tree a helper run plays. const helperEnv = "LICH_WAITDELAY_HELPER" +// releaseEnv names the directory the grandchild watches: the test writes +// releaseFile there once it is done with the grandchild, which then exits and +// writes exitedFile. Left to sleep out its hold, the grandchild outlived a +// package that finished sooner, still running the test binary go test was +// deleting: "unlinkat ... project.test.exe: Access is denied" on Windows. +const ( + releaseEnv = "LICH_WAITDELAY_RELEASE" + releaseFile = "release" + exitedFile = "exited" +) + +// grandchildHold is how long the grandchild holds the pipe at most, far past +// any budget the parent could set; pollEvery is how often it and the test look +// for each other's file. +const ( + grandchildHold = 10 * time.Second + pollEvery = 20 * time.Millisecond +) + // TestSubprocessHelper is not a test of anything: it is the child and the // grandchild TestSubprocessBudgetOutlivesAGrandchild spawns, re-executing this // binary because a shell one-liner would not run on Windows. As "child" it // starts a grandchild on its own stdout — the pipe the parent is reading — and -// exits at once; as "grandchild" it holds that pipe open far past any budget -// the parent could set. A plain run does nothing. +// exits at once; as "grandchild" it holds that pipe open until the test +// releases it. A plain run does nothing. func TestSubprocessHelper(t *testing.T) { t.Parallel() switch os.Getenv(helperEnv) { @@ -29,21 +49,44 @@ func TestSubprocessHelper(t *testing.T) { } os.Exit(0) case "grandchild": - time.Sleep(10 * time.Second) + dir := os.Getenv(releaseEnv) + awaitFile(filepath.Join(dir, releaseFile), grandchildHold) + if err := os.WriteFile(filepath.Join(dir, exitedFile), nil, 0o644); err != nil { + os.Exit(1) + } os.Exit(0) } } +// awaitFile reports whether path appeared within limit. +func awaitFile(path string, limit time.Duration) bool { + for deadline := time.Now().Add(limit); time.Now().Before(deadline); time.Sleep(pollEvery) { + if _, err := os.Stat(path); err == nil { + return true + } + } + return false +} + // A budget is only real if it bounds the call rather than the child. The child -// here exits immediately and the grandchild behind it keeps stdout open for ten -// seconds: with nothing closing that pipe, Output waits all ten and reports +// here exits immediately and the grandchild behind it keeps stdout open until +// released: with nothing closing that pipe, Output waits for it and reports // success, deadline or none. func TestSubprocessBudgetOutlivesAGrandchild(t *testing.T) { t.Parallel() + dir := t.TempDir() + t.Cleanup(func() { + if err := os.WriteFile(filepath.Join(dir, releaseFile), nil, 0o644); err != nil { + t.Fatalf("release the grandchild: %v", err) + } + if !awaitFile(filepath.Join(dir, exitedFile), grandchildHold) { + t.Fatal("the grandchild did not exit once released") + } + }) ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() cmd := commandContext(ctx, os.Args[0], "-test.run=TestSubprocessHelper") - cmd.Env = append(cmd.Env, helperEnv+"=child") + cmd.Env = append(cmd.Env, helperEnv+"=child", releaseEnv+"="+dir) start := time.Now() _, err := cmd.Output()