mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35:40 +00:00
font/{gofont,opentype},text,widget{,/material}: [API] add font fallback and bidi support
This commit restructures the entire text shaping stack to enable lines of shaped text to
have non-homogeneous properties like which font face they belong to and which direction
a segment of text is going.
The text package now provides a concrete type text.Shaper which can be used to convert
strings into sequences of renderable text.Glyphs. At a high level, the API is used
like this:
// Prepare some fonts.
var collection []text.FontFace
// Make a shaper with those fonts loaded.
shaper := text.NewShaper(collection)
// Shape a string.
shaper.LayoutString(text.Parameters{
PxPerEm: fixed.I(12),
}, 0, 100, system.Locale{}, "Hello")
// Iterate the glyphs from that string.
for glyph, ok := shaper.NextGlyph(); ok; glyph, ok = shaper.NextGlyph() {
// Convert the glyph data into a path. In real uses, convert batches of glyphs
// rather than single glyphs to reduce the number of individual paths and offsets
// required to display your text.
shape := shaper.Shape([]text.Glyph{glyph})
// Offset the glyph to the position it declares within its fields. This will
// automatically handle correct bidirectional text glyph positioning.
offset := op.Offset(image.Pt(glyph.X.Floor(), int(glyph.Y))).Push(gtx.Ops)
// Create a clip area from the shape of the glyph.
area := clip.Outline{Path: shape}.Push(gtx.Ops)
// Paint whatever the current color is within the glyph's shape.
paint.PaintOp{}.Add(gtx.Ops)
area.Pop()
offset.Pop()
}
This API will transparently handle both font fallback (choosing appropriate fonts
from those loaded when the primary font doesn't contain a required glyph) and
bidirectional text (mixed left-to-right and right-to-left text). Glyphs are
iterated in order of the input runes, not their visual order, but proper use
of the provided offsets will ensure that text always displays correctly.
Thanks to Elias Naur for suggesting this glyph iterator strategy. It let us cut
through a lot of accumulated complexity from trying to match our old text APIs,
meaning that this change actually is a net negative change in lines of code.
This commit consumes the upstream github.com/go-text/typesetting/shaping API
now that my prior work is merged there, removing the need for the font/opentype/internal
package entirely.
As part of my efforts, I fuzzed both the low-level text shaping stack and the
editor widget extensively. I've committed regression tests found that way into
the appropriate testdata files to ensure the fuzzer re-checks them.
Fixes: https://todo.sr.ht/~eliasnaur/gio/425
Fixes: https://todo.sr.ht/~eliasnaur/gio/211
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
@@ -1,521 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/text"
|
||||
"github.com/benoitkugler/textlayout/language"
|
||||
"github.com/gioui/uax/segment"
|
||||
"github.com/gioui/uax/uax14"
|
||||
"github.com/go-text/typesetting/di"
|
||||
"github.com/go-text/typesetting/font"
|
||||
"github.com/go-text/typesetting/shaping"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
// computeGlyphClusters populates the Clusters field of a Layout.
|
||||
// The order of the clusters is visual, meaning
|
||||
// that the first cluster is the leftmost cluster displayed even when
|
||||
// the cluster is part of RTL text.
|
||||
func computeGlyphClusters(l *text.Layout) {
|
||||
clusters := make([]text.GlyphCluster, 0, len(l.Glyphs)+1)
|
||||
if len(l.Glyphs) < 1 {
|
||||
if l.Runes.Count > 0 {
|
||||
// Empty line corresponding to a newline character.
|
||||
clusters = append(clusters, text.GlyphCluster{
|
||||
Runes: text.Range{
|
||||
Count: 1,
|
||||
Offset: l.Runes.Offset,
|
||||
},
|
||||
})
|
||||
}
|
||||
l.Clusters = clusters
|
||||
return
|
||||
}
|
||||
rtl := l.Direction == system.RTL
|
||||
|
||||
// Check for trailing whitespace characters and synthesize
|
||||
// GlyphClusters to represent them.
|
||||
lastGlyph := l.Glyphs[len(l.Glyphs)-1]
|
||||
if rtl {
|
||||
lastGlyph = l.Glyphs[0]
|
||||
}
|
||||
trailingNewline := lastGlyph.ClusterIndex+lastGlyph.RuneCount < l.Runes.Count+l.Runes.Offset
|
||||
newlineCluster := text.GlyphCluster{
|
||||
Runes: text.Range{
|
||||
Count: 1,
|
||||
Offset: l.Runes.Count + l.Runes.Offset - 1,
|
||||
},
|
||||
Glyphs: text.Range{
|
||||
Offset: len(l.Glyphs),
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
i int = 0
|
||||
inc int = 1
|
||||
runesProcessed int = 0
|
||||
glyphsProcessed int = 0
|
||||
)
|
||||
|
||||
if rtl {
|
||||
i = len(l.Glyphs) - 1
|
||||
inc = -inc
|
||||
glyphsProcessed = len(l.Glyphs) - 1
|
||||
newlineCluster.Glyphs.Offset = 0
|
||||
}
|
||||
// Construct clusters from the line's glyphs.
|
||||
for ; i < len(l.Glyphs) && i >= 0; i += inc {
|
||||
g := l.Glyphs[i]
|
||||
xAdv := g.XAdvance * fixed.Int26_6(inc)
|
||||
for k := 0; k < g.GlyphCount-1 && k < len(l.Glyphs); k++ {
|
||||
i += inc
|
||||
xAdv += l.Glyphs[i].XAdvance * fixed.Int26_6(inc)
|
||||
}
|
||||
|
||||
startRune := runesProcessed
|
||||
runeIncrement := g.RuneCount
|
||||
startGlyph := glyphsProcessed
|
||||
glyphIncrement := g.GlyphCount * inc
|
||||
if rtl {
|
||||
startGlyph = glyphsProcessed + glyphIncrement + 1
|
||||
}
|
||||
clusters = append(clusters, text.GlyphCluster{
|
||||
Advance: xAdv,
|
||||
Runes: text.Range{
|
||||
Count: g.RuneCount,
|
||||
Offset: startRune + l.Runes.Offset,
|
||||
},
|
||||
Glyphs: text.Range{
|
||||
Count: g.GlyphCount,
|
||||
Offset: startGlyph,
|
||||
},
|
||||
})
|
||||
runesProcessed += runeIncrement
|
||||
glyphsProcessed += glyphIncrement
|
||||
}
|
||||
// Insert synthetic clusters at the right edge of the line.
|
||||
if trailingNewline {
|
||||
clusters = append(clusters, newlineCluster)
|
||||
}
|
||||
l.Clusters = clusters
|
||||
}
|
||||
|
||||
// langConfig describes the language and writing system of a body of text.
|
||||
type langConfig struct {
|
||||
// Language the text is written in.
|
||||
language.Language
|
||||
// Writing system used to represent the text.
|
||||
language.Script
|
||||
// Direction of the text, usually driven by the writing system.
|
||||
di.Direction
|
||||
}
|
||||
|
||||
// mapRunesToClusterIndices returns a slice. Each index within that slice corresponds
|
||||
// to an index within the runes input slice. The value stored at that index is the
|
||||
// index of the glyph at the start of the corresponding glyph cluster shaped by
|
||||
// harfbuzz.
|
||||
func mapRunesToClusterIndices(runes []rune, glyphs []shaping.Glyph) []int {
|
||||
mapping := make([]int, len(runes))
|
||||
glyphCursor := 0
|
||||
if len(runes) == 0 {
|
||||
return nil
|
||||
}
|
||||
// If the final cluster values are lower than the starting ones,
|
||||
// the text is RTL.
|
||||
rtl := len(glyphs) > 0 && glyphs[len(glyphs)-1].ClusterIndex < glyphs[0].ClusterIndex
|
||||
if rtl {
|
||||
glyphCursor = len(glyphs) - 1
|
||||
}
|
||||
for i := range runes {
|
||||
for glyphCursor >= 0 && glyphCursor < len(glyphs) &&
|
||||
((rtl && glyphs[glyphCursor].ClusterIndex <= i) ||
|
||||
(!rtl && glyphs[glyphCursor].ClusterIndex < i)) {
|
||||
if rtl {
|
||||
glyphCursor--
|
||||
} else {
|
||||
glyphCursor++
|
||||
}
|
||||
}
|
||||
if rtl {
|
||||
glyphCursor++
|
||||
} else if (glyphCursor >= 0 && glyphCursor < len(glyphs) &&
|
||||
glyphs[glyphCursor].ClusterIndex > i) ||
|
||||
(glyphCursor == len(glyphs) && len(glyphs) > 1) {
|
||||
glyphCursor--
|
||||
targetClusterIndex := glyphs[glyphCursor].ClusterIndex
|
||||
for glyphCursor-1 >= 0 && glyphs[glyphCursor-1].ClusterIndex == targetClusterIndex {
|
||||
glyphCursor--
|
||||
}
|
||||
}
|
||||
if glyphCursor < 0 {
|
||||
glyphCursor = 0
|
||||
} else if glyphCursor >= len(glyphs) {
|
||||
glyphCursor = len(glyphs) - 1
|
||||
}
|
||||
mapping[i] = glyphCursor
|
||||
}
|
||||
return mapping
|
||||
}
|
||||
|
||||
// inclusiveGlyphRange returns the inclusive range of runes and glyphs matching
|
||||
// the provided start and breakAfter rune positions.
|
||||
// runeToGlyph must be a valid mapping from the rune representation to the
|
||||
// glyph reprsentation produced by mapRunesToClusterIndices.
|
||||
// numGlyphs is the number of glyphs in the output representing the runes
|
||||
// under consideration.
|
||||
func inclusiveGlyphRange(start, breakAfter int, runeToGlyph []int, numGlyphs int) (glyphStart, glyphEnd int) {
|
||||
rtl := runeToGlyph[len(runeToGlyph)-1] < runeToGlyph[0]
|
||||
runeStart := start
|
||||
runeEnd := breakAfter
|
||||
if rtl {
|
||||
glyphStart = runeToGlyph[runeEnd]
|
||||
if runeStart-1 >= 0 {
|
||||
glyphEnd = runeToGlyph[runeStart-1] - 1
|
||||
} else {
|
||||
glyphEnd = numGlyphs - 1
|
||||
}
|
||||
} else {
|
||||
glyphStart = runeToGlyph[runeStart]
|
||||
if runeEnd+1 < len(runeToGlyph) {
|
||||
glyphEnd = runeToGlyph[runeEnd+1] - 1
|
||||
} else {
|
||||
glyphEnd = numGlyphs - 1
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// breakOption represets a location within the rune slice at which
|
||||
// it may be safe to break a line of text.
|
||||
type breakOption struct {
|
||||
// breakAtRune is the index at which it is safe to break.
|
||||
breakAtRune int
|
||||
// penalty is the cost of breaking at this index. Negative
|
||||
// penalties mean that the break is beneficial, and a penalty
|
||||
// of uax14.PenaltyForMustBreak means a required break.
|
||||
penalty int
|
||||
}
|
||||
|
||||
// getBreakOptions returns a slice of line break candidates for the
|
||||
// text in the provided slice.
|
||||
func getBreakOptions(text []rune) []breakOption {
|
||||
// Collect options for breaking the lines in a slice.
|
||||
var options []breakOption
|
||||
const adjust = -1
|
||||
breaker := uax14.NewLineWrap()
|
||||
segmenter := segment.NewSegmenter(breaker)
|
||||
segmenter.InitFromSlice(text)
|
||||
runeOffset := 0
|
||||
brokeAtEnd := false
|
||||
for segmenter.Next() {
|
||||
penalty, _ := segmenter.Penalties()
|
||||
// Determine the indices of the breaking runes in the runes
|
||||
// slice. Would be nice if the API provided this.
|
||||
currentSegment := segmenter.Runes()
|
||||
runeOffset += len(currentSegment)
|
||||
|
||||
// Collect all break options.
|
||||
options = append(options, breakOption{
|
||||
penalty: penalty,
|
||||
breakAtRune: runeOffset + adjust,
|
||||
})
|
||||
if options[len(options)-1].breakAtRune == len(text)-1 {
|
||||
brokeAtEnd = true
|
||||
}
|
||||
}
|
||||
if len(text) > 0 && !brokeAtEnd {
|
||||
options = append(options, breakOption{
|
||||
penalty: uax14.PenaltyForMustBreak,
|
||||
breakAtRune: len(text) - 1,
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
type Shaper func(shaping.Input) (shaping.Output, error)
|
||||
|
||||
// paragraph shapes a single paragraph of text, breaking it into multiple lines
|
||||
// to fit within the provided maxWidth.
|
||||
func paragraph(shaper Shaper, face font.Face, ppem fixed.Int26_6, maxWidth int, lc langConfig, paragraph []rune) ([]output, error) {
|
||||
// TODO: handle splitting bidi text here
|
||||
|
||||
// Shape the text.
|
||||
input := toInput(face, ppem, lc, paragraph)
|
||||
out, err := shaper(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Get a mapping from input runes to output glyphs.
|
||||
runeToGlyph := mapRunesToClusterIndices(paragraph, out.Glyphs)
|
||||
|
||||
// Fetch line break candidates.
|
||||
breaks := getBreakOptions(paragraph)
|
||||
|
||||
return lineWrap(out, input.Direction, paragraph, runeToGlyph, breaks, maxWidth), nil
|
||||
}
|
||||
|
||||
// shouldKeepSegmentOnLine decides whether the segment of text from the current
|
||||
// end of the line to the provided breakOption should be kept on the current
|
||||
// line. It should be called successively with each available breakOption,
|
||||
// and the line should be broken (without keeping the current segment)
|
||||
// whenever it returns false.
|
||||
//
|
||||
// The parameters require some explanation:
|
||||
// - out - the shaping.Output that is being line-broken.
|
||||
// - runeToGlyph - a mapping where accessing the slice at the index of a rune
|
||||
// into out will yield the index of the first glyph corresponding to that rune.
|
||||
// - lineStartRune - the index of the first rune in the line.
|
||||
// - b - the line break candidate under consideration.
|
||||
// - curLineWidth - the amount of space total in the current line.
|
||||
// - curLineUsed - the amount of space in the current line that is already used.
|
||||
// - nextLineWidth - the amount of space available on the next line.
|
||||
//
|
||||
// This function returns both a valid shaping.Output broken at b and a boolean
|
||||
// indicating whether the returned output should be used.
|
||||
func shouldKeepSegmentOnLine(out shaping.Output, runeToGlyph []int, lineStartRune int, b breakOption, curLineWidth, curLineUsed, nextLineWidth int) (candidateLine shaping.Output, keep bool) {
|
||||
// Convert the break target to an inclusive index.
|
||||
glyphStart, glyphEnd := inclusiveGlyphRange(lineStartRune, b.breakAtRune, runeToGlyph, len(out.Glyphs))
|
||||
|
||||
// Construct a line out of the inclusive glyph range.
|
||||
candidateLine = out
|
||||
candidateLine.Glyphs = candidateLine.Glyphs[glyphStart : glyphEnd+1]
|
||||
candidateLine.RecomputeAdvance()
|
||||
candidateAdvance := candidateLine.Advance.Ceil()
|
||||
if candidateAdvance > curLineWidth && candidateAdvance-curLineUsed <= nextLineWidth {
|
||||
// If it fits on the next line, put it there.
|
||||
return candidateLine, false
|
||||
}
|
||||
|
||||
return candidateLine, true
|
||||
}
|
||||
|
||||
// lineWrap wraps the shaped glyphs of a paragraph to a particular max width.
|
||||
func lineWrap(out shaping.Output, dir di.Direction, paragraph []rune, runeToGlyph []int, breaks []breakOption, maxWidth int) []output {
|
||||
var outputs []output
|
||||
if len(breaks) == 0 {
|
||||
// Pass empty lines through as empty.
|
||||
outputs = append(outputs, output{
|
||||
Shaped: out,
|
||||
RuneRange: text.Range{
|
||||
Count: len(paragraph),
|
||||
},
|
||||
})
|
||||
return outputs
|
||||
}
|
||||
|
||||
for i := 0; i < len(breaks); i++ {
|
||||
b := breaks[i]
|
||||
if b.breakAtRune+1 < len(runeToGlyph) {
|
||||
// Check if this break is valid.
|
||||
gIdx := runeToGlyph[b.breakAtRune]
|
||||
g2Idx := runeToGlyph[b.breakAtRune+1]
|
||||
cIdx := out.Glyphs[gIdx].ClusterIndex
|
||||
c2Idx := out.Glyphs[g2Idx].ClusterIndex
|
||||
if cIdx == c2Idx {
|
||||
// This break is within a harfbuzz cluster, and is
|
||||
// therefore invalid.
|
||||
copy(breaks[i:], breaks[i+1:])
|
||||
breaks = breaks[:len(breaks)-1]
|
||||
i--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start := 0
|
||||
runesProcessed := 0
|
||||
for i := 0; i < len(breaks); i++ {
|
||||
b := breaks[i]
|
||||
// Always keep the first segment on a line.
|
||||
good, _ := shouldKeepSegmentOnLine(out, runeToGlyph, start, b, maxWidth, 0, maxWidth)
|
||||
end := b.breakAtRune
|
||||
innerLoop:
|
||||
for k := i + 1; k < len(breaks); k++ {
|
||||
bb := breaks[k]
|
||||
candidate, ok := shouldKeepSegmentOnLine(out, runeToGlyph, start, bb, maxWidth, good.Advance.Ceil(), maxWidth)
|
||||
if ok {
|
||||
// Use this new, longer segment.
|
||||
good = candidate
|
||||
end = bb.breakAtRune
|
||||
i++
|
||||
} else {
|
||||
break innerLoop
|
||||
}
|
||||
}
|
||||
numRunes := end - start + 1
|
||||
outputs = append(outputs, output{
|
||||
Shaped: good,
|
||||
RuneRange: text.Range{
|
||||
Count: numRunes,
|
||||
Offset: runesProcessed,
|
||||
},
|
||||
})
|
||||
runesProcessed += numRunes
|
||||
start = end + 1
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
// output is a run of shaped text with metadata about its position
|
||||
// within a text document.
|
||||
type output struct {
|
||||
Shaped shaping.Output
|
||||
RuneRange text.Range
|
||||
}
|
||||
|
||||
func toSystemDirection(d di.Direction) system.TextDirection {
|
||||
switch d {
|
||||
case di.DirectionLTR:
|
||||
return system.LTR
|
||||
case di.DirectionRTL:
|
||||
return system.RTL
|
||||
}
|
||||
return system.LTR
|
||||
}
|
||||
|
||||
// toGioGlyphs converts text shaper glyphs into the minimal representation
|
||||
// that Gio needs.
|
||||
func toGioGlyphs(in []shaping.Glyph) []text.Glyph {
|
||||
out := make([]text.Glyph, 0, len(in))
|
||||
for _, g := range in {
|
||||
out = append(out, text.Glyph{
|
||||
ID: g.GlyphID,
|
||||
ClusterIndex: g.ClusterIndex,
|
||||
RuneCount: g.RuneCount,
|
||||
GlyphCount: g.GlyphCount,
|
||||
XAdvance: g.XAdvance,
|
||||
YAdvance: g.YAdvance,
|
||||
XOffset: g.XOffset,
|
||||
YOffset: g.YOffset,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToLine converts the output into a text.Line
|
||||
func (o output) ToLine() text.Line {
|
||||
layout := text.Layout{
|
||||
Glyphs: toGioGlyphs(o.Shaped.Glyphs),
|
||||
Runes: o.RuneRange,
|
||||
Direction: toSystemDirection(o.Shaped.Direction),
|
||||
}
|
||||
return text.Line{
|
||||
Layout: layout,
|
||||
Bounds: fixed.Rectangle26_6{
|
||||
Min: fixed.Point26_6{
|
||||
Y: -o.Shaped.LineBounds.Ascent,
|
||||
},
|
||||
Max: fixed.Point26_6{
|
||||
X: o.Shaped.Advance,
|
||||
Y: -o.Shaped.LineBounds.Ascent + o.Shaped.LineBounds.LineHeight(),
|
||||
},
|
||||
},
|
||||
Width: o.Shaped.Advance,
|
||||
Ascent: o.Shaped.LineBounds.Ascent,
|
||||
Descent: -o.Shaped.LineBounds.Descent + o.Shaped.LineBounds.Gap,
|
||||
}
|
||||
}
|
||||
|
||||
func mapDirection(d system.TextDirection) di.Direction {
|
||||
switch d {
|
||||
case system.LTR:
|
||||
return di.DirectionLTR
|
||||
case system.RTL:
|
||||
return di.DirectionRTL
|
||||
}
|
||||
return di.DirectionLTR
|
||||
}
|
||||
|
||||
// Document shapes text using the given font, ppem, maximum line width, language,
|
||||
// and sequence of runes. It returns a slice of lines corresponding to the txt,
|
||||
// broken to fit within maxWidth and on paragraph boundaries.
|
||||
func Document(shaper Shaper, face font.Face, ppem fixed.Int26_6, maxWidth int, lc system.Locale, txt io.RuneReader) []text.Line {
|
||||
var (
|
||||
outputs []text.Line
|
||||
startByte int
|
||||
startRune int
|
||||
paragraphText []rune
|
||||
done bool
|
||||
langs = make(map[language.Script]int)
|
||||
)
|
||||
for !done {
|
||||
var (
|
||||
bytes int
|
||||
runes int
|
||||
)
|
||||
newlineAdjust := 0
|
||||
paragraphLoop:
|
||||
for r, sz, re := txt.ReadRune(); !done; r, sz, re = txt.ReadRune() {
|
||||
if re != nil {
|
||||
done = true
|
||||
continue
|
||||
}
|
||||
paragraphText = append(paragraphText, r)
|
||||
script := language.LookupScript(r)
|
||||
langs[script]++
|
||||
bytes += sz
|
||||
runes++
|
||||
if r == '\n' {
|
||||
newlineAdjust = 1
|
||||
break paragraphLoop
|
||||
}
|
||||
}
|
||||
var (
|
||||
primary language.Script
|
||||
primaryTotal int
|
||||
)
|
||||
for script, total := range langs {
|
||||
if total > primaryTotal {
|
||||
primary = script
|
||||
primaryTotal = total
|
||||
}
|
||||
}
|
||||
if lc.Language == "" {
|
||||
lc.Language = "EN"
|
||||
}
|
||||
lcfg := langConfig{
|
||||
Language: language.NewLanguage(lc.Language),
|
||||
Script: primary,
|
||||
Direction: mapDirection(lc.Direction),
|
||||
}
|
||||
lines, _ := paragraph(shaper, face, ppem, maxWidth, lcfg, paragraphText[:len(paragraphText)-newlineAdjust])
|
||||
for i := range lines {
|
||||
// Update the offsets of each paragraph to be correct within the
|
||||
// whole document.
|
||||
lines[i].RuneRange.Offset += startRune
|
||||
// Update the cluster values to be rune indices within the entire
|
||||
// document.
|
||||
for k := range lines[i].Shaped.Glyphs {
|
||||
lines[i].Shaped.Glyphs[k].ClusterIndex += startRune
|
||||
}
|
||||
outputs = append(outputs, lines[i].ToLine())
|
||||
}
|
||||
// If there was a trailing newline update the byte counts to include
|
||||
// it on the last line of the paragraph.
|
||||
if newlineAdjust > 0 {
|
||||
outputs[len(outputs)-1].Layout.Runes.Count += newlineAdjust
|
||||
}
|
||||
paragraphText = paragraphText[:0]
|
||||
startByte += bytes
|
||||
startRune += runes
|
||||
}
|
||||
for i := range outputs {
|
||||
computeGlyphClusters(&outputs[i].Layout)
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
// toInput converts its parameters into a shaping.Input.
|
||||
func toInput(face font.Face, ppem fixed.Int26_6, lc langConfig, runes []rune) shaping.Input {
|
||||
var input shaping.Input
|
||||
input.Direction = lc.Direction
|
||||
input.Text = runes
|
||||
input.Size = ppem
|
||||
input.Face = face
|
||||
input.Language = lc.Language
|
||||
input.Script = lc.Script
|
||||
input.RunStart = 0
|
||||
input.RunEnd = len(runes)
|
||||
return input
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+10
-117
@@ -7,132 +7,25 @@ package opentype
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
|
||||
"github.com/benoitkugler/textlayout/fonts"
|
||||
"github.com/benoitkugler/textlayout/fonts/truetype"
|
||||
"github.com/benoitkugler/textlayout/harfbuzz"
|
||||
"github.com/go-text/typesetting/shaping"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/math/fixed"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/font/opentype/internal"
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/text"
|
||||
"github.com/go-text/typesetting/font"
|
||||
)
|
||||
|
||||
// Font implements the text.Shaper interface using a rich text
|
||||
// shaping engine.
|
||||
type Font struct {
|
||||
font *truetype.Font
|
||||
// Face is a shapeable representation of a font.
|
||||
type Face struct {
|
||||
face font.Face
|
||||
}
|
||||
|
||||
// Parse constructs a Font from source bytes.
|
||||
func Parse(src []byte) (*Font, error) {
|
||||
// Parse constructs a Face from source bytes.
|
||||
func Parse(src []byte) (Face, error) {
|
||||
face, err := truetype.Parse(bytes.NewReader(src))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed parsing truetype font: %w", err)
|
||||
return Face{}, fmt.Errorf("failed parsing truetype font: %w", err)
|
||||
}
|
||||
return &Font{
|
||||
font: face,
|
||||
}, nil
|
||||
return Face{face: face}, nil
|
||||
}
|
||||
|
||||
func (f *Font) Layout(ppem fixed.Int26_6, maxWidth int, lc system.Locale, txt io.RuneReader) ([]text.Line, error) {
|
||||
return internal.Document(shaping.Shape, f.font, ppem, maxWidth, lc, txt), nil
|
||||
}
|
||||
|
||||
func (f *Font) Shape(ppem fixed.Int26_6, str text.Layout) clip.PathSpec {
|
||||
return textPath(ppem, f, str)
|
||||
}
|
||||
|
||||
func (f *Font) Metrics(ppem fixed.Int26_6) font.Metrics {
|
||||
metrics := font.Metrics{}
|
||||
font := harfbuzz.NewFont(f.font)
|
||||
font.XScale = int32(ppem.Ceil()) << 6
|
||||
font.YScale = font.XScale
|
||||
// Use any horizontal direction.
|
||||
fontExtents := font.ExtentsForDirection(harfbuzz.LeftToRight)
|
||||
ascender := fixed.I(int(fontExtents.Ascender * 64))
|
||||
descender := fixed.I(int(fontExtents.Descender * 64))
|
||||
gap := fixed.I(int(fontExtents.LineGap * 64))
|
||||
metrics.Height = ascender + descender + gap
|
||||
metrics.Ascent = ascender
|
||||
metrics.Descent = descender
|
||||
// These three are not readily available.
|
||||
// TODO(whereswaldon): figure out how to get these values.
|
||||
metrics.XHeight = ascender
|
||||
metrics.CapHeight = ascender
|
||||
metrics.CaretSlope = image.Pt(0, 1)
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
func textPath(ppem fixed.Int26_6, font *Font, str text.Layout) clip.PathSpec {
|
||||
var lastPos f32.Point
|
||||
var builder clip.Path
|
||||
ops := new(op.Ops)
|
||||
var x fixed.Int26_6
|
||||
builder.Begin(ops)
|
||||
rune := 0
|
||||
ppemInt := ppem.Round()
|
||||
ppem16 := uint16(ppemInt)
|
||||
scaleFactor := float32(ppemInt) / float32(font.font.Upem())
|
||||
for _, g := range str.Glyphs {
|
||||
advance := g.XAdvance
|
||||
outline, ok := font.font.GlyphData(g.ID, ppem16, ppem16).(fonts.GlyphOutline)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Move to glyph position.
|
||||
pos := f32.Point{
|
||||
X: float32(x)/64 - float32(g.XOffset)/64,
|
||||
Y: -float32(g.YOffset) / 64,
|
||||
}
|
||||
builder.Move(pos.Sub(lastPos))
|
||||
lastPos = pos
|
||||
var lastArg f32.Point
|
||||
|
||||
// Convert sfnt.Segments to relative segments.
|
||||
for _, fseg := range outline.Segments {
|
||||
nargs := 1
|
||||
switch fseg.Op {
|
||||
case fonts.SegmentOpQuadTo:
|
||||
nargs = 2
|
||||
case fonts.SegmentOpCubeTo:
|
||||
nargs = 3
|
||||
}
|
||||
var args [3]f32.Point
|
||||
for i := 0; i < nargs; i++ {
|
||||
a := f32.Point{
|
||||
X: fseg.Args[i].X * scaleFactor,
|
||||
Y: -fseg.Args[i].Y * scaleFactor,
|
||||
}
|
||||
args[i] = a.Sub(lastArg)
|
||||
if i == nargs-1 {
|
||||
lastArg = a
|
||||
}
|
||||
}
|
||||
switch fseg.Op {
|
||||
case fonts.SegmentOpMoveTo:
|
||||
builder.Move(args[0])
|
||||
case fonts.SegmentOpLineTo:
|
||||
builder.Line(args[0])
|
||||
case fonts.SegmentOpQuadTo:
|
||||
builder.Quad(args[0], args[1])
|
||||
case fonts.SegmentOpCubeTo:
|
||||
builder.Cube(args[0], args[1], args[2])
|
||||
default:
|
||||
panic("unsupported segment op")
|
||||
}
|
||||
}
|
||||
lastPos = lastPos.Add(lastArg)
|
||||
x += advance
|
||||
rune++
|
||||
}
|
||||
return builder.End()
|
||||
func (f Face) Face() font.Face {
|
||||
return f.face
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package opentype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
"golang.org/x/image/math/fixed"
|
||||
|
||||
"gioui.org/io/system"
|
||||
)
|
||||
|
||||
var english = system.Locale{
|
||||
Language: "EN",
|
||||
Direction: system.LTR,
|
||||
}
|
||||
|
||||
func TestEmptyString(t *testing.T) {
|
||||
face, err := Parse(goregular.TTF)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ppem := fixed.I(200)
|
||||
|
||||
lines, err := face.Layout(ppem, 2000, english, strings.NewReader(""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
t.Fatalf("Layout returned no lines for empty string; expected 1")
|
||||
}
|
||||
l := lines[0]
|
||||
exp := fixed.Rectangle26_6{
|
||||
Min: fixed.Point26_6{
|
||||
Y: fixed.Int26_6(-12094),
|
||||
},
|
||||
Max: fixed.Point26_6{
|
||||
Y: fixed.Int26_6(2700),
|
||||
},
|
||||
}
|
||||
if got := l.Bounds; got != exp {
|
||||
t.Errorf("got bounds %+v for empty string; expected %+v", got, exp)
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user