Skip to content

Commit

Permalink
[shaping] add support for custom letter and word spacing (#165)
Browse files Browse the repository at this point in the history
* [shaping] add support for custom letter and word spacing

* fix typo in documentation

Co-authored-by: Chris Waldon <[email protected]>

* do not observe buffer alt slice directly

* fix doc typo

---------

Co-authored-by: Chris Waldon <[email protected]>
  • Loading branch information
benoitkugler and whereswaldon committed May 22, 2024
1 parent 6b7f526 commit 2c04547
Show file tree
Hide file tree
Showing 5 changed files with 317 additions and 15 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
127 changes: 127 additions & 0 deletions shaping/spacing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package shaping

import (
"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 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]
switch r {
case '\u0020', // space
'\u00A0', // no-break space
'\u1361', // Ethiopic word space
'\U00010100', '\U00010101', // Aegean word separators
'\U0001039F', // Ugaritic word divider
'\U0001091F': // Phoenician word separator
default:
continue
}
// we have a word separator: add space
// we do it by enlarging the separator glyph advance
// and distributing space around the glyph content
if isVertical {
run.Glyphs[i].YAdvance += additionalSpacing
run.Glyphs[i].YOffset += additionalSpacing / 2
} else {
run.Glyphs[i].XAdvance += additionalSpacing
run.Glyphs[i].XOffset += additionalSpacing / 2
}
}
run.RecomputeAdvance()
}

// AddLetterSpacing alters the run, adding [additionalSpacing] between
// each Harfbuzz clusters.
//
// Space is added at the boundaries if and only if there is an adjacent run, as specified by [isStartRun] and [isEndRun].
//
// 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, 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

// 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
}

// 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
startGIdx += startGlyph.GlyphCount
}

run.RecomputeAdvance()
}

// does not run RecomputeAdvance
func (run *Output) trimStartLetterSpacing() {
if len(run.Glyphs) == 0 {
return
}
firstG := &run.Glyphs[0]
halfSpacing := firstG.startLetterSpacing
if run.Direction.IsVertical() {
firstG.YAdvance -= halfSpacing
firstG.YOffset -= halfSpacing
} else {
firstG.XAdvance -= halfSpacing
firstG.XOffset -= halfSpacing
}
firstG.startLetterSpacing = 0
}

// 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)
}
}
}
158 changes: 158 additions & 0 deletions shaping/spacing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package shaping

import (
"testing"

"github.com/go-text/typesetting/di"
"github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/language"
tu "github.com/go-text/typesetting/testutils"
"golang.org/x/image/math/fixed"
)

func simpleShape(text []rune, face *font.Face, dir di.Direction) Output {
input := Input{
Text: text,
RunStart: 0,
RunEnd: len(text),
Direction: dir,
Face: face,
Size: 16 * 72 * 10,
Script: language.LookupScript(text[0]),
}
return (&HarfbuzzShaper{}).Shape(input)
}

func TestOutput_addWordSpacing(t *testing.T) {
latinFont := loadOpentypeFont(t, "../font/testdata/Roboto-Regular.ttf")
arabicFont := loadOpentypeFont(t, "../font/testdata/Amiri-Regular.ttf")
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+6*addSpacing)

out = simpleShape(arabic, arabicFont, di.DirectionRTL)
withoutSpacing = out.Advance
out.AddWordSpacing(arabic, addSpacing)
tu.Assert(t, out.Advance == withoutSpacing+1*addSpacing)

// vertical
out = simpleShape(english, latinFont, di.DirectionTTB)
withoutSpacing = out.Advance
out.AddWordSpacing(english, 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
dir di.Direction
start, end bool
expectedBonusAdvance fixed.Int26_6
}{
// 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, 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, test.start, test.end)
tu.Assert(t, out.Advance == withoutSpacing+test.expectedBonusAdvance)
}
}

func TestCustomSpacing(t *testing.T) {
latinFont := loadOpentypeFont(t, "../font/testdata/Roboto-Regular.ttf")
english := []rune("Hello world ! : the end")

letterSpacing, wordSpacing := fixed.I(4), fixed.I(20)
out := simpleShape(english, latinFont, di.DirectionLTR)
withoutSpacing := out.Advance
out.AddWordSpacing(english, wordSpacing)
out.AddLetterSpacing(letterSpacing, false, false)
tu.Assert(t, out.Advance == withoutSpacing+5*wordSpacing+23*letterSpacing)
}

