diff --git a/di/direction.go b/di/direction.go index e8da95ed..0a0fca4e 100644 --- a/di/direction.go +++ b/di/direction.go @@ -1,5 +1,9 @@ package di +import ( + "github.com/go-text/typesetting/harfbuzz" +) + // Direction indicates the layout direction of a piece of text. type Direction uint8 @@ -14,30 +18,52 @@ const ( DirectionBTT ) +const ( + progression Direction = 1 << iota + // axisVertical is the bit for the axis, 0 for horizontal, 1 for vertical + axisVertical + + // If this flag is set, the orientation is chosen + // using the [verticalSideways] flag. + // Otherwise, the segmenter will resolve the orientation based + // on unicode properties + verticalOrientationSet + // verticalSideways is set for 'sideways', unset for 'upright' + // It implies BVerticalOrientationSet is set + verticalSideways +) + // IsVertical returns whether d is laid out on a vertical // axis. If the return value is false, d is on the horizontal // axis. -func (d Direction) IsVertical() bool { - return d == DirectionBTT || d == DirectionTTB -} +func (d Direction) IsVertical() bool { return d&axisVertical != 0 } // Axis returns the layout axis for d. func (d Direction) Axis() Axis { - switch d { - case DirectionBTT, DirectionTTB: + if d.IsVertical() { return Vertical - default: - return Horizontal } + return Horizontal } +// SwitchAxis switches from horizontal to vertical (and vice versa), preserving +// the progression. +func (d Direction) SwitchAxis() Direction { return d ^ axisVertical } + // Progression returns the text layout progression for d. func (d Direction) Progression() Progression { - switch d { - case DirectionTTB, DirectionLTR: + if d&progression == 0 { return FromTopLeft - default: - return TowardTopLeft + } + return TowardTopLeft +} + +// SetProgression sets the progression, preserving the others bits. +func (d *Direction) SetProgression(p Progression) { + if p == FromTopLeft { + *d &= ^progression + } else { + *d |= progression } } @@ -65,3 +91,40 @@ const ( // of TowardTopLeft progression. TowardTopLeft Progression = true ) + +// HasVerticalOrientation returns true if the direction has set up +// an orientation for vertical text (typically using [SetSideways] or [SetUpright]) +func (d Direction) HasVerticalOrientation() bool { return d&verticalOrientationSet != 0 } + +// IsSideways returns true if the direction is vertical with a 'sideways' +// orientation. +// +// When shaping vertical text, 'sideways' means that the glyphs are rotated +// by 90°, clock-wise. This flag should be used by renderers to properly +// rotate the glyphs when drawing. +func (d Direction) IsSideways() bool { return d.IsVertical() && d&verticalSideways != 0 } + +// SetSideways makes d vertical with 'sideways' or 'upright' orientation, preserving only the +// progression. +func (d *Direction) SetSideways(sideways bool) { + *d |= axisVertical | verticalOrientationSet + if sideways { + *d |= verticalSideways + } else { + *d &= ^verticalSideways + } +} + +// Harfbuzz returns the equivalent direction used by harfbuzz. +func (d Direction) Harfbuzz() harfbuzz.Direction { + switch d & (progression | axisVertical) { + case DirectionRTL: + return harfbuzz.RightToLeft + case DirectionBTT: + return harfbuzz.BottomToTop + case DirectionTTB: + return harfbuzz.TopToBottom + default: + return harfbuzz.LeftToRight + } +} diff --git a/di/direction_test.go b/di/direction_test.go new file mode 100644 index 00000000..4e2833dd --- /dev/null +++ b/di/direction_test.go @@ -0,0 +1,65 @@ +package di + +import ( + "testing" + + "github.com/go-text/typesetting/harfbuzz" + tu "github.com/go-text/typesetting/opentype/testutils" +) + +func TestDirection(t *testing.T) { + tu.Assert(t, DirectionLTR.Axis() == Horizontal) + tu.Assert(t, DirectionRTL.Axis() == Horizontal) + tu.Assert(t, DirectionTTB.Axis() == Vertical) + tu.Assert(t, DirectionBTT.Axis() == Vertical) + tu.Assert(t, !DirectionLTR.IsVertical()) + tu.Assert(t, !DirectionRTL.IsVertical()) + tu.Assert(t, DirectionTTB.IsVertical()) + tu.Assert(t, DirectionBTT.IsVertical()) + + tu.Assert(t, DirectionLTR.Progression() == FromTopLeft) + tu.Assert(t, DirectionRTL.Progression() == TowardTopLeft) + tu.Assert(t, DirectionTTB.Progression() == FromTopLeft) + tu.Assert(t, DirectionBTT.Progression() == TowardTopLeft) + + tu.Assert(t, !DirectionTTB.IsSideways()) + tu.Assert(t, !DirectionBTT.IsSideways()) + + tu.Assert(t, DirectionLTR.SwitchAxis() == DirectionTTB) + tu.Assert(t, DirectionRTL.SwitchAxis() == DirectionBTT) + tu.Assert(t, DirectionTTB.SwitchAxis() == DirectionLTR) + tu.Assert(t, DirectionBTT.SwitchAxis() == DirectionRTL) + + tu.Assert(t, DirectionLTR.Harfbuzz() == harfbuzz.LeftToRight) + tu.Assert(t, DirectionRTL.Harfbuzz() == harfbuzz.RightToLeft) + tu.Assert(t, DirectionTTB.Harfbuzz() == harfbuzz.TopToBottom) + tu.Assert(t, DirectionBTT.Harfbuzz() == harfbuzz.BottomToTop) + + tu.Assert(t, !DirectionLTR.HasVerticalOrientation()) + tu.Assert(t, !DirectionRTL.HasVerticalOrientation()) + tu.Assert(t, !DirectionTTB.HasVerticalOrientation()) + tu.Assert(t, !DirectionBTT.HasVerticalOrientation()) + + for _, test := range []struct { + sideways bool + progression Progression + hb harfbuzz.Direction + }{ + {true, FromTopLeft, harfbuzz.TopToBottom}, + {true, TowardTopLeft, harfbuzz.BottomToTop}, + {false, FromTopLeft, harfbuzz.TopToBottom}, + {false, TowardTopLeft, harfbuzz.BottomToTop}, + } { + d := axisVertical + d.SetProgression(test.progression) + + tu.Assert(t, !d.HasVerticalOrientation()) + d.SetSideways(test.sideways) + + tu.Assert(t, d.HasVerticalOrientation()) + tu.Assert(t, d.IsSideways() == test.sideways) + tu.Assert(t, d.Axis() == Vertical) + tu.Assert(t, d.Progression() == test.progression) + tu.Assert(t, d.Harfbuzz() == test.hb) + } +} diff --git a/go.mod b/go.mod index 93b5b16e..aec56085 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/go-text/typesetting go 1.17 require ( - github.com/go-text/typesetting-utils v0.0.0-20231204162240-fa4dc564ba79 + github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 golang.org/x/image v0.3.0 golang.org/x/text v0.9.0 ) diff --git a/go.sum b/go.sum index 3674d809..b1138d00 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/go-text/typesetting-utils v0.0.0-20231204162240-fa4dc564ba79 h1:3yBOzx29wog0i7TnUBMcp90EwIb+A5kqmr5vny1UOm8= github.com/go-text/typesetting-utils v0.0.0-20231204162240-fa4dc564ba79/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 h1:zBx+p/W2aQYtNuyZNcTfinWvXBQwYtDfme051PR/lAY= +github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/harfbuzz/fonts.go b/harfbuzz/fonts.go index 6162ebe3..380c12f0 100644 --- a/harfbuzz/fonts.go +++ b/harfbuzz/fonts.go @@ -208,11 +208,12 @@ func (f *Font) getGlyphHOriginWithFallback(glyph GID) (Position, Position) { if !ok { x, y, ok = f.face.GlyphVOrigin(glyph) if ok { + x, y := f.emScalefX(float32(x)), f.emScalefY(float32(y)) dx, dy := f.guessVOriginMinusHOrigin(glyph) return x - dx, y - dy } } - return x, y + return f.emScalefX(float32(x)), f.emScalefY(float32(y)) } func (f *Font) getGlyphVOriginWithFallback(glyph GID) (Position, Position) { @@ -220,11 +221,12 @@ func (f *Font) getGlyphVOriginWithFallback(glyph GID) (Position, Position) { if !ok { x, y, ok = f.face.GlyphHOrigin(glyph) if ok { + x, y := f.emScalefX(float32(x)), f.emScalefY(float32(y)) dx, dy := f.guessVOriginMinusHOrigin(glyph) return x + dx, y + dy } } - return x, y + return f.emScalefX(float32(x)), f.emScalefY(float32(y)) } func (f *Font) guessVOriginMinusHOrigin(glyph GID) (x, y Position) { diff --git a/harfbuzz/harfbuzz_shape_test.go b/harfbuzz/harfbuzz_shape_test.go index 48314a5e..0eabff3d 100644 --- a/harfbuzz/harfbuzz_shape_test.go +++ b/harfbuzz/harfbuzz_shape_test.go @@ -22,6 +22,14 @@ import ( func TestShapeExpected(t *testing.T) { tests := collectTests(t) + + // add tests based on the C++ binary + tests = append(tests, + // check we properly scale the offset values + newTestData(t, "", "perf_reference/fonts/Roboto-Regular.ttf;--direction=ttb --font-size=2000;U+0061,U+0062;[gid70=0@-544,-1700+0,-2343|gid71=1@-562,-1912+0,-2343]"), + newTestData(t, "", "perf_reference/fonts/Roboto-Regular.ttf;--direction=ttb --font-size=3000;U+0061,U+0062;[gid70=0@-816,-2550+0,-3515|gid71=1@-842,-2868+0,-3515]"), + ) + fmt.Printf("Running %d tests...\n", len(tests)) for _, testD := range tests { diff --git a/opentype/api/font.go b/opentype/api/font.go index 03e03a00..46f52367 100644 --- a/opentype/api/font.go +++ b/opentype/api/font.go @@ -107,6 +107,21 @@ type GlyphOutline struct { Segments []Segment } +// Sideways updates the coordinates of the outline by applying +// a 90° clockwise rotation, and adding [yOffset] afterwards. +// +// When used for vertical text, pass +// -Glyph.YOffset, converted in font units, as [yOffset] +// (a positive value to lift the glyph up). +func (o GlyphOutline) Sideways(yOffset float32) { + for i := range o.Segments { + target := o.Segments[i].Args[:] + target[0].X, target[0].Y = target[0].Y, -target[0].X+yOffset + target[1].X, target[1].Y = target[1].Y, -target[1].X+yOffset + target[2].X, target[2].Y = target[2].Y, -target[2].X+yOffset + } +} + type SegmentOp uint8 const ( diff --git a/shaping/input.go b/shaping/input.go index 159ea20e..d1ccd4be 100644 --- a/shaping/input.go +++ b/shaping/input.go @@ -10,6 +10,7 @@ import ( "github.com/go-text/typesetting/harfbuzz" "github.com/go-text/typesetting/language" "github.com/go-text/typesetting/opentype/loader" + "github.com/go-text/typesetting/unicodedata" "golang.org/x/image/math/fixed" "golang.org/x/text/unicode/bidi" ) @@ -112,8 +113,8 @@ func SplitByFace(input Input, availableFaces Fontmap) []Input { // script, and face. type Segmenter struct { // pools of inputs, used to reduce allocations, - // which are alternatively considered as input/output of the segmentation - buffer1, buffer2 []Input + // which are alternatively swapped between each step of the segmentation + input, output []Input // used to handle Common script delimStack []delimEntry @@ -130,6 +131,7 @@ type delimEntry struct { // Split segments the given pre-configured input according to: // - text direction // - script +// - (vertical text only) glyph orientation // - face, as defined by [faces] // // Only the input runes in the range [text.RunStart] to [text.RunEnd] will be split. @@ -143,38 +145,54 @@ type delimEntry struct { // [text.Direction] is used during bidi ordering, and should refer to the general // context [text] is used in (typically the user system preference for GUI apps.) // +// For vertical text, if its orientation is set, is copied as it is; otherwise, the +// orientation is resolved using the Unicode recommendations (see https://www.unicode.org/reports/tr50/). +// // The returned sliced is owned by the [Segmenter] and is only valid until // the next call to [Split]. func (seg *Segmenter) Split(text Input, faces Fontmap) []Input { seg.reset() - seg.splitByBidi(text) + seg.splitByBidi(text) // fills output + + seg.input, seg.output = seg.output, seg.input // output is empty seg.splitByScript() + + // if needed, resolve text orientation for vertical text + if text.Direction.IsVertical() && !text.Direction.HasVerticalOrientation() { + seg.input, seg.output = seg.output, seg.input + seg.output = seg.output[:0] + seg.splitByVertOrientation() + } + + seg.input, seg.output = seg.output, seg.input + seg.output = seg.output[:0] seg.splitByFace(faces) - return seg.buffer1 + + return seg.output } func (seg *Segmenter) reset() { // zero the slices to avoid 'memory leak' on pointer slice fields - for i := range seg.buffer1 { - seg.buffer1[i].Text = nil - seg.buffer1[i].FontFeatures = nil + for i := range seg.input { + seg.input[i].Text = nil + seg.input[i].FontFeatures = nil } - for i := range seg.buffer2 { - seg.buffer2[i].Text = nil - seg.buffer2[i].FontFeatures = nil + for i := range seg.output { + seg.output[i].Text = nil + seg.output[i].FontFeatures = nil } - seg.buffer1 = seg.buffer1[:0] - seg.buffer2 = seg.buffer2[:0] + seg.input = seg.input[:0] + seg.output = seg.output[:0] // bidiParagraph is reset when using SetString seg.delimStack = seg.delimStack[:0] } -// fills buffer1 func (seg *Segmenter) splitByBidi(text Input) { - if text.Direction.Axis() != di.Horizontal || text.RunStart >= text.RunEnd { - seg.buffer1 = append(seg.buffer1, text) + // split vertical text like horizontal one + if text.RunStart >= text.RunEnd { + seg.output = append(seg.output, text) return } def := bidi.LeftToRight @@ -184,7 +202,7 @@ func (seg *Segmenter) splitByBidi(text Input) { seg.bidiParagraph.SetString(string(text.Text[text.RunStart:text.RunEnd]), bidi.DefaultDirection(def)) out, err := seg.bidiParagraph.Order() if err != nil { - seg.buffer1 = append(seg.buffer1, text) + seg.output = append(seg.output, text) return } @@ -199,12 +217,12 @@ func (seg *Segmenter) splitByBidi(text Input) { // override the direction if dir == bidi.RightToLeft { - currentInput.Direction = di.DirectionRTL + currentInput.Direction.SetProgression(di.TowardTopLeft) } else { - currentInput.Direction = di.DirectionLTR + currentInput.Direction.SetProgression(di.FromTopLeft) } - seg.buffer1 = append(seg.buffer1, currentInput) + seg.output = append(seg.output, currentInput) input.RunStart = currentInput.RunEnd } } @@ -230,11 +248,9 @@ func lookupDelimIndex(ch rune) int { return -1 } -// uses buffer1 as input and fills buffer2 -// // See https://unicode.org/reports/tr24/#Common for reference func (seg *Segmenter) splitByScript() { - for _, input := range seg.buffer1 { + for _, input := range seg.input { currentInput := input currentInput.Script = language.Common @@ -289,7 +305,7 @@ func (seg *Segmenter) splitByScript() { // split to a new run if i != input.RunStart { // push the existing one currentInput.RunEnd = i - seg.buffer2 = append(seg.buffer2, currentInput) + seg.output = append(seg.output, currentInput) } currentInput.RunStart = i @@ -298,15 +314,46 @@ func (seg *Segmenter) splitByScript() { } // close and add the last input currentInput.RunEnd = input.RunEnd - seg.buffer2 = append(seg.buffer2, currentInput) + seg.output = append(seg.output, currentInput) + } +} + +// assume the script has been resolved +func (seg *Segmenter) splitByVertOrientation() { + for _, input := range seg.input { + vo := unicodedata.LookupVerticalOrientation(input.Script) + currentInput := input + + for i := input.RunStart; i < input.RunEnd; i++ { + r := input.Text[i] + sideways := vo.Orientation(r) + if i == input.RunStart { + // first run : update the orientation, + // but do not create a new run + currentInput.Direction.SetSideways(sideways) + continue + } + + if sideways != currentInput.Direction.IsSideways() { + // create new run : push the current one ... + currentInput.RunEnd = i + seg.output = append(seg.output, currentInput) + + // ... and update the 'new' + currentInput.RunStart = i + currentInput.Direction.SetSideways(sideways) + } + } + + // close and add the last input + currentInput.RunEnd = input.RunEnd + seg.output = append(seg.output, currentInput) } } -// uses buffer2 as input, resets and fills buffer1 func (seg *Segmenter) splitByFace(faces Fontmap) { - seg.buffer1 = seg.buffer1[:0] - for _, input := range seg.buffer2 { - seg.buffer1 = splitByFace(input, faces, seg.buffer1) + for _, input := range seg.input { + seg.output = splitByFace(input, faces, seg.output) } } diff --git a/shaping/input_test.go b/shaping/input_test.go index 7c15e95f..86f16ec9 100644 --- a/shaping/input_test.go +++ b/shaping/input_test.go @@ -259,6 +259,13 @@ func TestSplitBidi(t *testing.T) { {0, len(ltrSource), di.DirectionLTR}, }, }, + { + text: ltrSource, + defaultDirection: di.DirectionTTB, + expectedRuns: []run{ + {0, len(ltrSource), di.DirectionTTB}, + }, + }, { text: ltrSource, defaultDirection: di.DirectionRTL, @@ -267,6 +274,14 @@ func TestSplitBidi(t *testing.T) { {len(ltrSource) - 1, len(ltrSource), di.DirectionRTL}, }, }, + { + text: ltrSource, + defaultDirection: di.DirectionBTT, + expectedRuns: []run{ + {0, len(ltrSource) - 1, di.DirectionTTB}, + {len(ltrSource) - 1, len(ltrSource), di.DirectionBTT}, + }, + }, { text: rtlSource, defaultDirection: di.DirectionRTL, @@ -274,6 +289,13 @@ func TestSplitBidi(t *testing.T) { {0, len(rtlSource), di.DirectionRTL}, }, }, + { + text: rtlSource, + defaultDirection: di.DirectionBTT, + expectedRuns: []run{ + {0, len(rtlSource), di.DirectionBTT}, + }, + }, { text: bidiSource, defaultDirection: di.DirectionLTR, @@ -301,10 +323,9 @@ func TestSplitBidi(t *testing.T) { } { var seg Segmenter seg.splitByBidi(Input{Text: test.text, RunEnd: len(test.text), Direction: test.defaultDirection}) - inputs := seg.buffer1 - tu.AssertC(t, len(inputs) == len(test.expectedRuns), string(test.text)) + tu.AssertC(t, len(seg.output) == len(test.expectedRuns), string(test.text)) for i, run := range test.expectedRuns { - got := inputs[i] + got := seg.output[i] tu.Assert(t, got.RunStart == run.start) tu.Assert(t, got.RunEnd == run.end) tu.Assert(t, got.Direction == run.dir) @@ -360,14 +381,14 @@ func TestSplitScript(t *testing.T) { }}, } { var seg Segmenter - seg.splitByBidi(Input{Text: test.text, RunEnd: len(test.text), Direction: di.DirectionLTR}) // fills buffer1 - tu.Assert(t, len(seg.buffer1) == 1) + seg.splitByBidi(Input{Text: test.text, RunEnd: len(test.text), Direction: di.DirectionLTR}) + tu.Assert(t, len(seg.output) == 1) + seg.input, seg.output = seg.output, seg.input seg.splitByScript() - inputs := seg.buffer2 - tu.Assert(t, len(inputs) == len(test.expectedRuns)) + tu.Assert(t, len(seg.output) == len(test.expectedRuns)) for i, run := range test.expectedRuns { - got := inputs[i] + got := seg.output[i] tu.Assert(t, got.RunStart == run.start) tu.Assert(t, got.RunEnd == run.end) tu.Assert(t, got.Script == run.script) @@ -375,11 +396,69 @@ func TestSplitScript(t *testing.T) { } } +func TestSplitVertOrientation(t *testing.T) { + type run struct { + start, end int + sideways bool + } + for _, test := range []struct { + text []rune + expectedRuns []run + }{ + { + []rune("A regular latin sentence."), + []run{ + {0, 25, true}, + }, + }, + { + []rune("ごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみ"), + []run{ + {0, 44, false}, // Hiragana is upright + }, + }, + { + []rune("ごさざ.しじす;ずせ"), + []run{ + {0, 10, false}, // Hiragana is upright + }, + }, + { + []rune("もつ青いそら…"), + []run{ + {0, 2, false}, // Hiragana is upright + {2, 3, false}, + {3, 7, false}, // Hiragana is upright + }, + }, + } { + var seg Segmenter + seg.input = []Input{{Text: test.text, RunEnd: len(test.text), Direction: di.DirectionTTB}} + + seg.splitByScript() + seg.input, seg.output = seg.output, seg.input + seg.output = seg.output[:0] + seg.splitByVertOrientation() + tu.Assert(t, len(seg.output) == len(test.expectedRuns)) + for i, run := range test.expectedRuns { + got := seg.output[i] + tu.Assert(t, got.RunStart == run.start) + tu.Assert(t, got.RunEnd == run.end) + tu.Assert(t, got.Direction.HasVerticalOrientation()) + tu.Assert(t, got.Direction.IsSideways() == run.sideways) + } + } +} + func TestSplit(t *testing.T) { latinFont := loadOpentypeFont(t, "../font/testdata/Roboto-Regular.ttf") arabicFont := loadOpentypeFont(t, "../font/testdata/Amiri-Regular.ttf") fm := fixedFontmap{latinFont, arabicFont} + sideways, upright := di.DirectionTTB, di.DirectionTTB + sideways.SetSideways(true) + upright.SetSideways(false) + var seg Segmenter type run struct { @@ -390,22 +469,27 @@ func TestSplit(t *testing.T) { } for _, test := range []struct { text string + dir di.Direction expectedRuns []run }{ { "", + di.DirectionLTR, []run{{0, 0, di.DirectionLTR, language.Common, nil}}, }, { "The quick brown fox jumps over the lazy dog.", + di.DirectionLTR, []run{{0, 44, di.DirectionLTR, language.Latin, latinFont}}, }, { "الحب سماء لا تمط غير الأحلام", + di.DirectionLTR, []run{{0, 28, di.DirectionRTL, language.Arabic, arabicFont}}, }, { "The quick سماء שלום لا fox تمط שלום غير the lazy dog.", + di.DirectionLTR, []run{ {0, 10, di.DirectionLTR, language.Latin, latinFont}, {10, 15, di.DirectionRTL, language.Arabic, arabicFont}, @@ -420,6 +504,7 @@ func TestSplit(t *testing.T) { }, { "الحب سماء brown привет fox تمط jumps привет over غير الأحلام", + di.DirectionLTR, []run{ {0, 10, di.DirectionRTL, language.Arabic, arabicFont}, {10, 16, di.DirectionLTR, language.Latin, latinFont}, @@ -432,11 +517,41 @@ func TestSplit(t *testing.T) { {48, 60, di.DirectionRTL, language.Arabic, arabicFont}, }, }, + // vertical text + { + "A french word", + di.DirectionTTB, + []run{ + {0, 13, sideways, language.Latin, latinFont}, + }, + }, + { + "A french word", + upright, + []run{ + {0, 13, upright, language.Latin, latinFont}, + }, + }, + { + "with upright \uff21\uff22\uff23", + di.DirectionTTB, + []run{ + {0, 13, sideways, language.Latin, latinFont}, + {13, 16, upright, language.Latin, latinFont}, + }, + }, + { + "ᠬᠦᠮᠦᠨ ᠪᠦ", + di.DirectionTTB, + []run{ + {0, 8, sideways, language.Mongolian, latinFont}, + }, + }, } { inputs := seg.Split(Input{ Text: []rune(test.text), RunEnd: len([]rune(test.text)), - Direction: di.DirectionLTR, + Direction: test.dir, Size: 10, Language: "fr", @@ -459,7 +574,7 @@ func TestSplit(t *testing.T) { Text: []rune("DUMMY" + test.text + "DUMMY"), RunStart: 5, RunEnd: 5 + len([]rune(test.text)), - Direction: di.DirectionLTR, + Direction: test.dir, }, fm) tu.Assert(t, len(inputs) == len(test.expectedRuns)) for i, run := range test.expectedRuns { diff --git a/shaping/output.go b/shaping/output.go index 59a977d6..9547f2cc 100644 --- a/shaping/output.go +++ b/shaping/output.go @@ -20,7 +20,8 @@ type Glyph struct { // typically negative Height fixed.Int26_6 // XBearing is the distance between the dot (with offset applied) and - // the glyph content, typically positive + // the glyph content, typically positive for horizontal text; + // often negative for vertical text. XBearing fixed.Int26_6 // YBearing is the distance between the dot (with offset applied) and // the top of the glyph content, typically positive @@ -34,6 +35,8 @@ type Glyph struct { // Offsets to be applied to the dot before actually drawing // the glyph. + // For vertical text, YOffset is typically used to position the glyph + // below the horizontal line at the dot XOffset, YOffset fixed.Int26_6 // ClusterIndex is the lowest rune index of all runes shaped into @@ -110,6 +113,7 @@ func (b Bounds) LineThickness() fixed.Int26_6 { // Output describes the dimensions and content of shaped text. type Output struct { // Advance is the distance the Dot has advanced. + // It is typically positive for horizontal text, negative for vertical. Advance fixed.Int26_6 // Size is copied from the shaping.Input.Size that produced this Output. Size fixed.Int26_6 @@ -121,6 +125,8 @@ type Output struct { // GlyphBounds describes a tight bounding box on the specific glyphs contained // within this output. The dimensions may not be sufficient to contain all // glyphs within the chosen font. + // + // Its [Gap] field is always zero. GlyphBounds Bounds // Direction is the direction used to shape the text, @@ -136,6 +142,17 @@ type Output struct { Face font.Face } +// 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()) +} + +// 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())) +} + // 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. @@ -183,8 +200,8 @@ func (o *Output) advanceSpaceAware() fixed.Int26_6 { func (o *Output) RecalculateAll() { var ( advance fixed.Int26_6 - tallest fixed.Int26_6 - lowest fixed.Int26_6 + ascent fixed.Int26_6 + descent fixed.Int26_6 ) if o.Direction.IsVertical() { @@ -192,12 +209,12 @@ func (o *Output) RecalculateAll() { g := &o.Glyphs[i] advance += g.YAdvance depth := g.XOffset + g.XBearing // start of the glyph - if depth < lowest { - lowest = depth + if depth < descent { + descent = depth } - height := g.XOffset + g.Width // end of the glyph - if height > tallest { - tallest = height + height := depth + g.Width // end of the glyph + if height > ascent { + ascent = height } } } else { // horizontal @@ -205,18 +222,109 @@ func (o *Output) RecalculateAll() { g := &o.Glyphs[i] advance += g.XAdvance height := g.YBearing + g.YOffset - if height > tallest { - tallest = height + if height > ascent { + ascent = height } depth := height + g.Height - if depth < lowest { - lowest = depth + if depth < descent { + descent = depth } } } o.Advance = advance o.GlyphBounds = Bounds{ - Ascent: tallest, - Descent: lowest, + Ascent: ascent, + Descent: descent, + } +} + +// Assuming [Glyphs] comes from an horizontal shaping, +// applies a 90°, clockwise rotation to the whole slice of glyphs, +// to create 'sideways' vertical text. +// +// The [Direction] field is updated by switching the axis to vertical +// and the orientation to "sideways". +// +// [RecalculateAll] should be called afterwards to update [Avance] and [GlyphBounds]. +func (out *Output) sideways() { + for i, g := range out.Glyphs { + // switch height and width + out.Glyphs[i].Width = -g.Height // height is negative + out.Glyphs[i].Height = -g.Width + // compute the bearings + out.Glyphs[i].XBearing = g.YBearing + g.Height + out.Glyphs[i].YBearing = g.Width + // switch advance direction + out.Glyphs[i].XAdvance = 0 + out.Glyphs[i].YAdvance = -g.XAdvance // YAdvance is negative + // apply a rotation around the dot, and position the glyph + // below the dot + out.Glyphs[i].XOffset = g.YOffset + out.Glyphs[i].YOffset = -(g.XOffset + g.XBearing + g.Width) + } + + // adjust direction + out.Direction.SetSideways(true) +} + +// properly update [GlyphBounds] +func (out *Output) moveCrossAxis(d fixed.Int26_6) { + if out.Direction.IsVertical() { + for i := range out.Glyphs { + out.Glyphs[i].XOffset += d + } + } else { + for i := range out.Glyphs { + out.Glyphs[i].YOffset += d + } + } + out.GlyphBounds.Ascent += d + out.GlyphBounds.Descent += d +} + +// AdjustBaselines aligns runs with different baselines. +// +// For vertical text, it centralizes 'sideways' runs, so +// that text with mixed 'upright' and +// 'sideways' orientation is better aligned. +// +// This is currently a no-op for horizontal text. +// +// Note that this method only update cross-axis metrics, +// so that the advance is preserved. As such, it is valid +// to call this method after line wrapping, for instance. +func (l Line) AdjustBaselines() { + if len(l) == 0 { + return + } + firstRun := l[0] + + if firstRun.Direction.Axis() == di.Horizontal { + return + } + + // Centralize sideways runs, to better align + // with upright ones, which are usually visually centered. + // We want to shift all the runs by the same amount, to + // avoid breaking alignment of similar runs (consider "A あ is a pretty char.") + var sidewaysBounds Bounds + for _, run := range l { + if !run.Direction.IsSideways() { + continue + } + if a := run.GlyphBounds.Ascent; a > sidewaysBounds.Ascent { + sidewaysBounds.Ascent = a + } + if d := run.GlyphBounds.Descent; d < sidewaysBounds.Descent { + sidewaysBounds.Descent = d + } + } + // Place the middle of sideways run at the baseline (the zero) + middle := sidewaysBounds.Descent + sidewaysBounds.LineThickness()/2 + for i := range l { + if !l[i].Direction.IsSideways() { + continue + } + l[i].moveCrossAxis(-middle) } } diff --git a/shaping/output_test.go b/shaping/output_test.go index 81ef0e73..9c914620 100644 --- a/shaping/output_test.go +++ b/shaping/output_test.go @@ -1,14 +1,17 @@ // SPDX-License-Identifier: Unlicense OR BSD-3-Clause -package shaping_test +package shaping import ( + "bytes" "reflect" "testing" + hd "github.com/go-text/typesetting-utils/harfbuzz" "github.com/go-text/typesetting/di" "github.com/go-text/typesetting/font" - "github.com/go-text/typesetting/shaping" + "github.com/go-text/typesetting/language" + tu "github.com/go-text/typesetting/opentype/testutils" "golang.org/x/image/math/fixed" ) @@ -21,42 +24,38 @@ const ( ) var ( - expectedFontExtents = shaping.Bounds{ - Ascent: fixed.I(int(15)), - Descent: fixed.I(int(-15)), - Gap: fixed.I(int(0)), + expectedFontExtents = Bounds{ + Ascent: fixed.I(15), + Descent: fixed.I(-15), + Gap: fixed.I(0), } - simpleGlyph = shaping.Glyph{ + simpleGlyph_ = Glyph{ GlyphID: simpleGID, - XAdvance: fixed.I(int(10)), - YAdvance: fixed.I(int(10)), - XOffset: fixed.I(int(0)), - YOffset: fixed.I(int(0)), - Width: fixed.I(int(10)), - Height: -fixed.I(int(10)), - YBearing: fixed.I(int(10)), + XAdvance: fixed.I(10), + YAdvance: -fixed.I(10), + Width: fixed.I(10), + Height: -fixed.I(10), + YBearing: fixed.I(10), } - deepGlyph = shaping.Glyph{ + deepGlyph = Glyph{ GlyphID: deepGID, - XAdvance: fixed.I(int(10)), - YAdvance: fixed.I(int(10)), - XOffset: -fixed.I(int(5)), - YOffset: fixed.I(int(0)), - Width: fixed.I(int(10)), - Height: -fixed.I(int(10)), - YBearing: fixed.I(int(0)), - XBearing: fixed.I(int(0)), + XAdvance: fixed.I(10), + YAdvance: -fixed.I(10), + XOffset: -fixed.I(5), + Width: fixed.I(10), + Height: -fixed.I(10), + YBearing: fixed.I(0), } - offsetGlyph = shaping.Glyph{ + offsetGlyph = Glyph{ GlyphID: offsetGID, - XAdvance: fixed.I(int(10)), - YAdvance: fixed.I(int(10)), - XOffset: -fixed.I(int(2)), - YOffset: fixed.I(int(2)), - Width: fixed.I(int(10)), - Height: -fixed.I(int(10)), - YBearing: fixed.I(int(10)), - XBearing: fixed.I(int(1)), + XAdvance: fixed.I(10), + YAdvance: -fixed.I(10), + XOffset: -fixed.I(2), + YOffset: fixed.I(2), + Width: fixed.I(10), + Height: -fixed.I(10), + YBearing: fixed.I(10), + XBearing: fixed.I(1), } ) @@ -66,25 +65,25 @@ func TestRecalculate(t *testing.T) { type testcase struct { Name string Direction di.Direction - Input []shaping.Glyph - Output shaping.Output + Input []Glyph + Output Output } for _, tc := range []testcase{ { Name: "empty", - Output: shaping.Output{ + Output: Output{ LineBounds: expectedFontExtents, }, }, { Name: "horizontal single simple glyph", Direction: di.DirectionLTR, - Input: []shaping.Glyph{simpleGlyph}, - Output: shaping.Output{ - Glyphs: []shaping.Glyph{simpleGlyph}, - Advance: simpleGlyph.XAdvance, - GlyphBounds: shaping.Bounds{ - Ascent: simpleGlyph.YBearing, + Input: []Glyph{simpleGlyph_}, + Output: Output{ + Glyphs: []Glyph{simpleGlyph_}, + Advance: simpleGlyph_.XAdvance, + GlyphBounds: Bounds{ + Ascent: simpleGlyph_.YBearing, Descent: fixed.I(0), }, LineBounds: expectedFontExtents, @@ -93,12 +92,12 @@ func TestRecalculate(t *testing.T) { { Name: "horizontal glyph below baseline", Direction: di.DirectionLTR, - Input: []shaping.Glyph{simpleGlyph, deepGlyph}, - Output: shaping.Output{ - Glyphs: []shaping.Glyph{simpleGlyph, deepGlyph}, - Advance: simpleGlyph.XAdvance + deepGlyph.XAdvance, - GlyphBounds: shaping.Bounds{ - Ascent: simpleGlyph.YBearing, + Input: []Glyph{simpleGlyph_, deepGlyph}, + Output: Output{ + Glyphs: []Glyph{simpleGlyph_, deepGlyph}, + Advance: simpleGlyph_.XAdvance + deepGlyph.XAdvance, + GlyphBounds: Bounds{ + Ascent: simpleGlyph_.YBearing, Descent: deepGlyph.YBearing + deepGlyph.Height, }, LineBounds: expectedFontExtents, @@ -107,11 +106,11 @@ func TestRecalculate(t *testing.T) { { Name: "horizontal single complex glyph", Direction: di.DirectionLTR, - Input: []shaping.Glyph{offsetGlyph}, - Output: shaping.Output{ - Glyphs: []shaping.Glyph{offsetGlyph}, + Input: []Glyph{offsetGlyph}, + Output: Output{ + Glyphs: []Glyph{offsetGlyph}, Advance: offsetGlyph.XAdvance, - GlyphBounds: shaping.Bounds{ + GlyphBounds: Bounds{ Ascent: offsetGlyph.YBearing + offsetGlyph.YOffset, Descent: fixed.I(0), }, @@ -121,12 +120,12 @@ func TestRecalculate(t *testing.T) { { Name: "vertical single simple glyph", Direction: di.DirectionTTB, - Input: []shaping.Glyph{simpleGlyph}, - Output: shaping.Output{ - Glyphs: []shaping.Glyph{simpleGlyph}, - Advance: simpleGlyph.YAdvance, - GlyphBounds: shaping.Bounds{ - Ascent: simpleGlyph.Width, + Input: []Glyph{simpleGlyph_}, + Output: Output{ + Glyphs: []Glyph{simpleGlyph_}, + Advance: simpleGlyph_.YAdvance, + GlyphBounds: Bounds{ + Ascent: simpleGlyph_.Width, Descent: 0, }, LineBounds: expectedFontExtents, @@ -135,12 +134,12 @@ func TestRecalculate(t *testing.T) { { Name: "vertical glyph below baseline", Direction: di.DirectionTTB, - Input: []shaping.Glyph{simpleGlyph, deepGlyph}, - Output: shaping.Output{ - Glyphs: []shaping.Glyph{simpleGlyph, deepGlyph}, - Advance: simpleGlyph.YAdvance + deepGlyph.YAdvance, - GlyphBounds: shaping.Bounds{ - Ascent: simpleGlyph.Width, + Input: []Glyph{simpleGlyph_, deepGlyph}, + Output: Output{ + Glyphs: []Glyph{simpleGlyph_, deepGlyph}, + Advance: simpleGlyph_.YAdvance + deepGlyph.YAdvance, + GlyphBounds: Bounds{ + Ascent: simpleGlyph_.Width, Descent: deepGlyph.XOffset + deepGlyph.XBearing, }, LineBounds: expectedFontExtents, @@ -149,12 +148,12 @@ func TestRecalculate(t *testing.T) { { Name: "vertical single complex glyph", Direction: di.DirectionTTB, - Input: []shaping.Glyph{offsetGlyph}, - Output: shaping.Output{ - Glyphs: []shaping.Glyph{offsetGlyph}, + Input: []Glyph{offsetGlyph}, + Output: Output{ + Glyphs: []Glyph{offsetGlyph}, Advance: offsetGlyph.YAdvance, - GlyphBounds: shaping.Bounds{ - Ascent: offsetGlyph.Width + offsetGlyph.XOffset, + GlyphBounds: Bounds{ + Ascent: offsetGlyph.Width + offsetGlyph.XOffset + offsetGlyph.XBearing, Descent: offsetGlyph.XOffset + offsetGlyph.XBearing, }, LineBounds: expectedFontExtents, @@ -162,7 +161,7 @@ func TestRecalculate(t *testing.T) { }, } { t.Run(tc.Name, func(t *testing.T) { - output := shaping.Output{ + output := Output{ Glyphs: tc.Input, LineBounds: expectedFontExtents, Direction: tc.Direction, @@ -186,3 +185,123 @@ func TestRecalculate(t *testing.T) { }) } } + +func TestRotate(t *testing.T) { + glyphs1 := Output{ + Glyphs: []Glyph{ + simpleGlyph_, deepGlyph, offsetGlyph, deepGlyph, deepGlyph, offsetGlyph, + }, + Direction: di.DirectionRTL, + } + glyphs2 := func() Output { + textInput := []rune("abcdefghijklmnop") + withKerningFont := "harfbuzz_reference/in-house/fonts/e39391c77a6321c2ac7a2d644de0396470cd4bfe.ttf" + b, _ := hd.Files.ReadFile(withKerningFont) + face, _ := font.ParseTTF(bytes.NewReader(b)) + + shaper := HarfbuzzShaper{} + input := Input{ + Text: textInput, + RunStart: 0, + RunEnd: len(textInput), + Direction: di.DirectionLTR, + Face: face, + Size: 16 * 72 * 10, + Script: language.Latin, + Language: language.NewLanguage("EN"), + } + + horiz := shaper.Shape(input) + return horiz + }() + for _, horiz := range []Output{glyphs1, glyphs2} { + horiz.RecalculateAll() + vert := horiz + vert.sideways() + vert.RecalculateAll() + + tu.Assert(t, vert.Direction.IsVertical()) + tu.Assert(t, vert.Direction.Progression() == horiz.Direction.Progression()) + // test that rotate actually preserve the bounds + tu.Assert(t, horiz.LineBounds == vert.LineBounds) + tu.Assert(t, horiz.GlyphBounds == vert.GlyphBounds) + tu.Assert(t, horiz.Advance == -vert.Advance) + } +} + +func TestConvertUnit(t *testing.T) { + f := benchEnFace + tu.Assert(t, f.Upem() == 2048) + + for _, test := range []struct { + size fixed.Int26_6 + font float32 + scaled fixed.Int26_6 + }{ + {fixed.I(100), 2048, fixed.I(100)}, + {fixed.I(100), 1024, fixed.I(50)}, + {fixed.I(100), 204.8, fixed.I(10)}, + {fixed.I(30), 2048, fixed.I(30)}, + {fixed.I(30), 1024, fixed.I(15)}, + {fixed.I(12), 1024, fixed.I(6)}, + {fixed.Int26_6(1<<6 + 20), 1024, fixed.Int26_6(1<<6+20) / 2}, + } { + o := Output{Size: test.size, Face: f} + tu.Assert(t, o.FromFontUnit(test.font) == test.scaled) + tu.Assert(t, o.ToFontUnit(test.scaled) == test.font) + } +} + +func TestLine_AdjustBaseline(t *testing.T) { + var sideways di.Direction + sideways.SetSideways(true) + + glyph := func(offset, width int) Glyph { return Glyph{Width: fixed.I(width), XOffset: fixed.I(offset)} } + tests := []struct { + l Line + ascents []int + descents []int + }{ + {Line{}, []int{}, []int{}}, // no-op + {Line{{Direction: di.DirectionLTR}}, []int{0}, []int{0}}, // no-op + {Line{ + { + Direction: di.DirectionTTB, + Glyphs: []Glyph{glyph(0, 20), glyph(0, 30)}, + GlyphBounds: Bounds{Ascent: fixed.I(30), Descent: fixed.I(0)}, + }, + { + Direction: sideways, + Glyphs: []Glyph{glyph(-10, 20), glyph(-10, 40)}, + GlyphBounds: Bounds{Ascent: fixed.I(40), Descent: fixed.I(-10)}, + }, + }, []int{30, 25}, []int{0, -25}}, // no-op + {Line{ + { + Direction: sideways, + Glyphs: []Glyph{glyph(-10, 20), glyph(-10, 20)}, + GlyphBounds: Bounds{Ascent: fixed.I(20), Descent: fixed.I(-10)}, + }, + { + Direction: di.DirectionTTB, + Glyphs: []Glyph{glyph(0, 20), glyph(0, 30)}, + GlyphBounds: Bounds{Ascent: fixed.I(30), Descent: fixed.I(0)}, + }, + { + Direction: sideways, + Glyphs: []Glyph{glyph(0, 20), glyph(0, 40)}, + GlyphBounds: Bounds{Ascent: fixed.I(40), Descent: fixed.I(0)}, + }, + }, []int{5, 30, 25}, []int{-25, 0, -15}}, // no-op + + } + for _, tt := range tests { + tt.l.AdjustBaselines() + for i, exp := range tt.ascents { + tu.Assert(t, tt.l[i].GlyphBounds.Ascent == fixed.I(exp)) + } + for i, exp := range tt.descents { + tu.Assert(t, tt.l[i].GlyphBounds.Descent == fixed.I(exp)) + } + } +} diff --git a/shaping/render_test.go b/shaping/render_test.go new file mode 100644 index 00000000..171d4227 --- /dev/null +++ b/shaping/render_test.go @@ -0,0 +1,195 @@ +package shaping + +import ( + "image" + "image/color" + "image/draw" + "image/png" + "os" + + "github.com/go-text/typesetting/opentype/api" +) + +// this file implements a very primitive "rasterizer", which can +// be used to visually inspect shaper outputs. + +func drawVLine(img *image.RGBA, start image.Point, height int, c color.RGBA) { + for y := start.Y; y <= start.Y+height; y++ { + img.SetRGBA(start.X, y, c) + } +} + +func drawHLine(img *image.RGBA, start image.Point, width int, c color.RGBA) { + for x := start.X; x <= start.X+width; x++ { + img.SetRGBA(x, start.Y, c) + } +} + +func drawRect(img *image.RGBA, min, max image.Point, c color.RGBA) { + for x := min.X; x <= max.X; x++ { + for y := min.Y; y <= max.Y; y++ { + img.SetRGBA(x, y, c) + } + } +} + +func drawPoint(img *image.RGBA, pt image.Point, c color.RGBA) { + drawRect(img, pt.Add(image.Pt(-1, -1)), pt.Add(image.Pt(1, 1)), c) +} + +// dot includes the offset +func drawGlyph(out *Output, img *image.RGBA, dot image.Point, outlines api.GlyphOutline, c color.RGBA) { + var current api.SegmentPoint + for _, seg := range outlines.Segments { + points := seg.ArgsSlice() + for _, point := range points { + x, y := out.FromFontUnit(point.X).Round(), -out.FromFontUnit(point.Y).Round() + drawPoint(img, dot.Add(image.Pt(x, y)), c) + } + + last := points[len(points)-1] + + if seg.Op == api.SegmentOpLineTo { + for t := float32(0); t < 1; t += 0.2 { + middleX := out.FromFontUnit(t*current.X + (1-t)*last.X).Round() + middleY := -out.FromFontUnit(t*current.Y + (1-t)*last.Y).Round() + drawPoint(img, dot.Add(image.Pt(middleX, middleY)), c) + } + } + + current = last + } +} + +var ( + red = color.RGBA{R: 0xFF, A: 0xFF} + green = color.RGBA{R: 0xCF, G: 0xFF, B: 0xCF, A: 0xFF} + black = color.RGBA{A: 0xFF} +) + +func imageDims(line []Output) (width, height, baseline int) { + firstRun := line[0] + if firstRun.Direction.IsVertical() { + ascent, descent := 0, 0 + for _, run := range line { + if a := run.GlyphBounds.Ascent.Round(); a > ascent { + ascent = a + } + if d := run.GlyphBounds.Descent.Round(); d < descent { + descent = d + } + height += -run.Advance.Round() + } + baseline = -descent + width = ascent - descent + } else { + ascent, descent := 0, 0 + for _, run := range line { + if a := run.GlyphBounds.Ascent.Round(); a > ascent { + ascent = a + } + if d := run.GlyphBounds.Descent.Round(); d < descent { + descent = d + } + width += run.Advance.Round() + } + baseline = ascent + height = ascent - descent + } + return +} + +func drawTextLine(runs []Output, file string) error { + width, height, baseline := imageDims(runs) + img := image.NewRGBA(image.Rect(0, 0, width, height)) + // white background + draw.Draw(img, img.Rect, image.NewUniform(color.White), image.Point{}, draw.Src) + + if runs[0].Direction.IsVertical() { + // draw the baseline + drawVLine(img, image.Pt(baseline, 0), height, black) + + dot := image.Pt(baseline, 0) + for _, run := range runs { + dot = drawVRun(run, img, dot) + } + } else { + // draw the baseline + drawHLine(img, image.Pt(0, baseline), width, black) + + dot := image.Pt(0, baseline) + for _, run := range runs { + dot = drawHRun(run, img, dot) + } + } + + f, err := os.Create(file) + if err != nil { + return err + } + if err = png.Encode(f, img); err != nil { + return err + } + err = f.Close() + return err +} + +// assume horizontal direction +func drawHRun(out Output, img *image.RGBA, dot image.Point) image.Point { + for _, g := range out.Glyphs { + // image has Y axis pointing down + dotWithOffset := dot.Add(image.Pt(g.XOffset.Round(), -g.YOffset.Round())) + + minX := dotWithOffset.X + g.XBearing.Round() + maxX := minX + g.Width.Round() + minY := dotWithOffset.Y - g.YBearing.Round() + maxY := minY - g.Height.Round() + + drawRect(img, image.Pt(minX, minY), image.Pt(maxX, maxY), green) + + // draw the dot + drawPoint(img, dot, black) + + // draw a sketch of the glyphs + glyphData := out.Face.GlyphData(g.GlyphID).(api.GlyphOutline) + drawGlyph(&out, img, dotWithOffset, glyphData, black) + + dot.X += g.XAdvance.Round() + // draw the advance + drawVLine(img, image.Pt(dot.X, 0), img.Bounds().Dy(), red) + } + + return dot +} + +// assume vertical direction +func drawVRun(out Output, img *image.RGBA, dot image.Point) image.Point { + for _, g := range out.Glyphs { + // image has Y axis pointing down + dotWithOffset := dot.Add(image.Pt(g.XOffset.Round(), -g.YOffset.Round())) + + minX := dotWithOffset.X + g.XBearing.Round() + maxX := minX + g.Width.Round() + minY := dotWithOffset.Y - g.YBearing.Round() + maxY := minY - g.Height.Round() + + drawRect(img, image.Pt(minX, minY), image.Pt(maxX, maxY), green) + + // draw the dot + drawPoint(img, dot, black) + + // draw a sketch of the glyphs + glyphData := out.Face.GlyphData(g.GlyphID).(api.GlyphOutline) + if out.Direction.IsSideways() { + glyphData.Sideways(out.ToFontUnit(-g.YOffset)) + } + drawGlyph(&out, img, dotWithOffset, glyphData, black) + + dot.Y += -g.YAdvance.Round() + + // draw the advance + drawHLine(img, image.Pt(0, dot.Y), img.Bounds().Dx(), red) + } + + return dot +} diff --git a/shaping/shaper.go b/shaping/shaping.go similarity index 88% rename from shaping/shaper.go rename to shaping/shaping.go index 6c1dddcf..26395028 100644 --- a/shaping/shaper.go +++ b/shaping/shaping.go @@ -72,17 +72,16 @@ func (t *HarfbuzzShaper) Shape(input Input) Output { start = clamp(start, 0, len(runes)) end = clamp(end, 0, len(runes)) t.buf.AddRunes(runes, start, end-start) - switch input.Direction { - case di.DirectionRTL: - t.buf.Props.Direction = harfbuzz.RightToLeft - case di.DirectionBTT: - t.buf.Props.Direction = harfbuzz.BottomToTop - case di.DirectionTTB: - t.buf.Props.Direction = harfbuzz.TopToBottom - default: - // Default to LTR. - t.buf.Props.Direction = harfbuzz.LeftToRight + + // handle vertical sideways text + isSideways := false + if input.Direction.IsSideways() { + // temporarily switch to horizontal + input.Direction = input.Direction.SwitchAxis() + isSideways = true } + + t.buf.Props.Direction = input.Direction.Harfbuzz() t.buf.Props.Language = input.Language t.buf.Props.Script = input.Script @@ -137,28 +136,36 @@ func (t *HarfbuzzShaper) Shape(input Input) Output { glyphs[i].XOffset = fixed.I(int(t.buf.Pos[i].XOffset)) >> scaleShift glyphs[i].YOffset = fixed.I(int(t.buf.Pos[i].YOffset)) >> scaleShift } - countClusters(glyphs, input.RunEnd, input.Direction) + countClusters(glyphs, input.RunEnd, input.Direction.Progression()) out := Output{ Glyphs: glyphs, Direction: input.Direction, Face: input.Face, Size: input.Size, } - fontExtents := font.ExtentsForDirection(t.buf.Props.Direction) + out.Runes.Offset = input.RunStart + out.Runes.Count = input.RunEnd - input.RunStart + + if isSideways { + // set the Direction to the correct value. + // this is required here so that the following call to ExtentsForDirection + // returns the vertical data. + out.sideways() + } + + fontExtents := font.ExtentsForDirection(out.Direction.Harfbuzz()) out.LineBounds = Bounds{ Ascent: fixed.I(int(fontExtents.Ascender)) >> scaleShift, Descent: fixed.I(int(fontExtents.Descender)) >> scaleShift, Gap: fixed.I(int(fontExtents.LineGap)) >> scaleShift, } - out.Runes.Offset = input.RunStart - out.Runes.Count = input.RunEnd - input.RunStart out.RecalculateAll() return out } // countClusters tallies the number of runes and glyphs in each cluster // and updates the relevant fields on the provided glyph slice. -func countClusters(glyphs []Glyph, textLen int, dir di.Direction) { +func countClusters(glyphs []Glyph, textLen int, dir di.Progression) { currentCluster := -1 runesInCluster := 0 glyphsInCluster := 0 @@ -184,7 +191,7 @@ func countClusters(glyphs []Glyph, textLen int, dir di.Direction) { if nextCluster == -1 { nextCluster = textLen } - switch dir.Progression() { + switch dir { case di.FromTopLeft: runesInCluster = nextCluster - currentCluster case di.TowardTopLeft: diff --git a/shaping/shaping_test.go b/shaping/shaping_test.go index da45ba5d..222f8e60 100644 --- a/shaping/shaping_test.go +++ b/shaping/shaping_test.go @@ -3,10 +3,6 @@ package shaping import ( "bytes" "fmt" - "image" - "image/color" - "image/draw" - "image/png" "os" "path/filepath" "runtime" @@ -208,7 +204,7 @@ func TestCountClusters(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - countClusters(tc.glyphs, tc.textLen, tc.dir) + countClusters(tc.glyphs, tc.textLen, tc.dir.Progression()) for i := range tc.glyphs { g := tc.glyphs[i] e := tc.expected[i] @@ -496,6 +492,42 @@ func TestFeatures(t *testing.T) { tu.Assert(t, len(out.Glyphs) == 1) } +func TestShapeVertical(t *testing.T) { + // consistency check on the internal axis switch + // for sideways vertical text + textInput := []rune("Lorem ipsum.") + face := benchEnFace + input := Input{ + Text: textInput, + RunStart: 0, + RunEnd: len(textInput), + Direction: di.DirectionTTB, + Face: face, + Size: 16 * 72, + Script: language.Latin, + Language: language.NewLanguage("EN"), + } + shaper := HarfbuzzShaper{} + + for _, test := range []struct { + dir di.Direction + sideways bool + }{ + {di.DirectionTTB, false}, + {di.DirectionTTB, true}, + {di.DirectionBTT, false}, + {di.DirectionBTT, true}, + } { + input.Direction = test.dir + input.Direction.SetSideways(test.sideways) + out := shaper.Shape(input) + tu.Assert(t, out.Direction.Progression() == test.dir.Progression()) + tu.Assert(t, out.Direction.IsSideways() == test.sideways) + tu.Assert(t, out.Advance < 0) + tu.Assert(t, out.GlyphBounds.Ascent > 0 && out.GlyphBounds.Descent < 0) + } +} + func TestCFF2(t *testing.T) { // regression test for https://github.com/go-text/typesetting/issues/118 b, err := td.Files.ReadFile("common/NotoSansCJKjp-VF.otf") @@ -520,6 +552,71 @@ func TestCFF2(t *testing.T) { tu.Assert(t, out.Advance > 0) } +func TestShapeVerticalScripts(t *testing.T) { + b, _ := td.Files.ReadFile("common/NotoSansMongolian-Regular.ttf") + monF, _ := font.ParseTTF(bytes.NewReader(b)) + b, _ = td.Files.ReadFile("common/mplus-1p-regular.ttf") + japF, _ := font.ParseTTF(bytes.NewReader(b)) + + monT := []rune("ᠬᠦᠮᠦᠨ ᠪᠦᠷ ᠲᠥᠷᠥᠵᠦ") + japT := []rune("青いそら…") + mixedT := []rune("あHelloあUne phrase") + + var ( + seg Segmenter + shaper HarfbuzzShaper + ) + + { + runs := seg.Split(Input{ + Text: monT, + RunEnd: len(monT), + Language: language.NewLanguage("mn"), + Size: fixed.I(12 * 16), + Direction: di.DirectionTTB, + }, fixedFontmap{monF}) + tu.Assert(t, len(runs) == 1) + + line := Line{shaper.Shape(runs[0])} + err := drawTextLine(line, filepath.Join(os.TempDir(), "shape_vert_mongolian.png")) + tu.AssertNoErr(t, err) + + line.AdjustBaselines() + err = drawTextLine(line, filepath.Join(os.TempDir(), "shape_vert_mongolian_adjusted.png")) + tu.AssertNoErr(t, err) + } + { + runs := seg.Split(Input{ + Text: japT, + RunEnd: len(japT), + Language: language.NewLanguage("ja"), + Size: fixed.I(12 * 16), + Direction: di.DirectionTTB, + }, fixedFontmap{japF}) + tu.Assert(t, len(runs) == 2) + line := Line{shaper.Shape(runs[0]), shaper.Shape(runs[1])} + err := drawTextLine(line, filepath.Join(os.TempDir(), "shape_vert_japanese.png")) + tu.AssertNoErr(t, err) + } + { + runs := seg.Split(Input{ + Text: mixedT, + RunEnd: len(mixedT), + Language: language.NewLanguage("ja"), + Size: fixed.I(12 * 16), + Direction: di.DirectionTTB, + }, fixedFontmap{japF}) + tu.Assert(t, len(runs) == 4) + line := Line{shaper.Shape(runs[0]), shaper.Shape(runs[1]), shaper.Shape(runs[2]), shaper.Shape(runs[3])} + err := drawTextLine(line, filepath.Join(os.TempDir(), "shape_vert_mixed.png")) + tu.AssertNoErr(t, err) + + line.AdjustBaselines() + err = drawTextLine(line, filepath.Join(os.TempDir(), "shape_vert_mixed_adjusted.png")) + tu.AssertNoErr(t, err) + } +} + func ExampleShaper_Shape() { textInput := []rune("abcdefghijklmnop") withKerningFont := "harfbuzz_reference/in-house/fonts/e39391c77a6321c2ac7a2d644de0396470cd4bfe.ttf" @@ -533,107 +630,25 @@ func ExampleShaper_Shape() { RunEnd: len(textInput), Direction: di.DirectionLTR, Face: face, - Size: 16 * 72 * 10, + Size: fixed.I(16 * 1000 / 72), Script: language.Latin, Language: language.NewLanguage("EN"), } - drawHGlyphs(shaper.Shape(input), filepath.Join(os.TempDir(), "shape_horiz.png")) + horiz := shaper.Shape(input) + drawTextLine(Line{horiz}, filepath.Join(os.TempDir(), "shape_horiz.png")) input.Direction = di.DirectionTTB - drawVGlyphs(shaper.Shape(input), filepath.Join(os.TempDir(), "shape_vert.png")) - - // Output: -} - -func drawVLine(img *image.RGBA, start image.Point, height int, c color.RGBA) { - for y := start.Y; y <= start.Y+height; y++ { - img.SetRGBA(start.X, y, c) - } -} + drawTextLine(Line{shaper.Shape(input)}, filepath.Join(os.TempDir(), "shape_vert.png")) -func drawHLine(img *image.RGBA, start image.Point, width int, c color.RGBA) { - for x := start.X; x <= start.X+width; x++ { - img.SetRGBA(x, start.Y, c) - } -} - -func drawRect(img *image.RGBA, min, max image.Point, c color.RGBA) { - for x := min.X; x <= max.X; x++ { - for y := min.Y; y <= max.Y; y++ { - img.SetRGBA(x, y, c) - } - } -} - -var ( - red = color.RGBA{R: 0xFF, A: 0xFF} - green = color.RGBA{G: 0xFF, A: 0xFF} - black = color.RGBA{A: 0xFF} -) - -// assume horizontal direction -func drawHGlyphs(out Output, file string) { - baseline := out.LineBounds.Ascent.Round() - height := out.LineBounds.LineThickness().Round() - width := out.Advance.Round() - img := image.NewRGBA(image.Rect(0, 0, width, height)) - // white background - draw.Draw(img, img.Rect, image.NewUniform(color.White), image.Point{}, draw.Src) + input.Direction.SetSideways(true) + drawTextLine(Line{shaper.Shape(input)}, filepath.Join(os.TempDir(), "shape_vert_rotated.png")) - drawHLine(img, image.Pt(0, baseline), width, black) - - dot := 0 - for _, g := range out.Glyphs { - minX := dot + g.XOffset.Round() + g.XBearing.Round() - maxX := minX + g.Width.Round() - minY := baseline + g.YOffset.Round() - g.YBearing.Round() - maxY := minY - g.Height.Round() + input.Direction = di.DirectionBTT + drawTextLine(Line{shaper.Shape(input)}, filepath.Join(os.TempDir(), "shape_vert_rev.png")) - drawRect(img, image.Pt(minX, minY), image.Pt(maxX, maxY), green) - - // draw the dot ... - drawRect(img, image.Pt(dot-1, baseline-1), image.Pt(dot+1, baseline+1), black) - - // ... and advance - dot += g.XAdvance.Round() - drawVLine(img, image.Pt(dot, 0), height, red) - } + input.Direction.SetSideways(true) + drawTextLine(Line{shaper.Shape(input)}, filepath.Join(os.TempDir(), "shape_vert_rev_rotated.png")) - f, _ := os.Create(file) - _ = png.Encode(f, img) -} - -// assume vertical direction -func drawVGlyphs(out Output, file string) { - baseline := -out.GlyphBounds.Descent.Round() - width := out.GlyphBounds.LineThickness().Round() - height := -out.Advance.Round() - img := image.NewRGBA(image.Rect(0, 0, width, height)) - // white background - draw.Draw(img, img.Rect, image.NewUniform(color.White), image.Point{}, draw.Src) - - drawVLine(img, image.Pt(baseline, 0), height, black) - - dot := 0 - for _, g := range out.Glyphs { - dot += -g.YAdvance.Round() - - minX := baseline + g.XOffset.Round() + g.XBearing.Round() - maxX := minX + g.Width.Round() - - minY := dot + g.YOffset.Round() - g.YBearing.Round() - maxY := minY - g.Height.Round() - - drawRect(img, image.Pt(minX, minY), image.Pt(maxX, maxY), green) - - // draw the dot ... - drawRect(img, image.Pt(baseline-1, dot-1), image.Pt(baseline+1, dot+1), black) - - // ... and advance - drawHLine(img, image.Pt(0, dot), width, red) - } - - f, _ := os.Create(file) - _ = png.Encode(f, img) + // Output: } diff --git a/unicodedata/unicode.go b/unicodedata/unicode.go index 1ea1a1b6..82b6dd66 100644 --- a/unicodedata/unicode.go +++ b/unicodedata/unicode.go @@ -4,6 +4,8 @@ package unicodedata import ( "unicode" + + "github.com/go-text/typesetting/language" ) var categories []*unicode.RangeTable @@ -174,3 +176,27 @@ const ( T ArabicJoining = 'T' // Transparent, e.g. Arabic Fatha G ArabicJoining = 'G' // Ignored, e.g. LRE, RLE, ZWNBSP ) + +// LookupVerticalOrientation returns the prefered orientation +// for the given script. +func LookupVerticalOrientation(s language.Script) ScriptVerticalOrientation { + for _, script := range uprightOrMixedScripts { + if script.script == s { + return script + } + } + + // all other scripts have full R (sideways) + return ScriptVerticalOrientation{exceptions: nil, script: s, isMainSideways: true} +} + +// Orientation returns the prefered orientation +// for the given rune. +// If the rune does not belong to this script, the default orientation of this script +// is returned (regardless of the actual script of the given rune). +func (sv ScriptVerticalOrientation) Orientation(r rune) (isSideways bool) { + if sv.exceptions == nil || !unicode.Is(sv.exceptions, r) { + return sv.isMainSideways + } + return !sv.isMainSideways +} diff --git a/unicodedata/unicode_test.go b/unicodedata/unicode_test.go index 7641e0fb..8dde65c0 100644 --- a/unicodedata/unicode_test.go +++ b/unicodedata/unicode_test.go @@ -4,6 +4,8 @@ import ( "reflect" "testing" "unicode" + + "github.com/go-text/typesetting/language" ) func TestUnicodeNormalization(t *testing.T) { @@ -587,3 +589,23 @@ func TestLookupMirrorChar(t *testing.T) { } } } + +func TestLookupVerticalOrientation(t *testing.T) { + tests := []struct { + s language.Script + r rune + wantIsSideways bool + }{ + {language.Cyrillic, '\u0400', true}, + {language.Latin, 'A', true}, + {language.Latin, '\uFF21', false}, + {language.Katakana, 'も', false}, + {language.Katakana, '\uFF89', true}, + {language.Hangul, '\uFFAB', true}, + } + for _, tt := range tests { + if gotIsSideways := LookupVerticalOrientation(tt.s).Orientation(tt.r); gotIsSideways != tt.wantIsSideways { + t.Errorf("LookupVerticalOrientation(%s) = %v, want %v", string(tt.r), gotIsSideways, tt.wantIsSideways) + } + } +} diff --git a/unicodedata/vertical_orientation.go b/unicodedata/vertical_orientation.go new file mode 100644 index 00000000..6c144202 --- /dev/null +++ b/unicodedata/vertical_orientation.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Unlicense OR BSD-3-Clause + +package unicodedata + +import ( + "unicode" + + "github.com/go-text/typesetting/language" +) + +// Code generated by typesettings-utils/generators/unicodedata/cmd/main.go DO NOT EDIT. + +// ScriptVerticalOrientation provides the glyph oriention +// to use for vertical text. +type ScriptVerticalOrientation struct { + exceptions *unicode.RangeTable + script language.Script + isMainSideways bool +} + +// uprightOrMixedScripts is the list of scripts +// which may use both mode ("upright" or "sideways") for vertical text orientation +var uprightOrMixedScripts = [...]ScriptVerticalOrientation{ + {nil, language.Anatolian_Hieroglyphs, false}, + {nil, language.Bopomofo, false}, + { + &unicode.RangeTable{ + R16: []unicode.Range16{ + {Lo: 0x1400, Hi: 0x1400, Stride: 1}, + }, + }, language.Canadian_Aboriginal, false, + }, + {nil, language.Egyptian_Hieroglyphs, false}, + {nil, language.Han, false}, + { + &unicode.RangeTable{ + R16: []unicode.Range16{ + {Lo: 0xffa0, Hi: 0xffbe, Stride: 1}, + {Lo: 0xffc2, Hi: 0xffc7, Stride: 1}, + {Lo: 0xffca, Hi: 0xffcf, Stride: 1}, + {Lo: 0xffd2, Hi: 0xffd7, Stride: 1}, + {Lo: 0xffda, Hi: 0xffdc, Stride: 1}, + }, + }, language.Hangul, false, + }, + {nil, language.Hiragana, false}, + { + &unicode.RangeTable{ + R16: []unicode.Range16{ + {Lo: 0xff66, Hi: 0xff6f, Stride: 1}, + {Lo: 0xff71, Hi: 0xff9d, Stride: 1}, + }, + }, language.Katakana, false, + }, + {nil, language.Khitan_Small_Script, false}, + { + &unicode.RangeTable{ + R16: []unicode.Range16{ + {Lo: 0x2160, Hi: 0x2188, Stride: 1}, + {Lo: 0xff21, Hi: 0xff3a, Stride: 1}, + {Lo: 0xff41, Hi: 0xff5a, Stride: 1}, + }, + }, language.Latin, true, + }, + {nil, language.Meroitic_Hieroglyphs, false}, + {nil, language.Nushu, false}, + {nil, language.Siddham, false}, + {nil, language.SignWriting, false}, + {nil, language.Soyombo, false}, + {nil, language.Tangut, false}, + {nil, language.Yi, false}, + {nil, language.Zanabazar_Square, false}, +}