From 6c8dcbdb4b5a9f4331f79b260a872aab2748b9af Mon Sep 17 00:00:00 2001 From: tainted-bit Date: Tue, 7 Jul 2020 03:17:45 -0400 Subject: [PATCH] font/opentype: add tests for Collection as a Face 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 --- 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 00000000..0c73b9d9 --- /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}K6LFSbp8OBLO6jLvEP(C7d(d;{jeQv|g6`6{EZ_j6IJRjNojcHIyRT(gv1V_7-&AG7 z@&hJ^bSOdShyHlzhk|Cm;UxQs;X4hTNK|Va#((d7&Y3ytT%8d|y!VRtc%3U=TLd#|5iKGn z2SRbk5sGT+=!_0IqoO7%MIyK;Tu5!iMS(=wDwhU95w81KSS&X+gF-YCKGx3vs33QMx=6IEJ3P^G5tJIhh;;WHz2Tp&2IU~o z*44c~IAPA71#uEM-cO~6VjX(r@d_a^Yh)~8ZQ(|fPX z526YPq%z}^dKphZtO0s@xX&a;Ge`q)Wz5MD8bt_%eZMheW|Fu7XLA}@70KHf4 zGZ43IPCcZpIxka{c@pXs;`Sk+*!dOqVHC$PfI_KLs~8TaI_JS$Ej!8b8S&K z!NRcj&BOrB&Q)pBR#9+Kf`%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}%We2Bmvg@bPa P00960-Xf