Skip to content

Commit

Permalink
[shaping] handle spacing in line wrapper
Browse files Browse the repository at this point in the history
  • Loading branch information
benoitkugler committed May 7, 2024
1 parent e15b16c commit df8c244
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 119 deletions.
20 changes: 14 additions & 6 deletions shaping/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ type Glyph struct {
GlyphCount int
GlyphID font.GID
Mask uint32

// startLetterSpacing and endLetterSpacing are set when letter spacing is applied,
// measuring the whitespace added on one side (half of the user provided letter spacing)
// The line wrapper will ignore [endLetterSpacing] when deciding where to break,
// and will trim [startLetterSpacing] at the start of the lines
startLetterSpacing, endLetterSpacing fixed.Int26_6
}

// LeftSideBearing returns the distance from the glyph's X origin to
Expand Down Expand Up @@ -172,6 +178,8 @@ func (o *Output) RecomputeAdvance() {

// advanceSpaceAware adjust the value in [Advance]
// if a white space character ends the run.
// Any end letter spacing (on the last glyph) is also removed
//
// TODO: should we take into account multiple spaces ?
func (o *Output) advanceSpaceAware() fixed.Int26_6 {
L := len(o.Glyphs)
Expand All @@ -180,17 +188,17 @@ func (o *Output) advanceSpaceAware() fixed.Int26_6 {
}

// adjust the last to account for spaces
lastG := o.Glyphs[L-1]
if o.Direction.IsVertical() {
if g := o.Glyphs[L-1]; g.Height == 0 {
return o.Advance - g.YAdvance
if lastG.Height == 0 {
return o.Advance - lastG.YAdvance
}
} else { // horizontal
if g := o.Glyphs[L-1]; g.Width == 0 {
return o.Advance - g.XAdvance
if lastG.Width == 0 {
return o.Advance - lastG.XAdvance
}
}

return o.Advance
return o.Advance - lastG.endLetterSpacing
}

// RecalculateAll updates the all other fields of the Output
Expand Down
97 changes: 56 additions & 41 deletions shaping/spacing.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
package shaping

import (
"github.com/go-text/typesetting/di"
"golang.org/x/image/math/fixed"
)

// AddWordSpacing alters the run, adding [additionalSpacing] on each
// word separator.
// [text] is the input slice used to create the run.
// Note that space is always added, even on boundaries.
//
// See also the convenience function [AddSpacing] to handle a slice of runs.

// See also https://www.w3.org/TR/css-text-3/#word-separator
func (run *Output) AddWordSpacing(text []rune, additionalSpacing fixed.Int26_6) {
isVertical := run.Direction.IsVertical()
for i, g := range run.Glyphs {
// find the corresponding runes :
// to simplify, we assume the words separators are not produced by ligatures
// so that the cluster "rune-length" is 1
if g.RuneCount != 1 {
// to simplify, we assume a simple one to one rune/glyph mapping
// which should be common in practice for word separators
if !(g.RuneCount == 1 && g.GlyphCount == 1) {
continue
}
r := text[g.ClusterIndex]
Expand All @@ -38,7 +40,7 @@ func (run *Output) AddWordSpacing(text []rune, additionalSpacing fixed.Int26_6)
run.Glyphs[i].YOffset += additionalSpacing / 2
} else {
run.Glyphs[i].XAdvance += additionalSpacing
run.Glyphs[i].XOffset += additionalSpacing / 2 // distribute space around the char
run.Glyphs[i].XOffset += additionalSpacing / 2
}
}
run.RecomputeAdvance()
Expand All @@ -47,31 +49,40 @@ func (run *Output) AddWordSpacing(text []rune, additionalSpacing fixed.Int26_6)
// AddLetterSpacing alters the run, adding [additionalSpacing] between
// each Harfbuzz clusters.
//
// Space it also included before the first cluster and after the last cluster.
// Space is added at the boundaries if and only if there is an adjacent run, as specified by [isStartRun] and [isEndRun].
//
// See [Line.TrimLetterSpacing] to trim unwanted space at line boundaries.
// See also the convenience function [AddSpacing] to handle a slice of runs.
//
// See also https://www.w3.org/TR/css-text-3/#letter-spacing-property
func (run *Output) AddLetterSpacing(additionalSpacing fixed.Int26_6) {
func (run *Output) AddLetterSpacing(additionalSpacing fixed.Int26_6, isStartRun, isEndRun bool) {
isVertical := run.Direction.IsVertical()

halfSpacing := additionalSpacing / 2
for startGIdx := 0; startGIdx < len(run.Glyphs); {
startGlyph := run.Glyphs[startGIdx]
endGIdx := startGIdx + startGlyph.GlyphCount - 1

if isVertical {
run.Glyphs[startGIdx].YAdvance += halfSpacing
run.Glyphs[startGIdx].YOffset += halfSpacing
} else {
run.Glyphs[startGIdx].XAdvance += halfSpacing
run.Glyphs[startGIdx].XOffset += halfSpacing
// start : apply spacing at boundary only if the run is not the first
if startGIdx > 0 || !isStartRun {
if isVertical {
run.Glyphs[startGIdx].YAdvance += halfSpacing
run.Glyphs[startGIdx].YOffset += halfSpacing
} else {
run.Glyphs[startGIdx].XAdvance += halfSpacing
run.Glyphs[startGIdx].XOffset += halfSpacing
}
run.Glyphs[startGIdx].startLetterSpacing += halfSpacing
}

if isVertical {
run.Glyphs[endGIdx].YAdvance += halfSpacing
} else {
run.Glyphs[endGIdx].XAdvance += halfSpacing
// end : apply spacing at boundary only if the run is not the last
isLastCluster := startGIdx+startGlyph.GlyphCount >= len(run.Glyphs)
if !isLastCluster || !isEndRun {
if isVertical {
run.Glyphs[endGIdx].YAdvance += halfSpacing
} else {
run.Glyphs[endGIdx].XAdvance += halfSpacing
}
run.Glyphs[endGIdx].endLetterSpacing += halfSpacing
}

// go to next cluster
Expand All @@ -81,32 +92,36 @@ func (run *Output) AddLetterSpacing(additionalSpacing fixed.Int26_6) {
run.RecomputeAdvance()
}

// TrimLetterSpacing post-processes the line by removing the given [letterSpacing]
// at the start and at the end of the line.
//
// This method should be used after wrapping runs altered by [Output.AddLetterSpacing],
// with the same [letterSpacing] argument.
func (line Line) TrimLetterSpacing(letterSpacing fixed.Int26_6) {
if len(line) == 0 {
// does not run RecomputeAdvance
func (run *Output) trimStartLetterSpacing() {
if len(run.Glyphs) == 0 {
return
}

halfSpacing := letterSpacing / 2
firstRun, lastRun := &line[0], &line[len(line)-1]
if firstRun.Direction.Axis() == di.Horizontal {
firstRun.Glyphs[0].XOffset -= halfSpacing
firstRun.Glyphs[0].XAdvance -= halfSpacing

L := len(lastRun.Glyphs)
firstRun.Glyphs[L-1].XAdvance -= halfSpacing
firstG := &run.Glyphs[0]
halfSpacing := firstG.startLetterSpacing
if run.Direction.IsVertical() {
firstG.YAdvance -= halfSpacing
firstG.YOffset -= halfSpacing
} else {
firstRun.Glyphs[0].YOffset -= halfSpacing
firstRun.Glyphs[0].YAdvance -= halfSpacing

L := len(lastRun.Glyphs)
firstRun.Glyphs[L-1].YAdvance -= halfSpacing
firstG.XAdvance -= halfSpacing
firstG.XOffset -= halfSpacing
}
firstG.startLetterSpacing = 0
}

firstRun.RecomputeAdvance()
lastRun.RecomputeAdvance()
// AddSpacing adds additionnal spacing between words and letters, mutating the given [runs].
// [text] is the input slice the [runs] refer to.
//
// See the method [Output.AddWordSpacing] and [Output.AddLetterSpacing] for details
// about what spacing actually is.
func AddSpacing(runs []Output, text []rune, wordSpacing, letterSpacing fixed.Int26_6) {
for i := range runs {
isStartRun, isEndRun := i == 0, i == len(runs)-1
if wordSpacing != 0 {
runs[i].AddWordSpacing(text, wordSpacing)
}
if letterSpacing != 0 {
runs[i].AddLetterSpacing(letterSpacing, isStartRun, isEndRun)
}
}
}
140 changes: 76 additions & 64 deletions shaping/spacing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import (
"github.com/go-text/typesetting/di"
"github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/language"
tu "github.com/go-text/typesetting/opentype/testutils"
tu "github.com/go-text/typesetting/testutils"
"golang.org/x/image/math/fixed"
)

func simpleShape(text []rune, face font.Face, dir di.Direction) Output {
func simpleShape(text []rune, face *font.Face, dir di.Direction) Output {
input := Input{
Text: text,
RunStart: 0,
Expand All @@ -26,14 +26,14 @@ func simpleShape(text []rune, face font.Face, dir di.Direction) Output {
func TestOutput_addWordSpacing(t *testing.T) {
latinFont := loadOpentypeFont(t, "../font/testdata/Roboto-Regular.ttf")
arabicFont := loadOpentypeFont(t, "../font/testdata/Amiri-Regular.ttf")
english := []rune("Hello world ! : the end")
english := []rune("\U0001039FHello\u1361world ! : the\u00A0end")
arabic := []rune("تثذرزسشص لمنهويء")

addSpacing := fixed.I(20)
out := simpleShape(english, latinFont, di.DirectionLTR)
withoutSpacing := out.Advance
out.AddWordSpacing(english, addSpacing)
tu.Assert(t, out.Advance == withoutSpacing+5*addSpacing)
tu.Assert(t, out.Advance == withoutSpacing+6*addSpacing)

out = simpleShape(arabic, arabicFont, di.DirectionRTL)
withoutSpacing = out.Advance
Expand All @@ -44,30 +44,45 @@ func TestOutput_addWordSpacing(t *testing.T) {
out = simpleShape(english, latinFont, di.DirectionTTB)
withoutSpacing = out.Advance
out.AddWordSpacing(english, addSpacing)
tu.Assert(t, out.Advance == withoutSpacing+5*addSpacing)
tu.Assert(t, out.Advance == withoutSpacing+6*addSpacing)
}

func TestOutput_addLetterSpacing(t *testing.T) {
latinFont := loadOpentypeFont(t, "../font/testdata/Roboto-Regular.ttf")
arabicFont := loadOpentypeFont(t, "../font/testdata/Amiri-Regular.ttf")
english := []rune("Hello world ! : the end")
englishWithLigature := []rune("Hello final")
arabic := []rune("تثذرزسشص لمنهويء")

addSpacing := fixed.I(4)
halfSpacing := addSpacing / 2
for _, test := range []struct {
text []rune
face font.Face
face *font.Face
dir di.Direction
start, end bool
expectedBonusAdvance fixed.Int26_6
}{
{english, latinFont, di.DirectionLTR, 23 * addSpacing},
{arabic, arabicFont, di.DirectionRTL, 16 * addSpacing},
// LTR
{english, latinFont, di.DirectionLTR, false, false, 23 * addSpacing},
{english, latinFont, di.DirectionLTR, true, true, 22 * addSpacing},
{english, latinFont, di.DirectionLTR, true, false, 22*addSpacing + halfSpacing},
{english, latinFont, di.DirectionLTR, false, true, 22*addSpacing + halfSpacing},
{englishWithLigature, latinFont, di.DirectionLTR, true, true, 9 * addSpacing}, // not 10
// RTL
{arabic, arabicFont, di.DirectionRTL, false, false, 16 * addSpacing},
{arabic, arabicFont, di.DirectionRTL, true, true, 15 * addSpacing},
{arabic, arabicFont, di.DirectionRTL, true, false, 15*addSpacing + halfSpacing},
{arabic, arabicFont, di.DirectionRTL, false, true, 15*addSpacing + halfSpacing},
// vertical
{english, latinFont, di.DirectionTTB, 23 * addSpacing},
{english, latinFont, di.DirectionTTB, false, false, 23 * addSpacing},
{english, latinFont, di.DirectionTTB, true, true, 22 * addSpacing},
{english, latinFont, di.DirectionTTB, true, false, 22*addSpacing + halfSpacing},
{english, latinFont, di.DirectionTTB, false, true, 22*addSpacing + halfSpacing},
} {
out := simpleShape(test.text, test.face, test.dir)
withoutSpacing := out.Advance
out.AddLetterSpacing(addSpacing)
out.AddLetterSpacing(addSpacing, test.start, test.end)
tu.Assert(t, out.Advance == withoutSpacing+test.expectedBonusAdvance)
}
}
Expand All @@ -80,67 +95,64 @@ func TestCustomSpacing(t *testing.T) {
out := simpleShape(english, latinFont, di.DirectionLTR)
withoutSpacing := out.Advance
out.AddWordSpacing(english, wordSpacing)
out.AddLetterSpacing(letterSpacing)
out.AddLetterSpacing(letterSpacing, false, false)
tu.Assert(t, out.Advance == withoutSpacing+5*wordSpacing+23*letterSpacing)
}

func TestTrimSpace(t *testing.T) {
letterSpacing := fixed.I(4)
latinFont := loadOpentypeFont(t, "../font/testdata/Roboto-Regular.ttf")

english := []rune("Hello world ! : the end")

l := Line{simpleShape(english, latinFont, di.DirectionLTR)}
run := &l[0]
advance := run.Advance

// no-op
l.TrimLetterSpacing(0)
tu.Assert(t, advance == run.Advance)
// make sure that additional letter spacing if properly removed
// at the start and end of wrapped lines
func TestTrailingSpaces(t *testing.T) {
letterSpacing, charAdvance := fixed.I(8), fixed.I(90)
halfSpacing := letterSpacing / 2
monoFont := loadOpentypeFont(t, "../font/testdata/UbuntuMono-R.ttf")

run.AddLetterSpacing(letterSpacing)
beforeTrim := run.Advance
l.TrimLetterSpacing(letterSpacing)
tu.Assert(t, run.Advance == beforeTrim-letterSpacing)
text := []rune("Hello world ! : the end_")

// vertical
l = Line{simpleShape(english, latinFont, di.DirectionTTB)}
run = &l[0]
advance = run.Advance

// no-op
l.TrimLetterSpacing(0)
tu.Assert(t, advance == run.Advance)

run.AddLetterSpacing(letterSpacing)
beforeTrim = run.Advance
l.TrimLetterSpacing(letterSpacing)
tu.Assert(t, run.Advance == beforeTrim-letterSpacing)
}
out := simpleShape(text, monoFont, di.DirectionLTR)
tu.Assert(t, out.Advance == fixed.Int26_6(len(text))*charAdvance) // assume 1:1 rune glyph mapping

func TestTrimSpaceWrap(t *testing.T) {
letterSpacing, _ := fixed.I(4), fixed.I(20)
latinFont := loadOpentypeFont(t, "../font/testdata/Roboto-Regular.ttf")
english := []rune("Hello world ! : the end")
type test struct {
toWrap []Output
policy LineBreakPolicy
width int
expectedRuns [][][2]fixed.Int26_6 // first and last advance, for each line and each run
}

wrap := func(dir di.Direction, letterSpacing fixed.Int26_6) Line {
runs := cutRunInto(simpleShape(english, latinFont, dir), 2)
// apply letter spacing
for i := range runs {
runs[i].AddLetterSpacing(letterSpacing)
for _, test := range []test{
{ // from one run
[]Output{out.copy()},
0, 1800,
[][][2]fixed.Int26_6{
{{charAdvance + halfSpacing, 0}}, // line 1
{{charAdvance + halfSpacing, charAdvance + halfSpacing}}, // line 2
},
},
{ // from two runs, break between
cutRunInto(out.copy(), 2), Always, 1172, // end of the first run
[][][2]fixed.Int26_6{
{{charAdvance + halfSpacing, 0}}, // line 1
{{charAdvance + halfSpacing, charAdvance + halfSpacing}}, // line 2
},
},
{ // from two runs, break inside
cutRunInto(out.copy(), 2), 0, 1800,
[][][2]fixed.Int26_6{
{{charAdvance + halfSpacing, charAdvance + letterSpacing}, {charAdvance + letterSpacing, 0}}, // line 1
{{charAdvance + halfSpacing, charAdvance + halfSpacing}}, // line 2
},
},
} {
AddSpacing(test.toWrap, text, 0, letterSpacing)
lines, _ := (&LineWrapper{}).WrapParagraph(WrapConfig{BreakPolicy: test.policy}, test.width, text, NewSliceIterator(test.toWrap))
tu.Assert(t, len(lines) == len(test.expectedRuns))
for i, expLine := range test.expectedRuns {
gotLine := lines[i]
tu.Assert(t, len(gotLine) == len(expLine))
for j, run := range expLine {
gotRun := gotLine[j]
tu.Assert(t, gotRun.Glyphs[0].XAdvance == run[0])
tu.Assert(t, gotRun.Glyphs[len(gotRun.Glyphs)-1].XAdvance == run[1])
}
}

// split in two lines
lines, _ := (&LineWrapper{}).WrapParagraph(WrapConfig{}, 1200, english, NewSliceIterator(runs))
tu.Assert(t, len(lines) == 2)
return lines[0]
}

line := wrap(di.DirectionLTR, letterSpacing)
tu.Assert(t, len(line) == 2)
withSpacing := line[0].Advance + line[1].Advance

line.TrimLetterSpacing(letterSpacing)
withTrimmedSpacing := line[0].Advance + line[1].Advance
tu.Assert(t, withTrimmedSpacing == withSpacing-letterSpacing)
}
Loading

0 comments on commit df8c244

Please sign in to comment.