1package text
2
3import (
4 "encoding/binary"
5 "hash/maphash"
6 "math"
7 "slices"
8
9 "github.com/go-text/typesetting/font"
10)
11
12// maxMeasureEntries bounds the cache so a single frame measuring many unique
13// strings cannot grow it without limit; beyond it results are not cached.
14const maxMeasureEntries = 4096
15
16var measureSeed = maphash.MakeSeed()
17
18type measureKey struct {
19 text string
20 families uint64 // hash of Request.Families and Variations
21 weight float32
22 stretch float32
23 italic bool
24 dir uint8
25 size float64
26 spacing float64
27 width float64
28}
29
30type measureEntry struct {
31 families []string // verified on hit, so a hash collision cannot alias
32 variations []font.Variation
33 layout Layout
34 used uint64
35}
36
37type measureCache struct {
38 entries map[measureKey]*measureEntry
39 frame uint64
40 free []*measureEntry // evicted entries, reused by put
41}
42
43func requestKey(s string, r Request, width float64) measureKey {
44 var h maphash.Hash
45 h.SetSeed(measureSeed)
46 for _, f := range r.Families {
47 h.WriteString(f)
48 h.WriteByte(0)
49 }
50 h.WriteByte(1)
51 for _, v := range r.Variations {
52 var b [8]byte
53 binary.LittleEndian.PutUint32(b[:4], uint32(v.Tag))
54 binary.LittleEndian.PutUint32(b[4:], math.Float32bits(v.Value))
55 h.Write(b[:])
56 }
57 return measureKey{text: s, families: h.Sum64(), weight: r.Weight, stretch: r.Stretch, italic: r.Italic, dir: uint8(r.Direction), size: r.Size, spacing: r.LetterSpacing, width: width}
58}
59
60func (c *measureCache) get(s string, r Request, width float64) (Layout, bool) {
61 e := c.entries[requestKey(s, r, width)]
62 if e == nil || !slices.Equal(e.families, r.Families) || !slices.Equal(e.variations, r.Variations) {
63 return Layout{}, false
64 }
65 e.used = c.frame
66 return e.layout, true
67}
68
69func (c *measureCache) put(s string, r Request, width float64, l Layout) {
70 if c.entries == nil {
71 c.entries = make(map[measureKey]*measureEntry)
72 }
73 if len(c.entries) >= maxMeasureEntries {
74 return
75 }
76 var e *measureEntry
77 if n := len(c.free); n > 0 {
78 e, c.free = c.free[n-1], c.free[:n-1]
79 } else {
80 e = new(measureEntry)
81 }
82 // Reuse the evicted entry's slices; Clone of an empty request stays nil.
83 e.families = append(e.families[:0], r.Families...)
84 e.variations = append(e.variations[:0], r.Variations...)
85 e.layout, e.used = l, c.frame
86 c.entries[requestKey(s, r, width)] = e
87}
88
89func (c *measureCache) endFrame() {
90 for k, e := range c.entries {
91 if c.frame-e.used >= 2 {
92 delete(c.entries, k)
93 // Callers may still hold e.layout; only the entry is recycled.
94 e.layout = Layout{}
95 c.free = append(c.free, e)
96 }
97 }
98 c.frame++
99}
100
101// Clone deep-copies lines, runs and glyphs so positions can be adjusted
102// without changing a cached measurement.
103func (l Layout) Clone() Layout {
104 lines := make([]Line, len(l.Lines))
105 for i, line := range l.Lines {
106 runs := make([]Run, len(line.Runs))
107 for j, run := range line.Runs {
108 run.Glyphs = slices.Clone(run.Glyphs)
109 runs[j] = run
110 }
111 line.Runs = runs
112 lines[i] = line
113 }
114 l.Lines = lines
115 return l
116}