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..1175c3a6 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,92 @@ 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 + + # 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" + + - 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 b1aa06c8..fa91bc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- Emoji in the native window's terminal are drawn in color on Windows, where + they came out as plain outlines. - The update command offered to a macOS install now taps `lichdotdev/tap` before upgrading. An install from the old `omartelo/tap` failed with "Cask 'lichdotdev/tap/lich' is unavailable", since Homebrew does not follow a moved tap on its own; tap it once with `brew tap lichdotdev/tap` to upgrade from an older lich. - In the native window, "Unfold long pastes" now unfolds Claude Code and Kiro CLI pastes: the clipboard, a batch of review comments, the prompt a pull request or issue opens a session with, and dropped or attached file paths. 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() 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 } 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 33cb9e1a..549cf3e0 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) 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..8cf379f7 --- /dev/null +++ b/native/glyphs/colr_test.go @@ -0,0 +1,148 @@ +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) + // 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") + } + 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,