From 5414a60b711b05d9cc6df28132f56a15c77cdd72 Mon Sep 17 00:00:00 2001 From: Benoit KUGLER Date: Fri, 12 Dec 2025 14:28:42 +0100 Subject: [PATCH 1/2] [shaping] initial support for tab alignement --- shaping/output.go | 102 ++++++++++++++++++++++++++++++----------- shaping/output_test.go | 21 +++++++++ 2 files changed, 95 insertions(+), 28 deletions(-) diff --git a/shaping/output.go b/shaping/output.go index 236d9de5..ae6bb5fc 100644 --- a/shaping/output.go +++ b/shaping/output.go @@ -3,6 +3,8 @@ package shaping import ( + "math" + "github.com/go-text/typesetting/di" "github.com/go-text/typesetting/font" "golang.org/x/image/math/fixed" @@ -155,30 +157,30 @@ type Output struct { // ToFontUnit converts a metrics (typically found in [Glyph] fields) // to unscaled font units. -func (o *Output) ToFontUnit(v fixed.Int26_6) float32 { - return float32(v) / float32(o.Size) * float32(o.Face.Upem()) +func (out *Output) ToFontUnit(v fixed.Int26_6) float32 { + return float32(v) / float32(out.Size) * float32(out.Face.Upem()) } // FromFontUnit converts an unscaled font value to the current [Size] -func (o *Output) FromFontUnit(v float32) fixed.Int26_6 { - return fixed.Int26_6(v * float32(o.Size) / float32(o.Face.Upem())) +func (out *Output) FromFontUnit(v float32) fixed.Int26_6 { + return fixed.Int26_6(v * float32(out.Size) / float32(out.Face.Upem())) } // RecomputeAdvance updates only the Advance field based on the current // contents of the Glyphs field. It is faster than RecalculateAll(), // and can be used to speed up line wrapping logic. -func (o *Output) RecomputeAdvance() { +func (out *Output) RecomputeAdvance() { advance := fixed.Int26_6(0) - if o.Direction.IsVertical() { - for _, g := range o.Glyphs { + if out.Direction.IsVertical() { + for _, g := range out.Glyphs { advance += g.YAdvance } } else { // horizontal - for _, g := range o.Glyphs { + for _, g := range out.Glyphs { advance += g.XAdvance } } - o.Advance = advance + out.Advance = advance } // advanceSpaceAware adjust the value in [Advance] @@ -189,45 +191,45 @@ func (o *Output) RecomputeAdvance() { // because the trailing space in this run will always be internal to the paragraph. // // TODO: should we take into account multiple spaces ? -func (o *Output) advanceSpaceAware(paragraphDir di.Direction) fixed.Int26_6 { - L := len(o.Glyphs) - if L == 0 || paragraphDir != o.Direction { - return o.Advance +func (out *Output) advanceSpaceAware(paragraphDir di.Direction) fixed.Int26_6 { + L := len(out.Glyphs) + if L == 0 || paragraphDir != out.Direction { + return out.Advance } // adjust the last to account for spaces var lastG Glyph - if o.Direction.Progression() == di.FromTopLeft { - lastG = o.Glyphs[L-1] + if out.Direction.Progression() == di.FromTopLeft { + lastG = out.Glyphs[L-1] } else { - lastG = o.Glyphs[0] + lastG = out.Glyphs[0] } - if o.Direction.IsVertical() { + if out.Direction.IsVertical() { if lastG.Height == 0 { - return o.Advance - lastG.YAdvance + return out.Advance - lastG.YAdvance } } else { // horizontal if lastG.Width == 0 { - return o.Advance - lastG.XAdvance + return out.Advance - lastG.XAdvance } } - return o.Advance - lastG.endLetterSpacing + return out.Advance - lastG.endLetterSpacing } // RecalculateAll updates the all other fields of the Output // to match the current contents of the Glyphs field. // This method will fail with UnimplementedDirectionError if the Output // direction is unimplemented. -func (o *Output) RecalculateAll() { +func (out *Output) RecalculateAll() { var ( advance fixed.Int26_6 ascent fixed.Int26_6 descent fixed.Int26_6 ) - if o.Direction.IsVertical() { - for i := range o.Glyphs { - g := &o.Glyphs[i] + if out.Direction.IsVertical() { + for i := range out.Glyphs { + g := &out.Glyphs[i] advance += g.YAdvance depth := g.XOffset + g.XBearing // start of the glyph if depth < descent { @@ -239,8 +241,8 @@ func (o *Output) RecalculateAll() { } } } else { // horizontal - for i := range o.Glyphs { - g := &o.Glyphs[i] + for i := range out.Glyphs { + g := &out.Glyphs[i] advance += g.XAdvance height := g.YBearing + g.YOffset if height > ascent { @@ -252,8 +254,8 @@ func (o *Output) RecalculateAll() { } } } - o.Advance = advance - o.GlyphBounds = Bounds{ + out.Advance = advance + out.GlyphBounds = Bounds{ Ascent: ascent, Descent: descent, } @@ -303,6 +305,40 @@ func (out *Output) moveCrossAxis(d fixed.Int26_6) { out.GlyphBounds.Descent += d } +func (out *Output) applyTabs(text []rune, columnWidth, runStart fixed.Int26_6) { + isVertical := out.Direction.IsVertical() + columnWidthF := float64(columnWidth) / 64 + var advance fixed.Int26_6 + for i, g := range out.Glyphs { + gAdvance := g.XAdvance + if isVertical { + gAdvance = g.YAdvance + } + isTab := g.RuneCount == 1 && g.GlyphCount == 1 && text[g.ClusterIndex] == '\t' + if !isTab { + advance += gAdvance + continue + } + // update the advance of the glyph so that the next glyph is "tab-aligned" : + // we want the "end" of the tab to be a multiple of columnWidth, that is : + // (runStart + advance + updatedTabAdvance) % columnWith == 0 + glyphStartF := float64(runStart+advance) / 64 + remainder := math.Mod(glyphStartF, columnWidthF) + updatedTabAdvance := fixed.Int26_6((columnWidthF - remainder) * 64) + + if isVertical { + out.Glyphs[i].YAdvance = updatedTabAdvance + } else { + out.Glyphs[i].XAdvance = updatedTabAdvance + } + + advance += updatedTabAdvance + } + + // no need to call RecomputeAdvance + out.Advance = advance +} + // AdjustBaselines aligns runs with different baselines. // // For vertical text, it centralizes 'sideways' runs, so @@ -349,3 +385,13 @@ func (l Line) AdjustBaselines() { l[i].moveCrossAxis(-middle) } } + +// AlignTabs updates the advance of glyphs mapped to '\t' runes, +// so that tabs are aligned on columns defined by [columnWidth]. +func (l Line) AlignTabs(text []rune, columnWidth fixed.Int26_6) { + var runsAdvance fixed.Int26_6 // the position of the start of the current run + for i := range l { + l[i].applyTabs(text, columnWidth, runsAdvance) + runsAdvance += l[i].Advance + } +} diff --git a/shaping/output_test.go b/shaping/output_test.go index 752237f1..99339001 100644 --- a/shaping/output_test.go +++ b/shaping/output_test.go @@ -467,3 +467,24 @@ func TestAdvanceSpaceAware(t *testing.T) { }) } } + +func TestLine_applyTabs(t *testing.T) { + text := []rune("A first run\twith tab. A second run\t\ta\t.") + // simplify with 1:1 rune glyph mapping + glyphs := make([]Glyph, len(text)) + for i := range text { + glyphs[i] = Glyph{ClusterIndex: i, RuneCount: 1, GlyphCount: 1, XAdvance: fixed.I(1)} + } + + run1 := Output{Glyphs: glyphs[0:22]} + run2 := Output{Glyphs: glyphs[22:]} + run1.RecalculateAll() + run2.RecalculateAll() + line := Line{run1, run2} + + line.AlignTabs(text, fixed.I(5)) + tu.Assert(t, run1.Glyphs[11].XAdvance == fixed.I(4)) + tu.Assert(t, run2.Glyphs[34-22].XAdvance == fixed.I(3)) + tu.Assert(t, run2.Glyphs[35-22].XAdvance == fixed.I(5)) + tu.Assert(t, run2.Glyphs[37-22].XAdvance == fixed.I(4)) +} From a9d4d37e5ba95db0c28d2c547b33502a18f03d87 Mon Sep 17 00:00:00 2001 From: Benoit KUGLER Date: Fri, 12 Dec 2025 15:22:33 +0100 Subject: [PATCH 2/2] properly handle tabs with 0 width --- shaping/output.go | 20 ++++++++++++++------ shaping/output_test.go | 6 ++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/shaping/output.go b/shaping/output.go index ae6bb5fc..42d45b01 100644 --- a/shaping/output.go +++ b/shaping/output.go @@ -319,12 +319,18 @@ func (out *Output) applyTabs(text []rune, columnWidth, runStart fixed.Int26_6) { advance += gAdvance continue } - // update the advance of the glyph so that the next glyph is "tab-aligned" : - // we want the "end" of the tab to be a multiple of columnWidth, that is : - // (runStart + advance + updatedTabAdvance) % columnWith == 0 - glyphStartF := float64(runStart+advance) / 64 - remainder := math.Mod(glyphStartF, columnWidthF) - updatedTabAdvance := fixed.Int26_6((columnWidthF - remainder) * 64) + + var updatedTabAdvance fixed.Int26_6 + if columnWidth == 0 { + // simply trim the advance, nothing else to do + } else { + // update the advance of the glyph so that the next glyph is "tab-aligned" : + // we want the "end" of the tab to be a multiple of columnWidth, that is : + // (runStart + advance + updatedTabAdvance) % columnWith == 0 + glyphStartF := float64(runStart+advance) / 64 + remainder := math.Mod(glyphStartF, columnWidthF) + updatedTabAdvance = fixed.Int26_6((columnWidthF - remainder) * 64) + } if isVertical { out.Glyphs[i].YAdvance = updatedTabAdvance @@ -388,6 +394,8 @@ func (l Line) AdjustBaselines() { // AlignTabs updates the advance of glyphs mapped to '\t' runes, // so that tabs are aligned on columns defined by [columnWidth]. +// As a special case, if [columnWidth] is zero, +// tabs are trimmed (their advance is set to 0). func (l Line) AlignTabs(text []rune, columnWidth fixed.Int26_6) { var runsAdvance fixed.Int26_6 // the position of the start of the current run for i := range l { diff --git a/shaping/output_test.go b/shaping/output_test.go index 99339001..6da444f0 100644 --- a/shaping/output_test.go +++ b/shaping/output_test.go @@ -487,4 +487,10 @@ func TestLine_applyTabs(t *testing.T) { tu.Assert(t, run2.Glyphs[34-22].XAdvance == fixed.I(3)) tu.Assert(t, run2.Glyphs[35-22].XAdvance == fixed.I(5)) tu.Assert(t, run2.Glyphs[37-22].XAdvance == fixed.I(4)) + + line.AlignTabs(text, fixed.I(0)) + tu.Assert(t, run1.Glyphs[11].XAdvance == fixed.I(0)) + tu.Assert(t, run2.Glyphs[34-22].XAdvance == fixed.I(0)) + tu.Assert(t, run2.Glyphs[35-22].XAdvance == fixed.I(0)) + tu.Assert(t, run2.Glyphs[37-22].XAdvance == fixed.I(0)) }