diff --git a/shaping/output.go b/shaping/output.go index 8ce9b43..a942bb3 100644 --- a/shaping/output.go +++ b/shaping/output.go @@ -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 @@ -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) @@ -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 diff --git a/shaping/spacing.go b/shaping/spacing.go new file mode 100644 index 0000000..cc16c34 --- /dev/null +++ b/shaping/spacing.go @@ -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) + } + } +} diff --git a/shaping/spacing_test.go b/shaping/spacing_test.go new file mode 100644 index 0000000..4ea8d8a --- /dev/null +++ b/shaping/spacing_test.go @@ -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]) + } + } + } +} diff --git a/shaping/wrapping.go b/shaping/wrapping.go index 35aaa97..a7e1bc0 100644 --- a/shaping/wrapping.go +++ b/shaping/wrapping.go @@ -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 @@ -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 } @@ -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 @@ -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) @@ -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. @@ -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. diff --git a/shaping/wrapping_test.go b/shaping/wrapping_test.go index 9b5acbf..739de6a 100644 --- a/shaping/wrapping_test.go +++ b/shaping/wrapping_test.go @@ -2379,17 +2379,17 @@ var benchSizes = []benchSizeConfig{ {runes: 1000, parts: []int{1, 10, 100, 1000}}, } -// cutRunInto divides the run into parts of size (with the last part absorbing any remainder). +// cutRunInto divides the run into [parts] of same size (with the last part absorbing any remainder). func cutRunInto(run Output, parts int) []Output { var outs []Output mapping := mapRunesToClusterIndices3(run.Direction, run.Runes, run.Glyphs, nil) runesPerPart := run.Runes.Count / parts partStart := 0 for i := 0; i < parts-1; i++ { - outs = append(outs, cutRun(run, mapping, partStart, partStart+runesPerPart-1)) + outs = append(outs, cutRun(run, mapping, partStart, partStart+runesPerPart-1, false)) partStart += runesPerPart } - outs = append(outs, cutRun(run, mapping, partStart, run.Runes.Count-1)) + outs = append(outs, cutRun(run, mapping, partStart, run.Runes.Count-1, false)) return outs }