// make sure that additional letter spacing is 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")

text := []rune("Hello world ! : the end_")

out := simpleShape(text, monoFont, di.DirectionLTR)
tu.Assert(t, out.Advance == fixed.Int26_6(len(text))*charAdvance) // assume 1:1 rune glyph mapping

type test struct {
toWrap []Output
policy LineBreakPolicy
width int
expectedRuns [][][2]fixed.Int26_6 // first and last advance, for each line and each run
}

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])
}
}
}
}
21 changes: 15 additions & 6 deletions shaping/wrapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ func inclusiveGlyphRange(dir di.Direction, start, breakAfter int, runeToGlyph []

// cutRun returns the sub-run of run containing glyphs corresponding to the provided
// _inclusive_ rune range.
func cutRun(run Output, mapping []glyphIndex, startRune, endRune int) Output {
// if [trimStart] is true, the leading letter spacing is removed
func cutRun(run Output, mapping []glyphIndex, startRune, endRune int, trimStart bool) Output {
// Convert the rune range of interest into an inclusive range within the
// current run's runes.
runeStart := startRune - run.Runes.Offset
Expand All @@ -240,9 +241,12 @@ func cutRun(run Output, mapping []glyphIndex, startRune, endRune int) Output {

// Construct a run out of the inclusive glyph range.
run.Glyphs = run.Glyphs[glyphStart : glyphEnd+1]
run.RecomputeAdvance()
run.Runes.Offset = run.Runes.Offset + runeStart
run.Runes.Count = runeEnd - runeStart + 1
run.Runes.Offset = run.Runes.Offset + runeStart
if trimStart {
run.trimStartLetterSpacing()
}
run.RecomputeAdvance()
return run
}

Expand Down Expand Up @@ -563,7 +567,7 @@ func (r *shapedRunSlice) Restore() {
// wrapBuffer, returned line wrapping results will use memory stored within
// the buffer. This means that the same buffer cannot be reused for another
// wrapping operation while the wrapped lines are still in use (unless they
// are deeply copied). If necessary, using a multiple WrapBuffers can work
// are deeply copied). If necessary, using multiple wrapBuffers can work
// around this restriction.
type wrapBuffer struct {
// paragraph is a buffer holding paragraph allocated (primarily) from subregions
Expand Down Expand Up @@ -646,6 +650,9 @@ func (w *wrapBuffer) startLine() {
w.bestInLine = false
}

// candidateLen returns the number of [Output]s in the current line wrapping candidate.
func (w *wrapBuffer) candidateLen() int { return len(w.alt) }

// candidateAppend adds the given run to the current line wrapping candidate.
func (w *wrapBuffer) candidateAppend(run Output) {
w.alt = append(w.alt, run)
Expand Down Expand Up @@ -795,7 +802,8 @@ func (l *LineWrapper) fillUntil(runs RunIterator, option breakOption) {
// If part of this run has already been used on a previous line, trim
// the runes corresponding to those glyphs off.
l.mapper.mapRun(currRunIndex, run)
run = cutRun(run, l.mapper.mapping, l.lineStartRune, run.Runes.Count+run.Runes.Offset)
isFirstInLine := l.scratch.candidateLen() == 0
run = cutRun(run, l.mapper.mapping, l.lineStartRune, run.Runes.Count+run.Runes.Offset, isFirstInLine)
}
// While the run being processed doesn't contain the current line breaking
// candidate, just append it to the candidate line.
Expand Down Expand Up @@ -1093,7 +1101,8 @@ func (l *LineWrapper) processBreakOption(option breakOption, config lineConfig)
// Reject invalid line break candidate and acquire a new one.
return breakInvalid, Output{}
}
candidateRun := cutRun(run, l.mapper.mapping, l.lineStartRune, option.breakAtRune)
isFirstInLine := l.scratch.candidateLen() == 0
candidateRun := cutRun(run, l.mapper.mapping, l.lineStartRune, option.breakAtRune, isFirstInLine)
candidateLineWidth := (candidateRun.advanceSpaceAware() + l.scratch.candidateAdvance()).Ceil()
if candidateLineWidth > config.maxWidth {
// The run doesn't fit on the line.
Expand Down
Loading

0 comments on commit 2c04547

Please sign in to comment.