-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[shaping] add support for custom letter and word spacing (#165)
* [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
1 parent
6b7f526
commit 2c04547
Showing
5 changed files
with
317 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.