diff --git a/font/renderer.go b/font/renderer.go index 7c864db7..553209b0 100644 --- a/font/renderer.go +++ b/font/renderer.go @@ -3,11 +3,8 @@ package font import ( - "bytes" - "compress/gzip" "errors" "fmt" - "io" ot "github.com/go-text/typesetting/font/opentype" "github.com/go-text/typesetting/font/opentype/tables" @@ -62,7 +59,8 @@ type GlyphSVG struct { // The SVG image content, decompressed if needed. // The actual glyph description is an SVG element // with id="glyph" (as in id="glyph12"), - // and several glyphs may share the same Source + // and several glyphs may share the same Source. + // Source is read-only; copy it before modifying it. Source []byte // ViewBox is the initial viewport of the SVG document: @@ -187,26 +185,15 @@ func (bt bitmap) glyphData(gid gID, xPpem, yPpem uint16) (GlyphBitmap, error) { } func (s svg) glyphData(gid gID, upem uint16) (GlyphSVG, bool) { - data, ok := s.rawGlyphData(gid) + doc, ok := s.rawGlyphData(gid) if !ok { return GlyphSVG{}, false } - - // un-compress if needed - if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b { - r, err := gzip.NewReader(bytes.NewReader(data)) - if err != nil { - return GlyphSVG{}, false - } - defer r.Close() - var buf bytes.Buffer - if _, err := io.Copy(&buf, r); err != nil { - return GlyphSVG{}, false - } - data = buf.Bytes() + data, viewBox, ok := doc.resolve(upem) + if !ok { + return GlyphSVG{}, false } - - return GlyphSVG{Source: data, ViewBox: svgViewBox(data, upem)}, true + return GlyphSVG{Source: data, ViewBox: viewBox}, true } // this file converts from font format for glyph outlines to diff --git a/font/svg.go b/font/svg.go index fca82f7c..89859260 100644 --- a/font/svg.go +++ b/font/svg.go @@ -4,11 +4,14 @@ package font import ( "bytes" + "compress/gzip" "encoding/xml" "fmt" + "io" "math" "strconv" "strings" + "sync" "github.com/go-text/typesetting/font/opentype/tables" ) @@ -39,20 +42,47 @@ type svgDocument struct { svg []byte first gID // The first glyph ID in the range described by this index entry. last gID // The last glyph ID in the range described by this index entry. Must be >= startGlyphID. + + // resolved on first use and shared by every glyph of the document + once sync.Once + resolved []byte // un-compressed + viewBox SVGViewBox + valid bool +} + +// resolve un-compresses the document if needed and parses its viewBox +func (doc *svgDocument) resolve(upem uint16) ([]byte, SVGViewBox, bool) { + doc.once.Do(func() { + data := doc.svg + if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b { + r, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return + } + defer r.Close() + data, err = io.ReadAll(r) + if err != nil { + return + } + } + doc.resolved, doc.viewBox = data, svgViewBox(data, upem) + doc.valid = true + }) + return doc.resolved, doc.viewBox, doc.valid } // rawGlyphData returns the SVG document for [gid], or false. -func (s svg) rawGlyphData(gid gID) ([]byte, bool) { +func (s svg) rawGlyphData(gid gID) (*svgDocument, bool) { // binary search for i, j := 0, len(s); i < j; { h := i + (j-i)/2 - entry := s[h] + entry := &s[h] if gid < entry.first { j = h } else if entry.last < gid { i = h + 1 } else { - return entry.svg, true + return entry, true } } return nil, false diff --git a/font/svg_test.go b/font/svg_test.go index e05f7dbd..6faaed58 100644 --- a/font/svg_test.go +++ b/font/svg_test.go @@ -3,6 +3,9 @@ package font import ( + "bytes" + "compress/gzip" + "sync" "testing" "github.com/go-text/typesetting/font/opentype/tables" @@ -77,3 +80,58 @@ func TestSVGOffsetOverflow(t *testing.T) { }}) tu.Assert(t, err != nil) } + +func BenchmarkGlyphDataSVG(b *testing.B) { + font := loadFont(b, "toys/chromacheck-svg.ttf") + face := NewFace(font) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = face.GlyphDataSVG(1) + } +} + +func TestSVGResolveConcurrent(t *testing.T) { + const source = `` + var compressed bytes.Buffer + w := gzip.NewWriter(&compressed) + if _, err := w.Write([]byte(source)); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + badChecksum := append([]byte(nil), compressed.Bytes()...) + badChecksum[len(badChecksum)-8] ^= 1 + for _, test := range []struct { + name string + data []byte + valid bool + }{ + {"plain", []byte(source), true}, + {"gzip", compressed.Bytes(), true}, + {"truncated header", []byte{0x1f, 0x8b}, false}, + {"truncated body", compressed.Bytes()[:10], false}, + {"invalid checksum", badChecksum, false}, + } { + t.Run(test.name, func(t *testing.T) { + s := svg{{first: 0, last: 7, svg: test.data}} + var wg sync.WaitGroup + for gid := gID(0); gid <= 7; gid++ { + wg.Add(1) + go func(gid gID) { + defer wg.Done() + for repeat := 0; repeat < 2; repeat++ { + glyph, ok := s.glyphData(gid, 1000) + if ok != test.valid { + t.Errorf("glyph %d: success %v, want %v", gid, ok, test.valid) + } else if ok && (string(glyph.Source) != source || glyph.ViewBox != (SVGViewBox{1, 2, 30, 40})) { + t.Errorf("glyph %d: unexpected source or viewBox: %+v", gid, glyph) + } + } + }(gid) + } + wg.Wait() + }) + } +}