diff --git a/internal/gpu/glyph_mask_engine.go b/internal/gpu/glyph_mask_engine.go index 88b8a313..9f8de3bf 100644 --- a/internal/gpu/glyph_mask_engine.go +++ b/internal/gpu/glyph_mask_engine.go @@ -123,11 +123,14 @@ func (e *GlyphMaskEngine) LayoutText( if fontSize <= 0 { fontSize = face.Size() } + if runs, ok := fallbackFontRuns(face, s); ok { + if !fontRunsHaveSources(runs) { + return GlyphMaskBatch{}, fmt.Errorf("glyph mask: fallback face has no FontSource") + } + return e.layoutMultiFaceText(runs, x, y, color, matrix, deviceScale, false), nil + } fontSource := face.Source() if fontSource == nil { - // MultiFace or other composite face — no single FontSource. - // Full per-font-run support: ADR-065. For now, signal caller to - // fall back to CPU path which handles MultiFace correctly. return GlyphMaskBatch{}, fmt.Errorf("glyph mask: face has no FontSource (MultiFace requires ADR-065)") } fontID := computeGlyphMaskFontID(fontSource) @@ -187,6 +190,12 @@ func (e *GlyphMaskEngine) LayoutTextAliased( if fontSize <= 0 { fontSize = face.Size() } + if runs, ok := fallbackFontRuns(face, s); ok { + if !fontRunsHaveSources(runs) { + return GlyphMaskBatch{}, fmt.Errorf("glyph mask aliased: fallback face has no FontSource") + } + return e.layoutMultiFaceText(runs, x, y, color, matrix, deviceScale, true), nil + } fontSource := face.Source() if fontSource == nil { return GlyphMaskBatch{}, fmt.Errorf("glyph mask aliased: face has no FontSource (MultiFace requires ADR-065)") @@ -275,7 +284,7 @@ func (e *GlyphMaskEngine) layoutShapedGlyphs( } fontSource := face.Source() if fontSource == nil { - return GlyphMaskBatch{}, fmt.Errorf("glyph mask shaped: face has no FontSource (MultiFace requires ADR-065)") + return e.layoutFallbackShapedGlyphs(face, glyphs, x, y, color, matrix, deviceScale, isCJK, aliased) } fontID := computeGlyphMaskFontID(fontSource) parsed := fontSource.Parsed() @@ -297,6 +306,211 @@ func (e *GlyphMaskEngine) layoutShapedGlyphs( return e.layoutGlyphs(glyphs, x, y, fontSize, fontID, parsed, hinting, useLCD, lcdLayout, face.Variations(), &lcdFilter, batchColor, matrix, deviceScale, isCJK, aliased), nil } +type fontRunProvider interface { + FontRuns(string) []text.FontRun +} + +func fallbackFontRuns(face text.Face, s string) ([]text.FontRun, bool) { + if face.Source() != nil { + return nil, false + } + provider, ok := face.(fontRunProvider) + if !ok { + return nil, false + } + return provider.FontRuns(s), true +} + +func fontRunsHaveSources(runs []text.FontRun) bool { + for _, run := range runs { + if run.Face == nil || run.Face.Source() == nil { + return false + } + } + return true +} + +func (e *GlyphMaskEngine) layoutFallbackShapedGlyphs( + face text.Face, + glyphs []text.ShapedGlyph, + x, y float64, + color gg.RGBA, + matrix gg.Matrix, + deviceScale float64, + isCJK bool, + aliased bool, +) (GlyphMaskBatch, error) { + if _, ok := face.(fontRunProvider); !ok { + return GlyphMaskBatch{}, fmt.Errorf("glyph mask shaped: face has no FontSource (MultiFace requires ADR-065)") + } + var defaultFace text.Face + if multi, ok := face.(*text.MultiFace); ok { + defaultFace = multi.FaceForRune(0) + } + for _, glyph := range glyphs { + owner := glyph.Face + if owner == nil { + owner = defaultFace + } + if owner == nil || owner.Source() == nil { + return GlyphMaskBatch{}, fmt.Errorf("glyph mask shaped: fallback face has no FontSource") + } + } + return e.layoutMultiFaceShapedGlyphs(face, glyphs, x, y, color, matrix, deviceScale, isCJK, aliased), nil +} + +// layoutMultiFaceText keeps fallback glyphs on the GPU by rasterizing each +// source-font run into the same atlas. The resulting quads are appended in +// source order, so one queued GlyphMaskBatch preserves batching and advances. +// e.mu must be held by the caller. +func (e *GlyphMaskEngine) layoutMultiFaceText( + runs []text.FontRun, + x, y float64, + color gg.RGBA, + matrix gg.Matrix, + deviceScale float64, + aliased bool, +) GlyphMaskBatch { + if len(runs) == 0 { + return GlyphMaskBatch{} + } + + var result GlyphMaskBatch + for _, run := range runs { + if run.Face == nil { + continue + } + var shaped []text.ShapedGlyph + for glyph := range run.Face.Glyphs(run.Text) { + shaped = append(shaped, text.ShapedGlyph{ + Face: run.Face, + GID: glyph.GID, + X: run.Offset + glyph.X, + Y: glyph.Y, + IsCJK: text.IsCJKRune(glyph.Rune), + }) + } + batch := e.layoutShapedGlyphsForFace(run.Face, shaped, x, y, color, matrix, deviceScale, run.IsCJK, aliased) + if len(batch.Quads) == 0 { + continue + } + if len(result.Quads) == 0 { + result = batch + } else { + result.Quads = append(result.Quads, batch.Quads...) + result.IsLCD = result.IsLCD || batch.IsLCD + } + } + return result +} + +// layoutMultiFaceShapedGlyphs dispatches source-aware shaped glyphs in +// contiguous face groups. Glyphs without an embedded Face retain the legacy +// contract and use the first fallback face. +// e.mu must be held by the caller. +func (e *GlyphMaskEngine) layoutMultiFaceShapedGlyphs( + face text.Face, + glyphs []text.ShapedGlyph, + x, y float64, + color gg.RGBA, + matrix gg.Matrix, + deviceScale float64, + isCJK bool, + aliased bool, +) GlyphMaskBatch { + if face == nil || len(glyphs) == 0 { + return GlyphMaskBatch{} + } + var defaultFace text.Face + if multi, ok := face.(*text.MultiFace); ok { + defaultFace = multi.FaceForRune(0) + } + var result GlyphMaskBatch + start := 0 + for start < len(glyphs) { + owner := glyphs[start].Face + groupSourceAware := owner != nil + if owner == nil { + owner = defaultFace + } + end := start + 1 + groupIsCJK := glyphs[start].IsCJK + for end < len(glyphs) { + next := glyphs[end].Face + if next == nil { + next = defaultFace + } + if next != owner || glyphs[end].IsCJK != groupIsCJK { + break + } + end++ + } + group := make([]text.ShapedGlyph, end-start) + copy(group, glyphs[start:end]) + for i := range group { + if group[i].Face == nil { + group[i].Face = owner + } + } + // Keep the explicit caller hint only for legacy glyphs that predate the + // per-glyph IsCJK bit. Source-aware Shape results carry the exact script + // class and must not let an adjacent Latin glyph force full hinting on a + // CJK glyph (or vice versa). + groupCJK := groupIsCJK + if !groupSourceAware { + groupCJK = isCJK + } + batch := e.layoutShapedGlyphsForFace(owner, group, x, y, color, matrix, deviceScale, groupCJK, aliased) + if len(batch.Quads) > 0 { + if len(result.Quads) == 0 { + result = batch + } else { + result.Quads = append(result.Quads, batch.Quads...) + result.IsLCD = result.IsLCD || batch.IsLCD + } + } + start = end + } + return result +} + +// layoutShapedGlyphsForFace is the single-source implementation shared by +// fallback text and source-aware shaped runs. e.mu must be held by the caller. +func (e *GlyphMaskEngine) layoutShapedGlyphsForFace( + face text.Face, + glyphs []text.ShapedGlyph, + x, y float64, + color gg.RGBA, + matrix gg.Matrix, + deviceScale float64, + isCJK bool, + aliased bool, +) GlyphMaskBatch { + if face == nil || len(glyphs) == 0 { + return GlyphMaskBatch{} + } + fontSize := face.Size() * deviceScale + if fontSize <= 0 { + fontSize = face.Size() + } + fontSource := face.Source() + if fontSource == nil { + return GlyphMaskBatch{} + } + fontID := computeGlyphMaskFontID(fontSource) + hinting := selectGlyphMaskHinting(fontSize, matrix, isCJK, deviceScale) + useLCD := !aliased && e.lcdLayout != text.LCDLayoutNone && selectGlyphMaskLCD(fontSize, matrix) + premul := color.Premultiply() + batchColor := [4]float32{float32(premul.R), float32(premul.G), float32(premul.B), float32(premul.A)} + lcdLayout := e.lcdLayout + lcdFilter := e.lcdFilter + if aliased { + lcdLayout = text.LCDLayoutNone + lcdFilter = text.LCDFilter{} + } + return e.layoutGlyphs(glyphs, x, y, fontSize, fontID, fontSource.Parsed(), hinting, useLCD, lcdLayout, face.Variations(), &lcdFilter, batchColor, matrix, deviceScale, isCJK, aliased) +} + // snapXGrid precomputes the integer device-space X position for each glyph by // accumulating ROUNDED advances. Rounding each glyph's absolute position // independently would make adjacent advances jitter by ±1px and open visible diff --git a/internal/gpu/glyph_mask_engine_test.go b/internal/gpu/glyph_mask_engine_test.go index 5ad52dd7..d75b3ab4 100644 --- a/internal/gpu/glyph_mask_engine_test.go +++ b/internal/gpu/glyph_mask_engine_test.go @@ -53,6 +53,58 @@ func TestGlyphMaskEngineLayoutShapedGlyphsAliasedUsesBinaryCoverage(t *testing.T } } +func TestGlyphMaskEngineMultiFaceStaysOnGPUAndPreservesShapedRuns(t *testing.T) { + source, err := text.NewFontSource(goregular.TTF) + if err != nil { + t.Fatalf("NewFontSource: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + latin := text.NewFilteredFace(source.Face(20), text.RangeBasicLatin) + cyrillic := text.NewFilteredFace(source.Face(20), text.RangeCyrillic) + face, err := text.NewMultiFace(latin, cyrillic) + if err != nil { + t.Fatalf("NewMultiFace: %v", err) + } + + engine := NewGlyphMaskEngine() + batch, err := engine.LayoutText(face, "AБ", 0, 24, gg.RGBA{A: 1}, gg.Identity(), 1) + if err != nil { + t.Fatalf("LayoutText(MultiFace): %v", err) + } + if len(batch.Quads) < 2 { + t.Fatalf("LayoutText(MultiFace) emitted %d quads, want at least 2", len(batch.Quads)) + } + + shaped := text.Shape("AБ", face) + if len(shaped) < 2 || shaped[0].Face != latin || shaped[1].Face != cyrillic { + t.Fatalf("Shape did not retain source faces: %#v", shaped) + } + shapedBatch, err := engine.LayoutShapedGlyphs(face, shaped, 0, 24, gg.RGBA{A: 1}, gg.Identity(), 1, false) + if err != nil { + t.Fatalf("LayoutShapedGlyphs(MultiFace): %v", err) + } + if len(shapedBatch.Quads) < 2 { + t.Fatalf("LayoutShapedGlyphs(MultiFace) emitted %d quads, want at least 2", len(shapedBatch.Quads)) + } + + filtered := text.NewFilteredFace(face, text.RangeBasicLatin) + filteredBatch, err := engine.LayoutText(filtered, "AБ", 0, 24, gg.RGBA{A: 1}, gg.Identity(), 1) + if err != nil { + t.Fatalf("LayoutText(FilteredFace(MultiFace)): %v", err) + } + if len(filteredBatch.Quads) == 0 { + t.Fatal("LayoutText(FilteredFace(MultiFace)) dropped the allowed fallback run") + } + filteredGlyphs := text.Shape("AБ", filtered) + filteredShaped, err := engine.LayoutShapedGlyphs(filtered, filteredGlyphs, 0, 24, gg.RGBA{A: 1}, gg.Identity(), 1, false) + if err != nil { + t.Fatalf("LayoutShapedGlyphs(FilteredFace(MultiFace)): %v", err) + } + if len(filteredShaped.Quads) == 0 { + t.Fatal("LayoutShapedGlyphs(FilteredFace(MultiFace)) dropped the allowed run") + } +} + func TestSelectGlyphMaskLCD(t *testing.T) { // ADR-060 / BUG-TEXT-001: selectGlyphMaskLCD always returns false for // the GPU pipeline because standard SrcOver blend cannot do per-channel diff --git a/internal/gpu/gpu_text.go b/internal/gpu/gpu_text.go index 8bd55ceb..8a360a20 100644 --- a/internal/gpu/gpu_text.go +++ b/internal/gpu/gpu_text.go @@ -113,15 +113,15 @@ func (e *GPUTextEngine) LayoutText( e.mu.Lock() defer e.mu.Unlock() + if face.Source() == nil { + return e.layoutFallbackText(face, s, x, y, color, matrix) + } logicalSize := face.Size() if logicalSize <= 0 { logicalSize = 16 // fallback: never zero } fontSource := face.Source() - if fontSource == nil { - return TextBatch{}, fmt.Errorf("MSDF text: face has no FontSource (MultiFace requires ADR-065)") - } fontID := computeFontID(fontSource) // ADR-054: pass variations for variable font gvar deltas. @@ -252,6 +252,100 @@ func (e *GPUTextEngine) LayoutText( }, nil } +func (e *GPUTextEngine) layoutFallbackText( + face text.Face, + s string, + x, y float64, + color gg.RGBA, + matrix gg.Matrix, +) (TextBatch, error) { + runs, ok := fallbackFontRuns(face, s) + if !ok { + return TextBatch{}, fmt.Errorf("MSDF text: face has no FontSource (MultiFace requires ADR-065)") + } + if !fontRunsHaveSources(runs) { + return TextBatch{}, fmt.Errorf("MSDF text: fallback face has no FontSource") + } + // A TextBatch has one atlas binding. Keep all fallback runs in the shared + // Latin atlas so the complete string remains one ordered GPU batch (including + // mixed-script fallback) rather than silently downgrading the operation to CPU. + return e.layoutMultiFaceText(runs, x, y, color, matrix), nil +} + +// layoutMultiFaceText lays out source-aware fallback runs into one MSDF atlas +// and one ordered batch. A batch can bind only one atlas texture, so fallback +// runs use the shared Latin atlas here; glyph IDs remain source-qualified by +// each run's FontID and are never interpreted using another face's parser. +// e.mu must be held by the caller. +func (e *GPUTextEngine) layoutMultiFaceText( + runs []text.FontRun, + x, y float64, + color gg.RGBA, + matrix gg.Matrix, +) TextBatch { + if len(runs) == 0 { + return TextBatch{} + } + + activeAtlas := e.atlasManager + atlasConfig := activeAtlas.Config() + refSize := float64(e.msdfSize) + var quads []TextQuad + for _, run := range runs { + if run.Face == nil { + continue + } + fontSource := run.Face.Source() + if fontSource == nil { + continue + } + logicalSize := run.Face.Size() + if logicalSize <= 0 { + logicalSize = 16 + } + fontID := computeFontID(fontSource) + variations := run.Face.Variations() + varHash := text.VariationHash(variations) + ratio := logicalSize / refSize + for glyph := range run.Face.Glyphs(run.Text) { + outline, err := e.extractor.ExtractOutlineHintedVar(fontSource.Parsed(), glyph.GID, refSize, text.HintingNone, variations) + if err != nil || outline == nil || outline.IsEmpty() { + continue + } + key := msdf.GlyphKey{ + FontID: fontID, + GlyphID: uint16(glyph.GID), //nolint:gosec // GlyphID is uint16 + Size: int16(e.msdfSize), //nolint:gosec // msdfSize fits int16 + VariationHash: varHash, + } + region, err := activeAtlas.Get(key, outline) + if err != nil { + slogger().Warn("MSDF fallback atlas get failed", "gid", glyph.GID, "err", err) + continue + } + if region.PlaneMaxX <= region.PlaneMinX || region.PlaneMaxY <= region.PlaneMinY { + continue + } + qx0 := float32(x + run.Offset + glyph.X + float64(region.PlaneMinX)*ratio) + qx1 := float32(x + run.Offset + glyph.X + float64(region.PlaneMaxX)*ratio) + qy0 := float32(y + float64(region.PlaneMinY)*ratio) + qy1 := float32(y + float64(region.PlaneMaxY)*ratio) + quads = append(quads, TextQuad{X0: qx0, Y0: qy0, X1: qx1, Y1: qy1, U0: region.U0, V0: region.V0, U1: region.U1, V1: region.V1}) + } + } + if len(quads) == 0 { + return TextBatch{} + } + return TextBatch{ + Quads: quads, + Color: color, + Transform: matrix, + AtlasIndex: 0, + PxRange: e.pxRange, + AtlasSize: float32(atlasConfig.Size), + } +} + // cjkAtlasOffset is the index offset for CJK atlas pages. // Latin atlas pages: 0..N-1, CJK atlas pages: cjkAtlasOffset..cjkAtlasOffset+M-1. const cjkAtlasOffset = 100 diff --git a/internal/gpu/gpu_text_test.go b/internal/gpu/gpu_text_test.go index c5cb6e13..d6916b62 100644 --- a/internal/gpu/gpu_text_test.go +++ b/internal/gpu/gpu_text_test.go @@ -5,6 +5,7 @@ package gpu import ( "testing" + "github.com/gogpu/gg" "github.com/gogpu/gg/text" "golang.org/x/image/font/gofont/gobold" "golang.org/x/image/font/gofont/goregular" @@ -61,3 +62,35 @@ func TestComputeFontID_SameFontStableID(t *testing.T) { t.Errorf("same font source should produce stable ID: %d != %d", id1, id2) } } + +func TestGPUTextEngineMultiFaceKeepsFallbackGlyphsBatched(t *testing.T) { + source, err := text.NewFontSource(goregular.TTF) + if err != nil { + t.Fatalf("NewFontSource: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + latin := text.NewFilteredFace(source.Face(20), text.RangeBasicLatin) + cyrillic := text.NewFilteredFace(source.Face(20), text.RangeCyrillic) + face, err := text.NewMultiFace(latin, cyrillic) + if err != nil { + t.Fatalf("NewMultiFace: %v", err) + } + + engine := NewGPUTextEngine() + batch, err := engine.LayoutText(face, "AБ", 0, 24, gg.RGBA{A: 1}, gg.Identity(), 1) + if err != nil { + t.Fatalf("LayoutText(MultiFace): %v", err) + } + if len(batch.Quads) < 2 { + t.Fatalf("LayoutText(MultiFace) emitted %d quads, want at least 2", len(batch.Quads)) + } + + filtered := text.NewFilteredFace(face, text.RangeBasicLatin) + filteredBatch, err := engine.LayoutText(filtered, "AБ", 0, 24, gg.RGBA{A: 1}, gg.Identity(), 1) + if err != nil { + t.Fatalf("LayoutText(FilteredFace(MultiFace)): %v", err) + } + if len(filteredBatch.Quads) == 0 { + t.Fatal("LayoutText(FilteredFace(MultiFace)) dropped the allowed run") + } +} diff --git a/scene/text.go b/scene/text.go index 9d8f4c29..4283d241 100644 --- a/scene/text.go +++ b/scene/text.go @@ -143,20 +143,34 @@ func (r *TextRenderer) RenderGlyph(glyph text.ShapedGlyph, face text.Face) (*Ren config := r.config r.mu.RUnlock() - // Get the font source and size - source := face.Source() + // A source-aware shaped glyph owns its local GID. Prefer that owner over + // the run face so composite runs do not parse a fallback GID through the + // primary font's cmap/outline tables. + owner := face + if glyph.Face != nil { + owner = glyph.Face + } + if owner == nil { + return nil, &text.FontError{Reason: "face is nil"} + } + + // Get the font source and size from the owning face. + source := owner.Source() if source == nil { return nil, &text.FontError{Reason: "face has no font source"} } - size := face.Size() + size := owner.Size() parsed := source.Parsed() + if parsed == nil { + return nil, &text.FontError{Reason: "face has no parsed font"} + } cache := r.ensureCache() fontID := computeSceneTextFontID(source) sizeKey := computeSizeKey(size) // ADR-054: pass variations for variable font gvar deltas. - variations := face.Variations() + variations := owner.Variations() varHash := text.VariationHash(variations) cacheKey := text.OutlineCacheKey{ @@ -205,6 +219,9 @@ func (r *TextRenderer) RenderGlyphs(glyphs []text.ShapedGlyph, face text.Face) ( if len(glyphs) == 0 { return nil, nil } + if face == nil { + return nil, &text.FontError{Reason: "face is nil"} + } r.mu.RLock() config := r.config @@ -213,10 +230,18 @@ func (r *TextRenderer) RenderGlyphs(glyphs []text.ShapedGlyph, face text.Face) ( // Get the font source and size source := face.Source() if source == nil { - return nil, &text.FontError{Reason: "face has no font source"} + return r.renderGlyphsWithOwners(glyphs, face) } - size := face.Size() parsed := source.Parsed() + if parsed == nil { + return nil, &text.FontError{Reason: "face has no parsed font"} + } + for i := range glyphs { + if glyphs[i].Face != nil && glyphs[i].Face != face { + return r.renderGlyphsWithOwners(glyphs, face) + } + } + size := face.Size() cache := r.ensureCache() fontID := computeSceneTextFontID(source) @@ -270,6 +295,22 @@ func (r *TextRenderer) RenderGlyphs(glyphs []text.ShapedGlyph, face text.Face) ( return rendered, nil } +// renderGlyphsWithOwners preserves source identity for composite shaped runs. +// RenderGlyph performs the owner/source lookup and keeps output order (with +// nil paths for empty glyphs) while allowing each fallback face to use its own +// outline cache and variation instance. +func (r *TextRenderer) renderGlyphsWithOwners(glyphs []text.ShapedGlyph, face text.Face) ([]*RenderedGlyph, error) { + rendered := make([]*RenderedGlyph, len(glyphs)) + for i := range glyphs { + glyph, err := r.RenderGlyph(glyphs[i], face) + if err != nil { + return nil, err + } + rendered[i] = glyph + } + return rendered, nil +} + // RenderRun converts a shaped run to renderable glyphs. func (r *TextRenderer) RenderRun(run *text.ShapedRun) ([]*RenderedGlyph, error) { if run == nil || len(run.Glyphs) == 0 { diff --git a/scene/text_test.go b/scene/text_test.go index 75cfc9a4..763a4102 100644 --- a/scene/text_test.go +++ b/scene/text_test.go @@ -5,6 +5,7 @@ import ( "github.com/gogpu/gg" "github.com/gogpu/gg/text" + "golang.org/x/image/font/gofont/goregular" ) func TestDefaultTextRendererConfig(t *testing.T) { @@ -229,6 +230,85 @@ func TestTextRenderer_RenderRun_Nil(t *testing.T) { } } +func TestTextRenderer_RenderRunMultiFaceUsesGlyphOwners(t *testing.T) { + source, err := text.NewFontSource(goregular.TTF) + if err != nil { + t.Fatalf("failed to create test font source: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + latin := text.NewFilteredFace(source.Face(18), text.RangeBasicLatin) + cyrillic := text.NewFilteredFace(source.Face(18), text.RangeCyrillic) + multi, err := text.NewMultiFace(latin, cyrillic) + if err != nil { + t.Fatalf("NewMultiFace failed: %v", err) + } + glyphs := text.Shape("AБ", multi) + if len(glyphs) != 2 { + t.Fatalf("Shape returned %d glyphs, want 2", len(glyphs)) + } + + rendered, err := NewTextRenderer().RenderRun(&text.ShapedRun{ + Glyphs: glyphs, + Face: multi, + Size: 18, + }) + if err != nil { + t.Fatalf("RenderRun(MultiFace): %v", err) + } + if len(rendered) != len(glyphs) { + t.Fatalf("RenderRun returned %d glyphs, want %d", len(rendered), len(glyphs)) + } + for i, glyph := range rendered { + if glyph == nil || glyph.Path == nil || glyph.Path.IsEmpty() { + t.Errorf("glyph %d lost its source-owned outline", i) + } + } + + legacy := glyphs[0] + legacy.Face = nil + if _, err := NewTextRenderer().RenderGlyphs([]text.ShapedGlyph{legacy}, multi); err == nil { + t.Fatal("legacy composite glyph without an owner should report an error") + } +} + +func TestTextRendererSourceOwnerValidation(t *testing.T) { + renderer := NewTextRenderer() + if _, err := renderer.RenderGlyph(text.ShapedGlyph{}, nil); err == nil { + t.Fatal("RenderGlyph nil face should report an error") + } + if _, err := renderer.RenderGlyphs([]text.ShapedGlyph{{GID: 1}}, nil); err == nil { + t.Fatal("RenderGlyphs nil face should report an error") + } + + source, err := text.NewFontSource(goregular.TTF) + if err != nil { + t.Fatalf("NewFontSource: %v", err) + } + primary := source.Face(18) + owner := source.Face(20) + glyphs := text.Shape("A", owner) + if len(glyphs) != 1 { + t.Fatalf("Shape returned %d glyphs, want 1", len(glyphs)) + } + if rendered, err := renderer.RenderGlyphs(glyphs, owner); err != nil || len(rendered) != 1 || rendered[0] == nil { + t.Fatalf("single-source RenderGlyphs = (%#v, %v)", rendered, err) + } + rendered, err := renderer.RenderGlyphs(glyphs, primary) + if err != nil || len(rendered) != 1 || rendered[0] == nil || rendered[0].Path == nil { + t.Fatalf("source-owned RenderGlyphs = (%#v, %v)", rendered, err) + } + + if err := source.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := renderer.RenderGlyph(text.ShapedGlyph{GID: 1}, primary); err == nil { + t.Fatal("RenderGlyph closed source should report an error") + } + if _, err := renderer.RenderGlyphs([]text.ShapedGlyph{{GID: 1}}, primary); err == nil { + t.Fatal("RenderGlyphs closed source should report an error") + } +} + func TestTextRenderer_RenderText_Empty(t *testing.T) { r := NewTextRenderer() diff --git a/text.go b/text.go index 297c4ac3..ef8209cf 100644 --- a/text.go +++ b/text.go @@ -219,25 +219,34 @@ func (c *Context) drawShapedGlyphsAliased(glyphs []text.ShapedGlyph, face text.F // CPU fallback when GPU shaped text is unavailable. // ADR-054: uses ExtractOutlineHintedVar to apply gvar deltas for variable fonts. func (c *Context) drawShapedGlyphsAsOutlines(glyphs []text.ShapedGlyph, face text.Face, x, y float64) { - source := face.Source() - if source == nil { - return - } - - parsed := source.Parsed() extractor := text.NewOutlineExtractor() - variations := face.Variations() - outlineFunc := func(gid text.GlyphID) *text.GlyphOutline { - outline, err := extractor.ExtractOutlineHintedVar(parsed, gid, face.Size(), text.HintingNone, variations) + for _, glyph := range glyphs { + owner := glyph.Face + if owner == nil { + owner = face + if multi, ok := face.(*text.MultiFace); ok { + // Legacy shaped data predates source identity. Use the first + // fallback face as a visible replacement rather than silently + // dropping the entire shaped draw; source-aware Shape results + // always carry the precise owner above. + owner = multi.FaceForRune(0) + } + } + if owner == nil { + continue + } + source := owner.Source() + if source == nil { + // A legacy shaped glyph without source identity cannot safely + // infer which font owns its local GID. Skip only that glyph; a + // source-aware MultiFace shape continues rendering other runs. + continue + } + outline, err := extractor.ExtractOutlineHintedVar(source.Parsed(), glyph.GID, owner.Size(), text.HintingNone, owner.Variations()) if err != nil { - return nil + continue } - return outline - } - - for _, glyph := range glyphs { - outline := outlineFunc(glyph.GID) if outline == nil || outline.IsEmpty() { continue } diff --git a/text/draw_aliased.go b/text/draw_aliased.go index 68863b96..f54676f2 100644 --- a/text/draw_aliased.go +++ b/text/draw_aliased.go @@ -13,19 +13,35 @@ import ( // through NoAAFiller (integer scanline, binary coverage) instead of AnalyticFiller. // // Position (x, y) is the baseline origin (same semantics as Draw). -// Supports sourceFace only. For MultiFace and FilteredFace, this is a no-op — -// callers should fall back to Draw() for complex font stacks. +// MultiFace and FilteredFace are rendered per source-owned rune so aliased +// output follows the same fallback policy as Draw without silently dropping +// the text. func DrawAliased(dst draw.Image, text string, face Face, x, y float64, col color.Color) { if text == "" || face == nil { return } + text = expandTabs(text) + drawAliasedFace(dst, text, face, x, y, col) +} + +func drawAliasedFace(dst draw.Image, text string, face Face, x, y float64, col color.Color) { sf, ok := face.(*sourceFace) - if !ok { + if ok { + drawAliasedSourceFace(dst, text, sf, x, y, col) return } - text = expandTabs(text) + switch face.(type) { + case *MultiFace, *FilteredFace: + drawAliasedComposite(dst, text, face, x, y, col) + } +} + +func drawAliasedSourceFace(dst draw.Image, text string, sf *sourceFace, x, y float64, col color.Color) { + if sf == nil { + return + } if vars := sf.Variations(); len(vars) > 0 { drawGlyphsVariable(dst, sf, text, x, y, col, vars, rasterModeAliased) @@ -34,3 +50,28 @@ func DrawAliased(dst draw.Image, text string, face Face, x, y float64, col color drawGlyphs(dst, sf, text, x, y, col, rasterizeAliasedGlyph) } + +// drawAliasedComposite walks one rune at a time to preserve the fallback +// face's advance contract. Each selected source face is then sent through the +// same aliased rasterizer as a standalone sourceFace. +func drawAliasedComposite(dst draw.Image, text string, face Face, x, y float64, col color.Color) { + currentX := x + for _, r := range text { + runeText := string(r) + if filtered, ok := face.(*FilteredFace); ok && !filtered.inRanges(r) { + continue + } + + owner := face + switch f := face.(type) { + case *MultiFace: + owner = f.FaceForRune(r) + case *FilteredFace: + owner = f.face + } + if owner != nil { + drawAliasedFace(dst, runeText, owner, currentX, y, col) + } + currentX += faceGlyphAdvance(face, runeText) + } +} diff --git a/text/draw_aliased_test.go b/text/draw_aliased_test.go index 96b3f9dd..5ccbc0b9 100644 --- a/text/draw_aliased_test.go +++ b/text/draw_aliased_test.go @@ -166,3 +166,54 @@ func TestDrawAliased_VsDraw_DifferentAlpha(t *testing.T) { t.Log("Confirmed: AA path produces intermediate alpha, aliased path does not") } } + +func TestDrawAliased_MultiFacePreservesFallback(t *testing.T) { + source, err := NewFontSourceFromFile(testFontPath(t)) + if err != nil { + t.Fatalf("Failed to load font: %v", err) + } + defer func() { _ = source.Close() }() + + latin := NewFilteredFace(source.Face(24), RangeBasicLatin) + cyrillic := NewFilteredFace(source.Face(24), RangeCyrillic) + multi, err := NewMultiFace(latin, cyrillic) + if err != nil { + t.Fatalf("NewMultiFace failed: %v", err) + } + + dst := image.NewRGBA(image.Rect(0, 0, 240, 60)) + DrawAliased(dst, "AБ", multi, 10, 42, color.Black) + + hasNonZero := false + for i := 3; i < len(dst.Pix); i += 4 { + a := dst.Pix[i] + if a != 0 && a != 255 { + t.Fatalf("pixel alpha = %d, want binary coverage", a) + } + if a != 0 { + hasNonZero = true + } + } + if !hasNonZero { + t.Fatal("DrawAliased dropped all MultiFace fallback glyphs") + } +} + +func TestDrawAliasedCompositeBoundaryCases(t *testing.T) { + dst := image.NewRGBA(image.Rect(0, 0, 120, 50)) + var nilSourceFace *sourceFace + DrawAliased(dst, "A", nilSourceFace, 5, 35, color.Black) + + source, err := NewFontSourceFromFile(testFontPath(t)) + if err != nil { + t.Fatalf("NewFontSourceFromFile: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + filtered := NewFilteredFace(source.Face(24), RangeBasicLatin) + DrawAliased(dst, "Б", filtered, 5, 35, color.Black) + for i, value := range dst.Pix { + if value != 0 { + t.Fatalf("rejected filtered rune changed pixel byte %d", i) + } + } +} diff --git a/text/draw_emoji.go b/text/draw_emoji.go index b2427f54..adcac00e 100644 --- a/text/draw_emoji.go +++ b/text/draw_emoji.go @@ -22,6 +22,13 @@ func DrawWithEmoji(dst draw.Image, text string, face Face, x, y float64, col col if text == "" || face == nil { return } + if face.Source() == nil { + // A fallback face has no single color-font parser. Preserve CPU + // fallback visibility instead of dereferencing a nil source; individual + // source faces can still be rendered by Draw's composite path. + Draw(dst, text, face, x, y, col) + return + } // Check if the font has color tables. parsed := face.Source().Parsed() diff --git a/text/draw_emoji_test.go b/text/draw_emoji_test.go index e3d306af..27fb55e5 100644 --- a/text/draw_emoji_test.go +++ b/text/draw_emoji_test.go @@ -2,6 +2,7 @@ package text import ( "image" + "image/color" "testing" "github.com/gogpu/gg/text/emoji" @@ -18,6 +19,26 @@ func TestBitmapGlyphCache_NewAndSize(t *testing.T) { } } +func TestDrawWithEmojiMultiFaceUsesCompositeFallback(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("NewFontSource: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + multi, err := NewMultiFace(source.Face(24)) + if err != nil { + t.Fatalf("NewMultiFace: %v", err) + } + dst := image.NewRGBA(image.Rect(0, 0, 100, 50)) + DrawWithEmoji(dst, "A", multi, 5, 35, color.Black) + for i := 3; i < len(dst.Pix); i += 4 { + if dst.Pix[i] != 0 { + return + } + } + t.Fatal("DrawWithEmoji dropped MultiFace text") +} + func TestBitmapGlyphCache_PutAndGet(t *testing.T) { cache := NewBitmapGlyphCache(100) diff --git a/text/filtered.go b/text/filtered.go index 0b709483..d6282f8c 100644 --- a/text/filtered.go +++ b/text/filtered.go @@ -119,6 +119,76 @@ func (f *FilteredFace) AppendGlyphs(dst []Glyph, text string) []Glyph { return dst } +// FontRuns exposes source-owned runs for a filtered composite face. A +// FilteredFace wrapping a MultiFace still has no single FontSource, so callers +// must resolve the nested owner before shaping or GPU rasterization. Runes +// rejected by the filter are omitted, matching Glyphs and Advance. +func (f *FilteredFace) FontRuns(text string) []FontRun { + if f == nil || text == "" || f.face == nil { + return nil + } + + var runs []FontRun + start := 0 + var current Face + var currentCJK bool + var offset float64 + + flush := func(end int) { + if current == nil || start >= end { + return + } + runText := text[start:end] + runs = append(runs, FontRun{ + Face: current, + Text: runText, + Start: start, + End: end, + Offset: offset, + IsCJK: currentCJK, + }) + offset += current.Advance(runText) + } + + for byteIndex, r := range text { + owner := f.fontRunFace(r) + if owner == nil { + flush(byteIndex) + current = nil + continue + } + isCJK := IsCJKRune(r) + if current == nil { + current = owner + currentCJK = isCJK + start = byteIndex + continue + } + if owner != current || isCJK != currentCJK { + flush(byteIndex) + start = byteIndex + current = owner + currentCJK = isCJK + } + } + flush(len(text)) + return runs +} + +func (f *FilteredFace) fontRunFace(r rune) Face { + if f == nil || f.face == nil || !f.inRanges(r) || !f.face.HasGlyph(r) { + return nil + } + switch f.face.(type) { + case *MultiFace, *FilteredFace: + return resolveFallbackFace(f.face, r) + default: + // Keep the wrapper for ordinary faces so its range policy remains part + // of the run contract; all runes in this run passed the filter. + return f + } +} + // Direction implements Face.Direction. func (f *FilteredFace) Direction() Direction { return f.face.Direction() diff --git a/text/filtered_test.go b/text/filtered_test.go index 5b953008..089fc579 100644 --- a/text/filtered_test.go +++ b/text/filtered_test.go @@ -2,6 +2,7 @@ package text import ( "testing" + "unicode" ) func TestUnicodeRangeContains(t *testing.T) { @@ -318,3 +319,25 @@ func TestFilteredFaceWithMultiFace(t *testing.T) { t.Errorf("unexpected runes: %q, %q", glyphs[0].Rune, glyphs[1].Rune) } } + +func TestFilteredFaceFontRunsBoundariesAndSplits(t *testing.T) { + if runs := (*FilteredFace)(nil).FontRuns("a"); runs != nil { + t.Fatalf("nil receiver FontRuns = %#v, want nil", runs) + } + if runs := (&FilteredFace{}).FontRuns("a"); runs != nil { + t.Fatalf("nil wrapped face FontRuns = %#v, want nil", runs) + } + + face := newMockFace(12, DirectionLTR, map[rune]float64{'a': 6, '界': 12}) + filtered := NewFilteredFace(face, UnicodeRange{Start: 0, End: unicode.MaxRune}) + runs := filtered.FontRuns("a界") + if len(runs) != 2 { + t.Fatalf("script split produced %d runs, want 2: %#v", len(runs), runs) + } + if runs[0].Face != filtered || runs[1].Face != filtered { + t.Fatalf("ordinary filtered face ownership not retained: %#v", runs) + } + if runs[0].IsCJK || !runs[1].IsCJK { + t.Fatalf("script classification = (%v, %v), want (false, true)", runs[0].IsCJK, runs[1].IsCJK) + } +} diff --git a/text/glyph_renderer.go b/text/glyph_renderer.go index 2876df15..d794eec5 100644 --- a/text/glyph_renderer.go +++ b/text/glyph_renderer.go @@ -179,17 +179,42 @@ func (r *GlyphRenderer) RenderRun(run *ShapedRun, params RenderParams) []*GlyphO return nil } - font := run.Face.Source().Parsed() - if font == nil { - return nil - } - - // ADR-054: propagate variations from face if not set in params. - if len(params.Variations) == 0 { - params.Variations = run.Face.Variations() + // A source-aware MultiFace run can contain glyph IDs from several font + // namespaces. Resolve each glyph's owner before extracting its outline; + // using run.Face.Source() would either return nil for MultiFace or parse a + // different font's GID. The returned slice keeps RenderGlyphs' one-entry- + // per-input-glyph contract, including nil entries for empty glyphs. + var result []*GlyphOutline + for i := range run.Glyphs { + glyph := &run.Glyphs[i] + owner := glyph.Face + if owner == nil { + owner = run.Face + } + source := owner.Source() + if source == nil { + continue + } + font := source.Parsed() + if font == nil { + continue + } + if result == nil { + result = make([]*GlyphOutline, len(run.Glyphs)) + } + glyphParams := params + // ADR-054: propagate variations from the owning face when not set in + // params. Fallback runs may use different variation instances. + if len(glyphParams.Variations) == 0 { + glyphParams.Variations = owner.Variations() + } + size := run.Size + if size <= 0 { + size = owner.Size() + } + result[i] = r.RenderGlyph(glyph, font, size, glyphParams) } - - return r.RenderGlyphs(run.Glyphs, font, run.Size, params) + return result } // RenderLayout renders a complete layout to outlines. @@ -449,16 +474,24 @@ func (tr *TextRenderer) ShapeAndRender(text string) ([]*GlyphOutline, error) { return nil, nil } - // Get the parsed font - font := tr.defaultFace.Source().Parsed() - if font == nil { - return nil, ErrUnsupportedFontType - } - params := RenderParams{ Color: tr.defaultColor, Opacity: 1.0, } + if tr.defaultFace.Source() == nil { + // MultiFace has no single ParsedFont. Render each source-aware glyph + // through its owning face instead of panicking or dropping the run. + return tr.glyphRenderer.RenderRun(&ShapedRun{ + Glyphs: glyphs, + Face: tr.defaultFace, + Size: tr.defaultSize, + }, params), nil + } + + font := tr.defaultFace.Source().Parsed() + if font == nil { + return nil, ErrUnsupportedFontType + } return tr.glyphRenderer.RenderGlyphs(glyphs, font, tr.defaultSize, params), nil } @@ -475,17 +508,23 @@ func (tr *TextRenderer) ShapeAndRenderAt(text string, x, y float64) ([]*GlyphOut return nil, nil } - // Get the parsed font - font := tr.defaultFace.Source().Parsed() - if font == nil { - return nil, ErrUnsupportedFontType - } - params := RenderParams{ Transform: TranslateTransform(float32(x), float32(y)), Color: tr.defaultColor, Opacity: 1.0, } + if tr.defaultFace.Source() == nil { + return tr.glyphRenderer.RenderRun(&ShapedRun{ + Glyphs: glyphs, + Face: tr.defaultFace, + Size: tr.defaultSize, + }, params), nil + } + + font := tr.defaultFace.Source().Parsed() + if font == nil { + return nil, ErrUnsupportedFontType + } return tr.glyphRenderer.RenderGlyphs(glyphs, font, tr.defaultSize, params), nil } diff --git a/text/glyph_renderer_test.go b/text/glyph_renderer_test.go index c5383bf7..54d1ba37 100644 --- a/text/glyph_renderer_test.go +++ b/text/glyph_renderer_test.go @@ -1,6 +1,7 @@ package text import ( + "errors" "image/color" "testing" ) @@ -190,6 +191,83 @@ func TestGlyphRenderer_RenderRun_NilInputs(t *testing.T) { } } +func TestGlyphRenderer_RenderRunMultiFaceUsesGlyphOwners(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("failed to create test font source: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + latin := NewFilteredFace(source.Face(18), RangeBasicLatin) + cyrillic := NewFilteredFace(source.Face(18), RangeCyrillic) + multi, err := NewMultiFace(latin, cyrillic) + if err != nil { + t.Fatalf("NewMultiFace failed: %v", err) + } + glyphs := Shape("AБ", multi) + if len(glyphs) != 2 { + t.Fatalf("Shape returned %d glyphs, want 2", len(glyphs)) + } + + rendered := NewGlyphRenderer().RenderRun(&ShapedRun{ + Glyphs: glyphs, + Face: multi, + Size: 18, + }, DefaultRenderParams()) + if len(rendered) != len(glyphs) { + t.Fatalf("RenderRun returned %d outlines, want %d", len(rendered), len(glyphs)) + } + for i, outline := range rendered { + if outline == nil || outline.IsEmpty() { + t.Errorf("glyph %d lost its source-owned outline", i) + } + } +} + +func TestGlyphRendererRenderRunSkipsInvalidOwnersAndDefaultsSize(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("NewFontSource: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + face := source.Face(18) + glyphs := Shape("A", face) + if len(glyphs) != 1 { + t.Fatalf("Shape returned %d glyphs, want 1", len(glyphs)) + } + ownerless := glyphs[0] + ownerless.Face = nil + + noSource, err := NewMultiFace(newMockFace(18, DirectionLTR, map[rune]float64{'A': 10})) + if err != nil { + t.Fatalf("NewMultiFace no source: %v", err) + } + missingSource := glyphs[0] + missingSource.Face = noSource + + closedSource, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("NewFontSource closed: %v", err) + } + closedFace := closedSource.Face(18) + if err := closedSource.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + missingParsed := glyphs[0] + missingParsed.Face = closedFace + + outlines := NewGlyphRenderer().RenderRun(&ShapedRun{ + Glyphs: []ShapedGlyph{ownerless, missingSource, missingParsed}, + Face: face, + Size: 0, + }, DefaultRenderParams()) + if len(outlines) != 3 || outlines[0] == nil { + t.Fatalf("RenderRun result = %#v, want first outline and stable nil slots", outlines) + } + if outlines[1] != nil || outlines[2] != nil { + t.Fatalf("invalid owners rendered unexpectedly: %#v", outlines) + } +} + func TestGlyphRenderer_RenderLayout_NilInputs(t *testing.T) { r := NewGlyphRenderer() params := DefaultRenderParams() @@ -481,3 +559,75 @@ func TestTextRenderer_ShapeAndRenderAt_NoFace(t *testing.T) { t.Error("expected error when no face is set") } } + +func TestTextRenderer_ShapeAndRenderMultiFace(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("failed to create test font source: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + latin := NewFilteredFace(source.Face(18), RangeBasicLatin) + cyrillic := NewFilteredFace(source.Face(18), RangeCyrillic) + multi, err := NewMultiFace(latin, cyrillic) + if err != nil { + t.Fatalf("NewMultiFace failed: %v", err) + } + + renderer := NewTextRenderer() + renderer.SetDefaultFace(multi) + renderer.SetDefaultSize(18) + for _, tc := range []struct { + name string + call func() ([]*GlyphOutline, error) + }{ + {name: "origin", call: func() ([]*GlyphOutline, error) { + return renderer.ShapeAndRender("AБ") + }}, + {name: "translated", call: func() ([]*GlyphOutline, error) { + return renderer.ShapeAndRenderAt("AБ", 10, 20) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + outlines, err := tc.call() + if err != nil { + t.Fatalf("ShapeAndRender: %v", err) + } + if len(outlines) != 2 { + t.Fatalf("got %d outlines, want 2", len(outlines)) + } + for i, outline := range outlines { + if outline == nil || outline.IsEmpty() { + t.Errorf("glyph %d lost its source-owned outline", i) + } + } + }) + } +} + +func TestTextRendererRejectsClosedFontSource(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("NewFontSource: %v", err) + } + face := source.Face(16) + if err := source.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + original := GetShaper() + t.Cleanup(func() { SetShaper(original) }) + SetShaper(&mockShaper{glyphs: []ShapedGlyph{{GID: 1, XAdvance: 8}}}) + + renderer := NewTextRenderer() + renderer.SetDefaultFace(face) + for name, call := range map[string]func() ([]*GlyphOutline, error){ + "origin": func() ([]*GlyphOutline, error) { return renderer.ShapeAndRender("A") }, + "translated": func() ([]*GlyphOutline, error) { return renderer.ShapeAndRenderAt("A", 2, 3) }, + } { + t.Run(name, func(t *testing.T) { + if _, err := call(); !errors.Is(err, ErrUnsupportedFontType) { + t.Fatalf("error = %v, want ErrUnsupportedFontType", err) + } + }) + } +} diff --git a/text/glyph_run.go b/text/glyph_run.go index 58b5a08d..f104b0b1 100644 --- a/text/glyph_run.go +++ b/text/glyph_run.go @@ -86,23 +86,32 @@ func (b *GlyphRunBuilder) AddShapedGlyph(fontID uint64, glyph *ShapedGlyph, size }) } -// AddShapedRun adds all glyphs from a ShapedRun. -// The origin parameter specifies the starting position for the run. +// AddShapedRun adds all glyphs from a ShapedRun. Source-aware glyphs are +// resolved individually because a fallback run may contain GIDs from several +// font namespaces. The origin parameter specifies the starting position. func (b *GlyphRunBuilder) AddShapedRun(run *ShapedRun, origin Point) { if run == nil || len(run.Glyphs) == 0 || run.Face == nil { return } - font := run.Face.Source().Parsed() - if font == nil { - return - } - - fontID := computeFontID(font) - size := float32(run.Size) - for i := range run.Glyphs { glyph := &run.Glyphs[i] + owner := glyph.Face + if owner == nil { + owner = run.Face + } + if owner.Source() == nil { + continue + } + font := owner.Source().Parsed() + if font == nil { + continue + } + fontID := computeFontID(font) + size := float32(run.Size) + if size <= 0 { + size = float32(owner.Size()) + } pos := Point{ X: origin.X + float32(glyph.X), Y: origin.Y + float32(glyph.Y), diff --git a/text/glyph_run_test.go b/text/glyph_run_test.go index 6b11976d..6b33f1b2 100644 --- a/text/glyph_run_test.go +++ b/text/glyph_run_test.go @@ -102,6 +102,85 @@ func TestGlyphRunBuilder_AddShapedGlyph_Nil(t *testing.T) { } } +func TestGlyphRunBuilder_AddShapedRunMultiFaceUsesGlyphOwners(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("failed to create font source: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + latin := NewFilteredFace(source.Face(16), RangeBasicLatin) + cyrillic := NewFilteredFace(source.Face(16), RangeCyrillic) + multi, err := NewMultiFace(latin, cyrillic) + if err != nil { + t.Fatalf("NewMultiFace failed: %v", err) + } + glyphs := Shape("AБ", multi) + if len(glyphs) != 2 { + t.Fatalf("Shape returned %d glyphs, want 2", len(glyphs)) + } + + builder := NewGlyphRunBuilder(NewGlyphCache()) + builder.AddShapedRun(&ShapedRun{Glyphs: glyphs, Face: multi, Size: 16}, Point{}) + if builder.Len() != len(glyphs) { + t.Fatalf("AddShapedRun added %d instances, want %d", builder.Len(), len(glyphs)) + } + for i, instance := range builder.Instances() { + if instance.GlyphID != glyphs[i].GID { + t.Errorf("instance %d GID=%d, want %d", i, instance.GlyphID, glyphs[i].GID) + } + if instance.Size != 16 { + t.Errorf("instance %d size=%v, want 16", i, instance.Size) + } + } +} + +func TestGlyphRunBuilderAddShapedRunOwnerValidationAndDefaultSize(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("NewFontSource: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + face := source.Face(17) + glyphs := Shape("A", face) + if len(glyphs) != 1 { + t.Fatalf("Shape returned %d glyphs, want 1", len(glyphs)) + } + ownerless := glyphs[0] + ownerless.Face = nil + + noSource, err := NewMultiFace(newMockFace(17, DirectionLTR, map[rune]float64{'A': 10})) + if err != nil { + t.Fatalf("NewMultiFace no source: %v", err) + } + missingSource := glyphs[0] + missingSource.Face = noSource + + closedSource, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("NewFontSource closed: %v", err) + } + closedFace := closedSource.Face(17) + if err := closedSource.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + missingParsed := glyphs[0] + missingParsed.Face = closedFace + + builder := NewGlyphRunBuilder(NewGlyphCache()) + builder.AddShapedRun(&ShapedRun{ + Glyphs: []ShapedGlyph{ownerless, missingSource, missingParsed}, + Face: face, + Size: 0, + }, Point{X: 2, Y: 3}) + instances := builder.Instances() + if len(instances) != 1 { + t.Fatalf("instances = %#v, want only valid owner", instances) + } + if instances[0].Size != 17 || instances[0].Position.X != 2 || instances[0].Position.Y != 3 { + t.Fatalf("defaulted instance = %#v", instances[0]) + } +} + func TestGlyphRunBuilder_AddShapedGlyphs(t *testing.T) { builder := NewGlyphRunBuilder(NewGlyphCache()) diff --git a/text/multi.go b/text/multi.go index 9413ff1c..29ae96fa 100644 --- a/text/multi.go +++ b/text/multi.go @@ -13,6 +13,21 @@ type MultiFace struct { direction Direction } +// FontRun is a contiguous source-font run in a fallback face. +// +// Start and End are byte offsets into the input passed to FontRuns. Offset is +// the advance of all preceding runs and can be used as the run's origin. The +// IsCJK flag lets GPU consumers keep script-specific atlas and hinting policy +// while still emitting one ordered batch for the complete string. +type FontRun struct { + Face Face + Text string + Start int + End int + Offset float64 + IsCJK bool +} + // NewMultiFace creates a MultiFace from faces. // All faces must have the same direction. // Returns error if faces is empty or directions don't match. @@ -77,6 +92,93 @@ func (m *MultiFace) HasGlyph(r rune) bool { return false } +// Faces returns the fallback chain in priority order. +// +// The returned slice is a copy and can be modified by the caller without +// changing this MultiFace. +func (m *MultiFace) Faces() []Face { + faces := make([]Face, len(m.faces)) + copy(faces, m.faces) + return faces +} + +// FaceForRune returns the first source face that contains r. Composite faces +// are resolved recursively so callers always receive a face that can expose a +// FontSource when one exists. If no face contains r, the first face is used as +// the replacement-glyph fallback, matching Glyphs and AppendGlyphs. +func (m *MultiFace) FaceForRune(r rune) Face { + for _, face := range m.faces { + if face != nil && face.HasGlyph(r) { + return resolveFallbackFace(face, r) + } + } + if len(m.faces) == 0 { + return nil + } + return resolveFallbackFace(m.faces[0], r) +} + +// FontRuns splits text into contiguous runs that share a source face and +// script class. Runs retain their original byte ranges and cumulative x +// offsets, allowing GPU consumers to rasterize each source independently and +// append quads without dropping shaped positions or leaving the GPU path. +func (m *MultiFace) FontRuns(text string) []FontRun { + if text == "" || len(m.faces) == 0 { + return nil + } + + var runs []FontRun + start := 0 + var current Face + var currentCJK bool + var offset float64 + + flush := func(end int) { + if current == nil || start >= end { + return + } + runText := text[start:end] + runs = append(runs, FontRun{ + Face: current, + Text: runText, + Start: start, + End: end, + Offset: offset, + IsCJK: currentCJK, + }) + offset += current.Advance(runText) + } + + for byteIndex, r := range text { + face := m.FaceForRune(r) + isCJK := IsCJKRune(r) + if current == nil { + current = face + currentCJK = isCJK + start = byteIndex + continue + } + if face != current || isCJK != currentCJK { + flush(byteIndex) + start = byteIndex + current = face + currentCJK = isCJK + } + } + flush(len(text)) + return runs +} + +// ShapeRuns is the method form of [ShapeRuns] for callers that already hold a +// MultiFace. Every returned ShapedRun carries the source Face used to produce +// its glyph IDs. +func (m *MultiFace) ShapeRuns(text string) []ShapedRun { + if m == nil || text == "" { + return nil + } + return shapeMultiFaceRuns(text, m, GetShaper()) +} + // Glyphs implements Face.Glyphs. // Returns an iterator over all glyphs, using the appropriate face for each rune. func (m *MultiFace) Glyphs(text string) iter.Seq[Glyph] { @@ -175,11 +277,22 @@ func (m *MultiFace) private() {} // faceForRune returns the first face that has the glyph for the rune. // If no face has the glyph, returns the first face as fallback. func (m *MultiFace) faceForRune(r rune) Face { - for _, face := range m.faces { - if face.HasGlyph(r) { - return face + return m.FaceForRune(r) +} + +// resolveFallbackFace unwraps nested composite faces while retaining any +// filtering decision that selected the face in the first place. +func resolveFallbackFace(face Face, r rune) Face { + switch f := face.(type) { + case *MultiFace: + return f.FaceForRune(r) + case *FilteredFace: + if nested, ok := f.face.(*MultiFace); ok && nested.HasGlyph(r) { + return resolveFallbackFace(nested, r) } + // Keep the filter wrapper: GPU consumers can use its FontSource while + // preserving the caller's range policy for glyph iteration. + return f } - // Fallback to first face if no face has the glyph - return m.faces[0] + return face } diff --git a/text/multi_test.go b/text/multi_test.go index 50e0852c..9b553bf0 100644 --- a/text/multi_test.go +++ b/text/multi_test.go @@ -37,7 +37,15 @@ func (m *mockFace) Language() string { return "en" } func (m *mockFace) Variations() []FontVariation { return nil } func (m *mockFace) private() {} func (m *mockFace) HasGlyph(r rune) bool { _, ok := m.glyphs[r]; return ok } -func (m *mockFace) Advance(text string) float64 { panic("not implemented") } +func (m *mockFace) Advance(text string) float64 { + var total float64 + for _, r := range text { + if advance, ok := m.glyphs[r]; ok { + total += advance + } + } + return total +} func (m *mockFace) Glyphs(text string) iter.Seq[Glyph] { return func(yield func(Glyph) bool) { x := 0.0 @@ -289,3 +297,73 @@ func TestMultiFaceLanguage(t *testing.T) { t.Errorf("expected language \"en\", got %q", mf.Language()) } } + +func TestMultiFaceFacesAndFontRuns(t *testing.T) { + latin := newMockFace(12, DirectionLTR, map[rune]float64{'a': 6, 'b': 7}) + fallback := newMockFace(12, DirectionLTR, map[rune]float64{'界': 12}) + mf, err := NewMultiFace(latin, fallback) + if err != nil { + t.Fatalf("NewMultiFace failed: %v", err) + } + + faces := mf.Faces() + if len(faces) != 2 || faces[0] != latin || faces[1] != fallback { + t.Fatalf("Faces() = %#v, want the original fallback order", faces) + } + faces[0] = fallback + if mf.FaceForRune('a') != latin { + t.Fatal("Faces returned a mutable view of the fallback chain") + } + + runs := mf.FontRuns("a界b") + if len(runs) != 3 { + t.Fatalf("FontRuns produced %d runs, want 3", len(runs)) + } + wantText := []string{"a", "界", "b"} + wantFace := []Face{latin, fallback, latin} + wantOffset := []float64{0, 6, 18} + for i, run := range runs { + if run.Text != wantText[i] || run.Face != wantFace[i] { + t.Errorf("run %d = (%q, %T), want (%q, %T)", i, run.Text, run.Face, wantText[i], wantFace[i]) + } + if run.Offset != wantOffset[i] { + t.Errorf("run %d offset = %v, want %v", i, run.Offset, wantOffset[i]) + } + } +} + +func TestMultiFaceSourceAwareBoundaryCases(t *testing.T) { + var zero MultiFace + if face := zero.FaceForRune('x'); face != nil { + t.Fatalf("zero-value FaceForRune = %v, want nil", face) + } + if runs := zero.FontRuns("x"); runs != nil { + t.Fatalf("zero-value FontRuns = %#v, want nil", runs) + } + if runs := zero.ShapeRuns("x"); runs != nil { + t.Fatalf("zero-value ShapeRuns = %#v, want nil", runs) + } + if runs := (*MultiFace)(nil).ShapeRuns("x"); runs != nil { + t.Fatalf("nil ShapeRuns = %#v, want nil", runs) + } + + latin := newMockFace(12, DirectionLTR, map[rune]float64{'a': 6}) + nested, err := NewMultiFace(latin) + if err != nil { + t.Fatalf("NewMultiFace nested: %v", err) + } + filtered := NewFilteredFace(nested, RangeBasicLatin) + outer, err := NewMultiFace(filtered) + if err != nil { + t.Fatalf("NewMultiFace outer: %v", err) + } + if got := outer.FaceForRune('a'); got != latin { + t.Fatalf("nested fallback owner = %T, want underlying source face", got) + } + + // A malformed zero-like value must not emit a run with no owner. + broken := &MultiFace{faces: []Face{nil}} + if runs := broken.FontRuns("x"); runs != nil { + t.Fatalf("FontRuns with no usable owner = %#v, want nil", runs) + } +} diff --git a/text/shaped.go b/text/shaped.go index 28dc229a..afca24d4 100644 --- a/text/shaped.go +++ b/text/shaped.go @@ -7,6 +7,14 @@ type ShapedGlyph struct { // GID is the glyph index in the font. GID GlyphID + // Face identifies the source face that owns GID. It is set by Shape and + // source-aware fallback runs, and is nil for legacy/custom shaped data + // where the Face argument supplied to the renderer owns every glyph. + // + // Glyph IDs are local to a font, so retaining this identity is required + // when a shaped run contains glyphs from more than one fallback face. + Face Face + // Cluster is the source character index in the original text. // Used for hit testing and cursor positioning. Cluster int diff --git a/text/shaper.go b/text/shaper.go index 418e862d..0a9850e0 100644 --- a/text/shaper.go +++ b/text/shaper.go @@ -1,6 +1,9 @@ package text -import "sync" +import ( + "sync" + "unicode/utf8" +) // Shaper converts text to positioned glyphs. // Implementations provide different levels of text shaping support: @@ -50,5 +53,139 @@ func GetShaper() Shaper { // It converts text to positioned glyphs using the given face. // The font size is obtained from face.Size(). func Shape(text string, face Face) []ShapedGlyph { - return GetShaper().Shape(text, face) + if text == "" || face == nil { + return nil + } + + shaper := GetShaper() + if face.Source() == nil { + if runsFace, ok := face.(interface{ FontRuns(string) []FontRun }); ok { + return flattenShapedRuns(shapeFontRuns(text, runsFace.FontRuns(text), shaper)) + } + } + + return annotateShapedGlyphs(shaper.Shape(text, face), face) +} + +// ShapeRuns shapes a fallback face while retaining source-face identity for +// every run. It is the source-aware counterpart to Shape and is useful to +// renderers that need to rasterize glyph IDs from more than one font. +func ShapeRuns(text string, face Face) []ShapedRun { + if text == "" || face == nil { + return nil + } + if face.Source() == nil { + if runsFace, ok := face.(interface{ FontRuns(string) []FontRun }); ok { + return shapeFontRuns(text, runsFace.FontRuns(text), GetShaper()) + } + } + glyphs := annotateShapedGlyphs(GetShaper().Shape(text, face), face) + if len(glyphs) == 0 { + return nil + } + return []ShapedRun{newShapedRun(face, glyphs, face.Direction())} +} + +// shapeMultiFaceRuns shapes each contiguous source run with the active +// shaper. Keeping shaping inside each run preserves GSUB/GPOS output and +// avoids interpreting a glyph ID from one font in another font's namespace. +func shapeMultiFaceRuns(input string, multi *MultiFace, shaper Shaper) []ShapedRun { + if multi == nil { + return nil + } + return shapeFontRuns(input, multi.FontRuns(input), shaper) +} + +// shapeFontRuns shapes source-aware fallback runs produced by a composite +// face. Run.Start is a byte offset, while ShapedGlyph.Cluster is a rune +// index, so convert each run's start explicitly before applying the offset. +func shapeFontRuns(input string, runs []FontRun, shaper Shaper) []ShapedRun { + if shaper == nil { + return nil + } + if len(runs) == 0 { + return nil + } + + shaped := make([]ShapedRun, 0, len(runs)) + var xOffset float64 + for _, run := range runs { + runeOffset := utf8.RuneCountInString(input[:run.Start]) + rawGlyphs := shaper.Shape(run.Text, run.Face) + if len(rawGlyphs) == 0 { + xOffset += run.Face.Advance(run.Text) + continue + } + // Position and cluster offsets below are run-specific. Copy even when + // the custom shaper supplied Face values because Shaper implementations + // may legally reuse a result buffer across calls. + glyphs := make([]ShapedGlyph, len(rawGlyphs)) + copy(glyphs, rawGlyphs) + glyphs = annotateShapedGlyphs(glyphs, run.Face) + + r := newShapedRun(run.Face, glyphs, run.Face.Direction()) + for i := range glyphs { + glyphs[i].X += xOffset + glyphs[i].Cluster += runeOffset + } + r.Glyphs = glyphs + shaped = append(shaped, r) + xOffset += r.Advance + } + return shaped +} + +func annotateShapedGlyphs(glyphs []ShapedGlyph, face Face) []ShapedGlyph { + // Shapers are allowed to reuse their result buffer. Never annotate that + // buffer in place: doing so leaks the first caller's source face into later + // calls (and can make a fallback glyph be rasterized through the wrong font). + // Keep the common case allocation-free when the shaper already supplied + // source identity for every glyph. + needsCopy := false + for i := range glyphs { + if glyphs[i].Face == nil { + needsCopy = true + break + } + } + if needsCopy { + annotated := make([]ShapedGlyph, len(glyphs)) + copy(annotated, glyphs) + glyphs = annotated + } + for i := range glyphs { + if glyphs[i].Face == nil { + glyphs[i].Face = face + } + } + return glyphs +} + +func flattenShapedRuns(runs []ShapedRun) []ShapedGlyph { + if len(runs) == 0 { + return nil + } + var glyphs []ShapedGlyph + for i := range runs { + glyphs = append(glyphs, runs[i].Glyphs...) + } + return glyphs +} + +func newShapedRun(face Face, glyphs []ShapedGlyph, direction Direction) ShapedRun { + advance := 0.0 + if len(glyphs) > 0 { + last := glyphs[len(glyphs)-1] + advance = last.X + last.XAdvance + } + metrics := face.Metrics() + return ShapedRun{ + Glyphs: glyphs, + Advance: advance, + Ascent: metrics.Ascent, + Descent: metrics.Descent, + Direction: direction, + Face: face, + Size: face.Size(), + } } diff --git a/text/shaper_builtin.go b/text/shaper_builtin.go index 7ca17644..f1c53b95 100644 --- a/text/shaper_builtin.go +++ b/text/shaper_builtin.go @@ -25,6 +25,11 @@ func (s *BuiltinShaper) Shape(text string, face Face) []ShapedGlyph { if text == "" || face == nil { return nil } + if face.Source() == nil { + if runsFace, ok := face.(interface{ FontRuns(string) []FontRun }); ok { + return flattenShapedRuns(shapeFontRuns(text, runsFace.FontRuns(text), s)) + } + } source := face.Source() if source == nil { diff --git a/text/shaper_own.go b/text/shaper_own.go index 5c00cab8..d4194f6c 100644 --- a/text/shaper_own.go +++ b/text/shaper_own.go @@ -65,6 +65,11 @@ func (s *OwnShaper) Shape(text string, face Face) []ShapedGlyph { if text == "" || face == nil { return nil } + if face.Source() == nil { + if runsFace, ok := face.(interface{ FontRuns(string) []FontRun }); ok { + return flattenShapedRuns(shapeFontRuns(text, runsFace.FontRuns(text), s)) + } + } source := face.Source() if source == nil { diff --git a/text/shaper_test.go b/text/shaper_test.go index ea56e398..0e01a164 100644 --- a/text/shaper_test.go +++ b/text/shaper_test.go @@ -30,6 +30,119 @@ func TestBuiltinShapeEmpty(t *testing.T) { } } +func TestShapeMultiFaceRetainsSourceFaces(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("failed to create font source: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + latin := NewFilteredFace(source.Face(16), RangeBasicLatin) + // The second range is intentionally disjoint from the primary face. Both + // wrappers share the same font data, but the shaped glyphs still retain the + // exact Face that owns each run (and therefore remain source-safe for GPU + // lookup). + other := NewFilteredFace(source.Face(16), RangeCyrillic) + multi, err := NewMultiFace(latin, other) + if err != nil { + t.Fatalf("NewMultiFace failed: %v", err) + } + + glyphs := Shape("AБ", multi) + if len(glyphs) != 2 { + t.Fatalf("Shape returned %d glyphs, want 2", len(glyphs)) + } + if glyphs[0].Face != latin || glyphs[1].Face != other { + t.Fatalf("source faces not retained: got %T and %T", glyphs[0].Face, glyphs[1].Face) + } + if glyphs[1].X <= glyphs[0].X { + t.Fatalf("fallback glyph position did not preserve run advance: x0=%v x1=%v", glyphs[0].X, glyphs[1].X) + } + + runs := ShapeRuns("AБ", multi) + if len(runs) != 2 || runs[0].Face != latin || runs[1].Face != other { + t.Fatalf("ShapeRuns did not retain source runs: %#v", runs) + } + + filtered := NewFilteredFace(multi, RangeBasicLatin) + filteredRuns := filtered.FontRuns("AБ") + if len(filteredRuns) != 1 || filteredRuns[0].Text != "A" || filteredRuns[0].Face != latin { + t.Fatalf("filtered composite runs = %#v, want only the Latin source run", filteredRuns) + } + filteredGlyphs := Shape("AБ", filtered) + if len(filteredGlyphs) != 1 || filteredGlyphs[0].Face != latin { + t.Fatalf("filtered composite Shape = %#v, want one Latin glyph", filteredGlyphs) + } +} + +func TestShapeRunsSourceAndBoundaryPaths(t *testing.T) { + face := builtinTestFace(t) + if runs := ShapeRuns("", face); runs != nil { + t.Fatalf("ShapeRuns empty = %#v, want nil", runs) + } + if runs := ShapeRuns("x", nil); runs != nil { + t.Fatalf("ShapeRuns nil face = %#v, want nil", runs) + } + + runs := ShapeRuns("A", face) + if len(runs) != 1 || runs[0].Face != face || len(runs[0].Glyphs) != 1 { + t.Fatalf("source ShapeRuns = %#v, want one source-owned run", runs) + } + + original := GetShaper() + t.Cleanup(func() { SetShaper(original) }) + SetShaper(&mockShaper{}) + if runs := ShapeRuns("A", face); runs != nil { + t.Fatalf("empty shaper ShapeRuns = %#v, want nil", runs) + } + + if runs := shapeMultiFaceRuns("x", nil, original); runs != nil { + t.Fatalf("nil multi shape = %#v, want nil", runs) + } + if runs := shapeFontRuns("x", nil, original); runs != nil { + t.Fatalf("empty font runs = %#v, want nil", runs) + } + if runs := shapeFontRuns("x", []FontRun{{Face: face, Text: "x"}}, nil); runs != nil { + t.Fatalf("nil shaper font runs = %#v, want nil", runs) + } + if glyphs := flattenShapedRuns(nil); glyphs != nil { + t.Fatalf("flatten empty = %#v, want nil", glyphs) + } +} + +func TestDirectShapersHonorMultiFaceOwners(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("NewFontSource: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + latin := NewFilteredFace(source.Face(16), RangeBasicLatin) + cyrillic := NewFilteredFace(source.Face(16), RangeCyrillic) + multi, err := NewMultiFace(latin, cyrillic) + if err != nil { + t.Fatalf("NewMultiFace: %v", err) + } + + for name, shaper := range map[string]Shaper{ + "builtin": &BuiltinShaper{}, + "own": NewOwnShaper(), + } { + t.Run(name, func(t *testing.T) { + glyphs := shaper.Shape("AБ", multi) + if len(glyphs) != 2 || glyphs[0].Face != latin || glyphs[1].Face != cyrillic { + t.Fatalf("direct Shape lost owners: %#v", glyphs) + } + }) + } +} + +func TestShapeFontRunsSkipsEmptyShaperResult(t *testing.T) { + face := newMockFace(12, DirectionLTR, map[rune]float64{'a': 6}) + runs := shapeFontRuns("a", []FontRun{{Face: face, Text: "a"}}, &mockShaper{}) + if len(runs) != 0 { + t.Fatalf("empty shaped result = %#v, want no runs", runs) + } +} + // TestBuiltinShapeLatinText tests shaping basic Latin text. func TestBuiltinShapeLatinText(t *testing.T) { face := builtinTestFace(t) @@ -319,6 +432,78 @@ func TestCustomShaperIntegration(t *testing.T) { } } +func TestShapeDoesNotMutateReusableShaperBuffer(t *testing.T) { + original := GetShaper() + t.Cleanup(func() { SetShaper(original) }) + + face1 := builtinTestFace(t) + face2 := face1.Source().Face(24) + shared := []ShapedGlyph{{GID: 7, Cluster: 0, XAdvance: 10}} + SetShaper(&mockShaper{glyphs: shared}) + + first := Shape("a", face1) + second := Shape("b", face2) + if len(first) != 1 || len(second) != 1 { + t.Fatalf("unexpected shaped lengths: first=%d second=%d", len(first), len(second)) + } + if first[0].Face != face1 { + t.Fatalf("first glyph face = %v, want first face", first[0].Face) + } + if second[0].Face != face2 { + t.Fatalf("second glyph face = %v, want second face", second[0].Face) + } + if shared[0].Face != nil { + t.Fatal("Shape mutated the reusable shaper result buffer") + } +} + +type reusableFaceShaper struct { + glyphs []ShapedGlyph +} + +func (s *reusableFaceShaper) Shape(string, Face) []ShapedGlyph { + return s.glyphs +} + +func TestShapeRunsCopiesReusableShaperBuffer(t *testing.T) { + source, err := NewFontSource(requireTestFont(t)) + if err != nil { + t.Fatalf("failed to create font source: %v", err) + } + t.Cleanup(func() { _ = source.Close() }) + latin := NewFilteredFace(source.Face(16), RangeBasicLatin) + cyrillic := NewFilteredFace(source.Face(16), RangeCyrillic) + multi, err := NewMultiFace(latin, cyrillic) + if err != nil { + t.Fatalf("NewMultiFace failed: %v", err) + } + + // This shaper reuses a result slice and supplies source identity itself. + // ShapeRuns must still copy before applying run offsets; otherwise the + // second run rewrites the first run's positions in place. + shared := []ShapedGlyph{{GID: 1, Face: latin, XAdvance: 10}} + original := GetShaper() + t.Cleanup(func() { SetShaper(original) }) + SetShaper(&reusableFaceShaper{glyphs: shared}) + + runs := ShapeRuns("AБ", multi) + if len(runs) != 2 { + t.Fatalf("ShapeRuns returned %d runs, want 2", len(runs)) + } + if runs[0].Glyphs[0].Face != latin || runs[1].Glyphs[0].Face != latin { + t.Fatalf("custom source identities changed unexpectedly: %#v", runs) + } + if runs[0].Glyphs[0].X != 0 { + t.Fatalf("first run X = %v, want 0 after second run shaping", runs[0].Glyphs[0].X) + } + if runs[1].Glyphs[0].X != 10 { + t.Fatalf("second run X = %v, want 10", runs[1].Glyphs[0].X) + } + if shared[0].X != 0 { + t.Fatalf("ShapeRuns mutated the reusable shaper result buffer (X=%v)", shared[0].X) + } +} + // BenchmarkBuiltinShape benchmarks the Shape function with BuiltinShaper. func BenchmarkBuiltinShape(b *testing.B) { source, err := NewFontSource(requireTestFont(b)) diff --git a/text_aliased_test.go b/text_aliased_test.go index e72da6fc..d9a62e2c 100644 --- a/text_aliased_test.go +++ b/text_aliased_test.go @@ -220,6 +220,46 @@ func TestDrawShapedGlyphsUsesGlobalAccelerator(t *testing.T) { } } +func TestDrawShapedGlyphsOutlineFallbackOwnerValidation(t *testing.T) { + face, glyphs := aliasedTestShapedGlyphs(t) + multi, err := text.NewMultiFace(face) + if err != nil { + t.Fatalf("NewMultiFace: %v", err) + } + + dc := NewContext(80, 60) + t.Cleanup(func() { _ = dc.Close() }) + dc.drawShapedGlyphsAsOutlines(glyphs, multi, 5, 40) + + modified := false + for y := range dc.Image().Bounds().Dy() { + for x := range dc.Image().Bounds().Dx() { + _, _, _, alpha := dc.Image().At(x, y).RGBA() + if alpha != 0 { + modified = true + break + } + } + if modified { + break + } + } + if !modified { + t.Fatal("legacy MultiFace glyph did not use the first fallback owner") + } + + // Invalid source identity and input are skipped per glyph without affecting + // already rendered fallback glyphs. + badOwner := glyphs[0] + badOwner.Face = multi + dc.drawShapedGlyphsAsOutlines([]text.ShapedGlyph{badOwner}, face, 5, 40) + dc.drawShapedGlyphsAsOutlines(glyphs, nil, 5, 40) + badGID := glyphs[0] + badGID.Face = face + badGID.GID = text.GlyphID(^uint16(0)) + dc.drawShapedGlyphsAsOutlines([]text.ShapedGlyph{badGID}, face, 5, 40) +} + func TestTextModeAliased_UsesPerContextAliasedAccelerator(t *testing.T) { t.Setenv("GOGPU_TEXT_MODE", "") context := &perContextAliasedTestOps{}