~eliasnaur/gio-patches

font/opentype: support using Collection as a Face v3 PROPOSED

tainted-bit: 2
 font/opentype: support using Collection as a Face
 font/opentype: add tests for Collection as a Face

 4 files changed, 285 insertions(+), 35 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~eliasnaur/gio-patches/patches/11495/mbox | git am -3
Learn more about email & git
View this thread in the archives

[PATCH v3 1/2] font/opentype: support using Collection as a Face Export this patch

This change allows font collection files (extensions .ttc or .otc)
to be used as a text.Face. These files contain an ordered list of
SFNT fonts, each supporting a maximum of 2^16 glyphs. When used as
a text.Face, each rune in the string to layout or render will be
assigned to the first font with a glyph for that rune, or to the
replacement character from the first font in the file otherwise.

With this change, it is possible to support multiple unicode planes
in a single text.Face by using a Collection with more than one
internal SFNT file. For example, it is now possible to display
characters from the basic multilingual plane and emoji in a single
widget.Label by loading an appropriate OTC file.

Fixes gio#104

Signed-off-by: tainted-bit <sourcehut@taintedbit.com>
---
 font/opentype/opentype.go | 136 ++++++++++++++++++++++++++++----------
 1 file changed, 101 insertions(+), 35 deletions(-)

diff --git a/font/opentype/opentype.go b/font/opentype/opentype.go
index 568faee..c509c8d 100644
--- a/font/opentype/opentype.go
+++ b/font/opentype/opentype.go
@@ -6,7 +6,6 @@ package opentype

import (
	"io"

	"unicode"
	"unicode/utf8"

@@ -25,9 +24,11 @@ type Font struct {
	font *sfnt.Font
}

// Collection is a collection of one or more fonts.
// Collection is a collection of one or more fonts. When used as a text.Face,
// each rune will be assigned a glyph from the first font in the collection
// that supports it.
type Collection struct {
	coll *sfnt.Collection
	fonts []*opentype
}

type opentype struct {
@@ -55,7 +56,7 @@ func ParseCollection(src []byte) (*Collection, error) {
	if err != nil {
		return nil, err
	}
	return &Collection{c}, nil
	return newCollectionFrom(c)
}

// ParseCollectionReaderAt parses an SFNT collection, such as TTC or OTC data,
@@ -68,21 +69,35 @@ func ParseCollectionReaderAt(src io.ReaderAt) (*Collection, error) {
	if err != nil {
		return nil, err
	}
	return &Collection{c}, nil
	return newCollectionFrom(c)
}

func newCollectionFrom(coll *sfnt.Collection) (*Collection, error) {
	fonts := make([]*opentype, coll.NumFonts())
	for i := range fonts {
		fnt, err := coll.Font(i)
		if err != nil {
			return nil, err
		}
		fonts[i] = &opentype{
			Font:    fnt,
			Hinting: font.HintingFull,
		}
	}
	return &Collection{fonts: fonts}, nil
}

// NumFonts returns the number of fonts in the collection.
func (c *Collection) NumFonts() int {
	return c.coll.NumFonts()
	return len(c.fonts)
}

// Font returns the i'th font in the collection.
func (c *Collection) Font(i int) (*Font, error) {
	fnt, err := c.coll.Font(i)
	if err != nil {
		return nil, err
	if i < 0 || len(c.fonts) <= i {
		return nil, sfnt.ErrNotFound
	}
	return &Font{font: fnt}, nil
	return &Font{font: c.fonts[i].Font}, nil
}

func (f *Font) Layout(ppem fixed.Int26_6, maxWidth int, txt io.Reader) ([]text.Line, error) {
@@ -90,13 +105,14 @@ func (f *Font) Layout(ppem fixed.Int26_6, maxWidth int, txt io.Reader) ([]text.L
	if err != nil {
		return nil, err
	}
	fonts := []*opentype{{Font: f.font, Hinting: font.HintingFull}}
	var buf sfnt.Buffer
	return layoutText(&buf, ppem, maxWidth, &opentype{Font: f.font, Hinting: font.HintingFull}, glyphs)
	return layoutText(&buf, ppem, maxWidth, fonts, glyphs)
}

func (f *Font) Shape(ppem fixed.Int26_6, str []text.Glyph) op.CallOp {
	var buf sfnt.Buffer
	return textPath(&buf, ppem, &opentype{Font: f.font, Hinting: font.HintingFull}, str)
	return textPath(&buf, ppem, []*opentype{{Font: f.font, Hinting: font.HintingFull}}, str)
}

func (f *Font) Metrics(ppem fixed.Int26_6) font.Metrics {
@@ -105,19 +121,53 @@ func (f *Font) Metrics(ppem fixed.Int26_6) font.Metrics {
	return o.Metrics(&buf, ppem)
}

func layoutText(sbuf *sfnt.Buffer, ppem fixed.Int26_6, maxWidth int, f *opentype, glyphs []text.Glyph) ([]text.Line, error) {
	m := f.Metrics(sbuf, ppem)
	lineTmpl := text.Line{
		Ascent: m.Ascent,
func (c *Collection) Layout(ppem fixed.Int26_6, maxWidth int, txt io.Reader) ([]text.Line, error) {
	glyphs, err := readGlyphs(txt)
	if err != nil {
		return nil, err
	}
	var buf sfnt.Buffer
	return layoutText(&buf, ppem, maxWidth, c.fonts, glyphs)
}

func (c *Collection) Shape(ppem fixed.Int26_6, str []text.Glyph) op.CallOp {
	var buf sfnt.Buffer
	return textPath(&buf, ppem, c.fonts, str)
}

func fontForGlyph(buf *sfnt.Buffer, fonts []*opentype, r rune) *opentype {
	if len(fonts) < 1 {
		return nil
	}
	for _, f := range fonts {
		if f.HasGlyph(buf, r) {
			return f
		}
	}
	return fonts[0] // Use replacement character from the first font if necessary
}

func layoutText(sbuf *sfnt.Buffer, ppem fixed.Int26_6, maxWidth int, fonts []*opentype, glyphs []text.Glyph) ([]text.Line, error) {
	var lines []text.Line
	var nextLine text.Line
	updateBounds := func(f *opentype) {
		m := f.Metrics(sbuf, ppem)
		if m.Ascent > nextLine.Ascent {
			nextLine.Ascent = m.Ascent
		}
		// m.Height is equal to m.Ascent + m.Descent + linegap.
		// Compute the descent including the linegap.
		Descent: m.Height - m.Ascent,
		Bounds:  f.Bounds(sbuf, ppem),
		descent := m.Height - m.Ascent
		if descent > nextLine.Descent {
			nextLine.Descent = descent
		}
		b := f.Bounds(sbuf, ppem)
		nextLine.Bounds = nextLine.Bounds.Union(b)
	}
	var lines []text.Line
	maxDotX := fixed.I(maxWidth)
	type state struct {
		r     rune
		f     *opentype
		adv   fixed.Int26_6
		x     fixed.Int26_6
		idx   int
@@ -126,26 +176,33 @@ func layoutText(sbuf *sfnt.Buffer, ppem fixed.Int26_6, maxWidth int, f *opentype
	}
	var prev, word state
	endLine := func() {
		line := lineTmpl
		line.Layout = glyphs[:prev.idx:prev.idx]
		line.Len = prev.len
		line.Width = prev.x + prev.adv
		line.Bounds.Max.X += prev.x
		lines = append(lines, line)
		if prev.f != nil {
			updateBounds(prev.f)
		}
		nextLine.Layout = glyphs[:prev.idx:prev.idx]
		nextLine.Len = prev.len
		nextLine.Width = prev.x + prev.adv
		nextLine.Bounds.Max.X += prev.x
		lines = append(lines, nextLine)
		glyphs = glyphs[prev.idx:]
		nextLine = text.Line{}
		prev = state{}
		word = state{}
	}
	for prev.idx < len(glyphs) {
		g := &glyphs[prev.idx]
		a, valid := f.GlyphAdvance(sbuf, ppem, g.Rune)
		next := state{
			r:     g.Rune,
			idx:   prev.idx + 1,
			len:   prev.len + utf8.RuneLen(g.Rune),
			x:     prev.x + prev.adv,
			adv:   a,
			valid: valid,
			r:   g.Rune,
			f:   fontForGlyph(sbuf, fonts, g.Rune),
			idx: prev.idx + 1,
			len: prev.len + utf8.RuneLen(g.Rune),
			x:   prev.x + prev.adv,
		}
		if next.f != nil {
			if next.f != prev.f {
				updateBounds(next.f)
			}
			next.adv, next.valid = next.f.GlyphAdvance(sbuf, ppem, g.Rune)
		}
		if g.Rune == '\n' {
			// The newline is zero width; use the previous
@@ -156,8 +213,8 @@ func layoutText(sbuf *sfnt.Buffer, ppem fixed.Int26_6, maxWidth int, f *opentype
			continue
		}
		var k fixed.Int26_6
		if prev.valid {
			k = f.Kern(sbuf, ppem, prev.r, next.r)
		if prev.valid && next.f != nil {
			k = next.f.Kern(sbuf, ppem, prev.r, next.r)
		}
		// Break the line if we're out of space.
		if prev.idx > 0 && next.x+next.adv+k > maxDotX {
@@ -184,7 +241,7 @@ func layoutText(sbuf *sfnt.Buffer, ppem fixed.Int26_6, maxWidth int, f *opentype
	return lines, nil
}

func textPath(buf *sfnt.Buffer, ppem fixed.Int26_6, f *opentype, str []text.Glyph) op.CallOp {
func textPath(buf *sfnt.Buffer, ppem fixed.Int26_6, fonts []*opentype, str []text.Glyph) op.CallOp {
	var lastPos f32.Point
	var builder clip.Path
	ops := new(op.Ops)
@@ -193,6 +250,10 @@ func textPath(buf *sfnt.Buffer, ppem fixed.Int26_6, f *opentype, str []text.Glyp
	builder.Begin(ops)
	for _, g := range str {
		if !unicode.IsSpace(g.Rune) {
			f := fontForGlyph(buf, fonts, g.Rune)
			if f == nil {
				continue
			}
			segs, ok := f.LoadGlyph(buf, ppem, g.Rune)
			if !ok {
				continue
@@ -274,6 +335,11 @@ func readGlyphs(r io.Reader) ([]text.Glyph, error) {
	return glyphs, nil
}

func (f *opentype) HasGlyph(buf *sfnt.Buffer, r rune) bool {
	g, err := f.Font.GlyphIndex(buf, r)
	return g != 0 && err == nil
}

func (f *opentype) GlyphAdvance(buf *sfnt.Buffer, ppem fixed.Int26_6, r rune) (advance fixed.Int26_6, ok bool) {
	g, err := f.Font.GlyphIndex(buf, r)
	if err != nil {
-- 
2.27.0

[PATCH v3 2/2] font/opentype: add tests for Collection as a Face Export this patch

Added tests to make sure that opentype.Collection can be used as a
text.Face, and that it correctly implements fallback behavior for
glyph lookups.

Signed-off-by: tainted-bit <sourcehut@taintedbit.com>
---
 font/opentype/opentype_test.go      | 184 ++++++++++++++++++++++++++++
 font/opentype/testdata/only1.ttf.gz | Bin 0 -> 442 bytes
 font/opentype/testdata/only2.ttf.gz | Bin 0 -> 629 bytes
 3 files changed, 184 insertions(+)
 create mode 100644 font/opentype/opentype_test.go
 create mode 100644 font/opentype/testdata/only1.ttf.gz
 create mode 100644 font/opentype/testdata/only2.ttf.gz

diff --git a/font/opentype/opentype_test.go b/font/opentype/opentype_test.go
new file mode 100644
index 0000000..0c73b9d
--- /dev/null
+++ b/font/opentype/opentype_test.go
@@ -0,0 +1,184 @@
package opentype

import (
	"bytes"
	"compress/gzip"
	"encoding/binary"
	"fmt"
	"io/ioutil"
	"os"
	"strings"
	"testing"

	"gioui.org/internal/ops"
	"gioui.org/op"
	"gioui.org/text"
	"golang.org/x/image/math/fixed"
)

func TestCollectionAsFace(t *testing.T) {
	// Load two fonts with disjoint glyphs. Font 1 supports only '1', and font 2 supports only '2'.
	// The fonts have different glyphs for the replacement character (".notdef").
	font1, ttf1, err := decompressFontFile("testdata/only1.ttf.gz")
	if err != nil {
		t.Fatalf("failed to load test font 1: %v", err)
	}
	font2, ttf2, err := decompressFontFile("testdata/only2.ttf.gz")
	if err != nil {
		t.Fatalf("failed to load test font 2: %v", err)
	}

	otc := mergeFonts(ttf1, ttf2)
	coll, err := ParseCollection(otc)
	if err != nil {
		t.Fatalf("failed to load merged test font: %v", err)
	}

	shapeValid1, err := shapeRune(font1, '1')
	if err != nil {
		t.Fatalf("failed shaping valid glyph with font 1: %v", err)
	}
	shapeInvalid1, err := shapeRune(font1, '3')
	if err != nil {
		t.Fatalf("failed shaping invalid glyph with font 1: %v", err)
	}
	shapeValid2, err := shapeRune(font2, '2')
	if err != nil {
		t.Fatalf("failed shaping valid glyph with font 2: %v", err)
	}
	shapeInvalid2, err := shapeRune(font2, '3') // Same invalid glyph as before to test replacement glyph difference
	if err != nil {
		t.Fatalf("failed shaping invalid glyph with font 2: %v", err)
	}
	shapeCollValid1, err := shapeRune(coll, '1')
	if err != nil {
		t.Fatalf("failed shaping valid glyph for font 1 with font collection: %v", err)
	}
	shapeCollValid2, err := shapeRune(coll, '2')
	if err != nil {
		t.Fatalf("failed shaping valid glyph for font 2 with font collection: %v", err)
	}
	shapeCollInvalid, err := shapeRune(coll, '4') // Different invalid glyph to confirm use of the replacement glyph
	if err != nil {
		t.Fatalf("failed shaping invalid glyph with font collection: %v", err)
	}

	// All shapes from the original fonts should be distinct because the glyphs are distinct, including the replacement
	// glyphs.
	distinctShapes := []op.CallOp{shapeValid1, shapeInvalid1, shapeValid2, shapeInvalid2}
	for i := 0; i < len(distinctShapes); i++ {
		for j := i + 1; j < len(distinctShapes); j++ {
			if areShapesEqual(distinctShapes[i], distinctShapes[j]) {
				t.Errorf("font shapes %d and %d are not distinct", i, j)
			}
		}
	}

	// Font collections should render glyphs from the first supported font. Replacement glyphs should come from the
	// first font in all cases.
	if !areShapesEqual(shapeCollValid1, shapeValid1) {
		t.Error("font collection did not render the valid glyph using font 1")
	}
	if !areShapesEqual(shapeCollValid2, shapeValid2) {
		t.Error("font collection did not render the valid glyph using font 2")
	}
	if !areShapesEqual(shapeCollInvalid, shapeInvalid1) {
		t.Error("font collection did not render the invalid glyph using the replacement from font 1")
	}
}

func decompressFontFile(name string) (*Font, []byte, error) {
	f, err := os.Open(name)
	if err != nil {
		return nil, nil, fmt.Errorf("could not open file for reading: %s: %v", name, err)
	}
	defer f.Close()
	gz, err := gzip.NewReader(f)
	if err != nil {
		return nil, nil, fmt.Errorf("font file contains invalid gzip data: %v", err)
	}
	src, err := ioutil.ReadAll(gz)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to decompress font file: %v", err)
	}
	fnt, err := Parse(src)
	if err != nil {
		return nil, nil, fmt.Errorf("file did not contain a valid font: %v", err)
	}
	return fnt, src, nil
}

// mergeFonts produces a trivial OpenType Collection (OTC) file for two source fonts.
// It makes many assumptions and is not meant for general use.
// For file format details, see https://docs.microsoft.com/en-us/typography/opentype/spec/otff
// For a robust tool to generate these files, see https://pypi.org/project/afdko/
func mergeFonts(ttf1, ttf2 []byte) []byte {
	// Locations to place the two embedded fonts. All of the offsets to the fonts' internal tables will need to be
	// shifted from the start of the file by the appropriate amount, and then everything will work as expected.
	offset1 := uint32(20) // Length of OpenType collection headers
	offset2 := offset1 + uint32(len(ttf1))

	var buf bytes.Buffer
	_, _ = buf.Write([]byte("ttcf\x00\x01\x00\x00\x00\x00\x00\x02"))
	_ = binary.Write(&buf, binary.BigEndian, offset1)
	_ = binary.Write(&buf, binary.BigEndian, offset2)

	// Inline function to copy a font into the collection verbatim, except for adding an offset to all of the font's
	// table positions.
	copyOffsetTTF := func(ttf []byte, offset uint32) {
		_, _ = buf.Write(ttf[:12])
		numTables := binary.BigEndian.Uint16(ttf[4:6])
		for i := uint16(0); i < numTables; i++ {
			p := 12 + 16*i
			_, _ = buf.Write(ttf[p : p+8])
			tblLoc := binary.BigEndian.Uint32(ttf[p+8:p+12]) + offset
			_ = binary.Write(&buf, binary.BigEndian, tblLoc)
			_, _ = buf.Write(ttf[p+12 : p+16])
		}
		_, _ = buf.Write(ttf[12+16*numTables:])
	}
	copyOffsetTTF(ttf1, offset1)
	copyOffsetTTF(ttf2, offset2)

	return buf.Bytes()
}

// shapeRune uses a given Face to shape exactly one rune at a fixed size, then returns the resulting shape data.
func shapeRune(f text.Face, r rune) (op.CallOp, error) {
	ppem := fixed.I(200)
	lines, err := f.Layout(ppem, 2000, strings.NewReader(string(r)))
	if err != nil {
		return op.CallOp{}, err
	}
	if len(lines) != 1 {
		return op.CallOp{}, fmt.Errorf("unexpected rendering for \"U+%08X\": got %d lines (expected: 1)", r, len(lines))
	}
	return f.Shape(ppem, lines[0].Layout), nil
}

// areShapesEqual returns true iff both given text shapes are produced with identical operations.
func areShapesEqual(shape1, shape2 op.CallOp) bool {
	var ops1, ops2 op.Ops
	shape1.Add(&ops1)
	shape2.Add(&ops2)
	var r1, r2 ops.Reader
	r1.Reset(&ops1)
	r2.Reset(&ops2)
	for {
		encOp1, ok1 := r1.Decode()
		encOp2, ok2 := r2.Decode()
		if ok1 != ok2 {
			return false
		}
		if !ok1 {
			break
		}
		if len(encOp1.Refs) > 0 || len(encOp2.Refs) > 0 {
			panic("unexpected ops with refs in font shaping test")
		}
		if !bytes.Equal(encOp1.Data, encOp2.Data) {
			return false
		}
	}
	return true
}
diff --git a/font/opentype/testdata/only1.ttf.gz b/font/opentype/testdata/only1.ttf.gz
new file mode 100644
index 0000000000000000000000000000000000000000..544159de47fc3ab52c79712a711f487d49003c7f
GIT binary patch
literal 442
zcmV;r0Y&~FiwFP!00002|7?*zXcR#h#ed)I%-+TeUKVm7Shx!bLM)O?j98rE8uhe^
zpoQ4z5-&L+XBI9(?6$BGgrvA4q=}uKh=rAfg@vUa0UP1aKA@mYOm<~w{ZC#oAH%%&
z<~Ixilu2-yn!h?ZeSWfe0CE<%wa|%ThIs(;7&yHYbz@)z)OEmHTE4&d>C4t5kPpDM
zb}PDFIq_luyadj)+pXy6Wpe}g3XHWot7}KP(g)eh^UEs>5ffBFb^x~%t;L*_Ign#O
za5w6-Z~@d*ejcxMSJTf-gUa~=%dXV$c5hI=S*d@c;^k&`F5M~S9#7Ne!ACFlE&xL)
z3JH|f(<|V;N}K6LFSbp8OBLO6j<H{f|K#ZBvVj4!WoAH*7t?viqQX<E?EdlolMaF)
zph1nAdn&yr4@>LvEP(C7d(d;{jeQv|g6`6{EZ_j6IJRjNojcHIyRT(gv1V_7-&AG7
z@&hJ^bSOdShyHlzhk|Cm;UxQs;X4hT<b-|DBW9N&n3)ZIGdu2w)xOlz*Gfsfr#I#|
klf5TB+wT4(0@B_5KMs)~Sw`hY00030{}tK+2w(yL0A;4vhyVZp

literal 0
HcmV?d00001

diff --git a/font/opentype/testdata/only2.ttf.gz b/font/opentype/testdata/only2.ttf.gz
new file mode 100644
index 0000000000000000000000000000000000000000..87a3e68caee839965866585593ccb9856e7273dd
GIT binary patch
literal 629
zcmV-*0*d_~iwFP!00002|7?*>NK|Va#((d7&Y3ytT%8d|y!VRtc%3U=TLd#|5iKGn
z2SRbk5sGT+=!_0IqoO7%MIyK;Tu5!iMS(=wDwhU95w81KSS<ou)WS_cU`Tdm;+&ak
zk{)<J{_k_1=l#FWK>&X+gF-YCKGx3vs33QMx=6IEJ3P^G5tJIhh;;WHz2Tp&2IU~o
z*44c~IAPA71#uEM-<L5*_**;z@z|~h(o_Af;`3>cO~6VjX(r@d_a^Yh)~8ZQ(|fPX
z526YPq%z}^dKphZtO0s@xX&a;Ge`q)Wz5MD8bt_%eZMheW|Fu7<a3)J86F$YWk`U0
zZS&6CWJ9dedU*U?#o=xK)a{R1|F~UluN$4W5A1AO8{RYe7*K*F4*~9U)&oB8&gSlC
z?`g-W7&7n8TdIlT-ov|)sOnX*fCk!<W_SEgepO!3FCA$Seo-5SVQ_>XLA}@70KHf4
zGZ43IPCcZpIxka{c@pXs;`Sk+*!dOqVHC$PfI_KLs~8TaI_JS$E<fj<6>j!8b8S&K
z!NRcj&BOrB&Q)pBR#9+Kf`<wS`RaWoQV~AQCzN`Tkn7fxkV3o{mDY;zypu{cr>%a8
zYSY%Tj9YQ*lBRohy>ilu%ed_x2Q{RvQv?6`>sv)zTWd>W!@n+nLrY~SSnaQpZlQ+0
zM#P8dp1G;(3lBbRJ>9xFmYrT)oRf{$mQM^W-kDt!`pf$>S?hB4)c5@Ji2%6^cHh5&
zDEbBEh05a$@gs#&YN<nn+dE?DPPRDN{p5qRpgR0teN&@p0t2JTK>}%We2Bmvg@bPa
P00960-Xf<Sa037U_3bnP

literal 0
HcmV?d00001

-- 
2.27.0