1package text
2
3import (
4 "errors"
5 "math"
6 "slices"
7
8 "github.com/bnema/nefergui/internal/bidi"
9 "github.com/go-text/typesetting/di"
10 "github.com/go-text/typesetting/font"
11 "github.com/go-text/typesetting/segmenter"
12 "github.com/go-text/typesetting/shaping"
13 "golang.org/x/image/math/fixed"
14)
15
16// Glyph contains a positioned glyph in logical pixels, with its original shaping metrics.
17type Glyph struct {
18 ID font.GID
19 X, Y float64
20 Advance float64
21 // Cluster is the rune index, in the original Measure input, of the first
22 // rune of the glyph's cluster. It is not a UTF-8 byte offset.
23 Cluster int
24 Missing bool
25}
26
27// Run is a shaped span with one face, direction and embedding level.
28type Run struct {
29 Face *Face
30 Direction di.Direction
31 Level uint8 // resolved embedding level after L1
32 // Start and End are rune indices in the original Measure input; End is
33 // exclusive. A CRLF separator counts as two runes.
34 Start, End int
35 X, Y, Advance float64
36 Glyphs []Glyph
37}
38type Line struct {
39 Runs []Run
40 Direction di.Direction // resolved paragraph base direction
41 Width, Baseline, Height float64
42}
43type Layout struct {
44 Lines []Line
45 Direction di.Direction // paragraph basis, auto resolved by bidi analysis
46 Width, Height, Baseline, LineHeight float64
47}
48
49// Engine is single-goroutine only: Measure and EndFrame are not synchronized.
50// Its catalog is fixed at NewEngine and must not change afterwards, because
51// cached measurements depend on its faces.
52type Engine struct {
53 catalog *Catalog
54 seg shaping.Segmenter
55 shaper shaping.HarfbuzzShaper
56 wrapper shaping.LineWrapper
57 cache measureCache
58 scratch measureScratch
59}
60
61// measureScratch holds per-call buffers reused across measure calls. Nothing
62// in it may be referenced by a returned Layout.
63type measureScratch struct {
64 runes []rune
65 faces []*Face
66 reverse map[*font.Face]*Face
67 faceFor []*font.Face
68 items []shaping.Input
69 levelled []shaping.Input
70 outs []shaping.Output
71 runs shaping.RunIterator
72 graphemes segmenter.Segmenter
73 missing []rune // lazy coverage: runes no named face covers
74 choices []*Face // lazy coverage: faces selected for this measure
75 levels []uint8
76 breaks [1]int
77 visual visualScratch
78}
79
80// fullBreak returns the single paragraph-end line break (nil for empty text),
81// backed by scratch storage.
82func (s *measureScratch) fullBreak(text []rune) []int {
83 if len(text) == 0 {
84 return nil
85 }
86 s.breaks[0] = len(text)
87 return s.breaks[:]
88}
89
90// iterate returns the reusable run iterator positioned at the start of outs.
91func (s *measureScratch) iterate(outs []shaping.Output) shaping.RunIterator {
92 if r, ok := s.runs.(interface{ Reset([]shaping.Output) }); ok {
93 r.Reset(outs)
94 return s.runs
95 }
96 s.runs = shaping.NewSliceIterator(outs)
97 return s.runs
98}
99
100func NewEngine(c *Catalog) *Engine { return &Engine{catalog: c} }
101
102type constantFace struct{ face *font.Face }
103
104func (m constantFace) ResolveFace(r rune) *font.Face { return m.face }
105
106// clusterFaces selects a single face for every UAX#29 extended grapheme. Format
107// characters (ZWJ and variation selectors) do not need outline coverage; they
108// remain in the cluster to let HarfBuzz apply substitutions.
109// seg is reused scratch storage.
110func clusterFaces(seg *segmenter.Segmenter, selected []*font.Face, text []rune, choices []*Face) []*font.Face {
111 selected = slices.Grow(selected[:0], len(text))[:len(text)]
112 seg.Init(text)
113 it := seg.GraphemeIterator()
114 for it.Next() {
115 g := it.Grapheme()
116 f := choices[0]
117 for _, candidate := range choices {
118 all := true
119 for _, r := range g.Text {
120 if r == 0x200d || r >= 0xfe00 && r <= 0xfe0f {
121 continue
122 }
123 if _, ok := candidate.Shape.NominalGlyph(r); !ok {
124 all = false
125 break
126 }
127 }
128 if all {
129 f = candidate
130 break
131 }
132 }
133 for i := range g.Text {
134 selected[g.Offset+i] = f.Shape
135 }
136 }
137 return selected
138}
139
140// faceCovers checks an entire grapheme, ignoring format selectors.
141func faceCovers(f *Face, cluster []rune) bool {
142 for _, ch := range cluster {
143 if ch == 0x200d || ch >= 0xfe00 && ch <= 0xfe0f {
144 continue
145 }
146 if _, ok := f.Shape.NominalGlyph(ch); !ok {
147 return false
148 }
149 }
150 return true
151}
152
153// split cluster-aligned face runs after go-text's bidi/script itemization.
154func splitFaces(out, items []shaping.Input, faceFor []*font.Face) []shaping.Input {
155 out = out[:0]
156 for _, item := range items {
157 if item.RunStart >= item.RunEnd {
158 continue
159 }
160 start := item.RunStart
161 face := faceFor[start]
162 for i := start + 1; i < item.RunEnd; i++ {
163 if faceFor[i] != face {
164 part := item
165 part.RunStart = start
166 part.RunEnd = i
167 part.Face = face
168 out = append(out, part)
169 start = i
170 face = faceFor[i]
171 }
172 }
173 item.RunStart = start
174 item.Face = face
175 out = append(out, item)
176 }
177 return out
178}
179func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []uint8) ([]shaping.Output, map[*font.Face]*Face, error) {
180 if e == nil || e.catalog == nil || len(e.catalog.Faces) == 0 {
181 return nil, nil, errors.New("no font catalog")
182 }
183 if r.Size <= 0 || math.IsNaN(r.Size) || math.IsInf(r.Size, 0) || r.Size > 4096 {
184 return nil, nil, errors.New("invalid font size")
185 }
186 choices := e.catalog.candidates(r)
187 if e.catalog.source != nil {
188 // Load named faces only as required for coverage. A failing face is
189 // excluded permanently and ranking is recomputed to admit its runner-up.
190 // The scratch segmenter is free here: clusterFaces re-inits it later.
191 named := choices
192 seg := &e.scratch.graphemes
193 seg.Init(text)
194 it := seg.GraphemeIterator()
195 missing := e.scratch.missing[:0]
196 for it.Next() {
197 cluster := it.Grapheme().Text
198 covered := false
199 for {
200 named = e.catalog.candidates(r)
201 failed := false
202 for _, f := range named {
203 if !f.ready() {
204 failed = true
205 break
206 }
207 if faceCovers(f, cluster) {
208 covered = true
209 break
210 }
211 }
212 if covered || !failed {
213 break
214 }
215 }
216 if !covered {
217 missing = append(missing, cluster...)
218 }
219 }
220 // Rebuild after failed loads. The fallback window excludes named
221 // families by key, so it never depends on load or failure state.
222 e.scratch.missing = missing
223 named = e.catalog.candidates(r)
224 choices = e.scratch.choices[:0]
225 for _, f := range named {
226 if f.Shape != nil && !f.bad {
227 choices = append(choices, f)
228 }
229 }
230 if len(missing) != 0 {
231 for _, f := range e.catalog.fallbackWindow(missing, r) {
232 if !f.ready() {
233 continue
234 }
235 choices = append(choices, f)
236 complete := true
237 seg.Init(missing)
238 it = seg.GraphemeIterator()
239 for it.Next() {
240 covered := false
241 for _, candidate := range choices {
242 if faceCovers(candidate, it.Grapheme().Text) {
243 covered = true
244 break
245 }
246 }
247 if !covered {
248 complete = false
249 break
250 }
251 }
252 if complete {
253 break
254 }
255 }
256 }
257 // Nothing selected (empty text, or only uncovered runes with a failed
258 // window): the primary face shapes the text and supplies deterministic tofu.
259 if len(choices) == 0 {
260 if f := e.catalog.primary(r); f != nil {
261 choices = append(choices, f)
262 }
263 }
264 e.scratch.choices = choices
265 }
266 if len(choices) == 0 {
267 return nil, nil, errors.New("no usable fonts")
268 }
269 sc := &e.scratch
270 if sc.reverse == nil {
271 sc.reverse = map[*font.Face]*Face{}
272 }
273 clear(sc.reverse)
274 faces := sc.faces[:0]
275 // Variations belong to the engine invocation; do not mutate the catalog's shared faces.
276 for _, f := range choices {
277 if len(r.Variations) > 0 {
278 f = f.WithVariations(r.Variations)
279 }
280 faces = append(faces, f)
281 sc.reverse[f.Shape] = f
282 }
283 sc.faces = faces
284 sc.faceFor = clusterFaces(&sc.graphemes, sc.faceFor, text, faces)
285 input := shaping.Input{Text: text, RunEnd: len(text), Direction: direction, Size: fixed.Int26_6(math.Round(r.Size * 64))}
286 sc.items = splitFaces(sc.items, e.seg.Split(input, constantFace{face: faces[0].Shape}), sc.faceFor)
287 sc.levelled = splitLevels(sc.levelled, sc.items, levels)
288 out := sc.outs[:0]
289 for _, item := range sc.levelled {
290 if item.RunEnd > item.RunStart {
291 out = append(out, e.shaper.Shape(item))
292 }
293 }
294 clear(out[len(out):cap(out)]) // drop glyph references from longer earlier calls
295 sc.outs = out
296 return out, sc.reverse, nil
297}
298
299// Measure shapes, wraps (UAX#14) and positions runs. Width <= 0 means unbounded.
300// Every UAX#9 paragraph resolves its own direction; line baselines stack in logical px.
301//
302// Results are cached per (text, request, width) until EndFrame evicts them.
303// The returned Lines, Runs and Glyphs are shared with the cache: treat them as
304// immutable and copy before changing positions (see Layout.Clone).
305func (e *Engine) Measure(s string, r Request, width float64) (Layout, error) {
306 if l, ok := e.cache.get(s, r, width); ok {
307 return l, nil
308 }
309 l, err := e.measure(s, r, width)
310 if err == nil {
311 e.cache.put(s, r, width, l)
312 }
313 return l, err
314}
315
316// EndFrame evicts measurements not used during the last two frames. Call it
317// once after each frame that may have measured text.
318func (e *Engine) EndFrame() { e.cache.endFrame() }
319
320func appendRunes(dst []rune, s string) []rune {
321 for _, c := range s {
322 dst = append(dst, c)
323 }
324 return dst
325}
326
327func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
328 var result Layout
329 if math.IsNaN(width) || math.IsInf(width, 0) || width > 1<<24 {
330 return result, errors.New("invalid width")
331 }
332 if math.IsNaN(r.LetterSpacing) || math.IsInf(r.LetterSpacing, 0) || math.Abs(r.LetterSpacing) > 4096 {
333 return result, errors.New("invalid letter spacing")
334 }
335 e.scratch.runes = appendRunes(e.scratch.runes[:0], s)
336 text := e.scratch.runes
337 if paragraphs, split := paragraphRanges(text); split {
338 // Each paragraph measure reuses the scratch runes; keep a copy.
339 return e.measureParagraphs(slices.Clone(text), paragraphs, r, width)
340 }
341 if r.Direction > bidi.RTL {
342 return result, errors.New("invalid paragraph direction")
343 }
344 paraLevels, baseLevel, err := bidi.ResolveInto(e.scratch.levels, text, r.Direction, e.scratch.fullBreak(text))
345 if err != nil {
346 return result, err
347 }
348 e.scratch.levels = paraLevels
349 direction := di.DirectionLTR
350 if baseLevel%2 == 1 {
351 direction = di.DirectionRTL
352 }
353 result.Direction = direction
354 outs, reverse, err := e.shape(text, r, direction, paraLevels)
355 if err != nil {
356 return result, err
357 }
358 if r.LetterSpacing != 0 {
359 shaping.AddSpacing(outs, text, 0, fixed.Int26_6(math.Round(r.LetterSpacing*64)))
360 }
361 baseline := r.Size * 0.8
362 height := r.Size * 1.2
363 if ext, ok := e.catalog.primary(r).Shape.FontHExtents(); ok {
364 unit := r.Size / float64(e.catalog.primary(r).Shape.Upem())
365 baseline = float64(ext.Ascender) * unit
366 height = float64(ext.Ascender-ext.Descender+ext.LineGap) * unit
367 }
368 if height <= 0 {
369 height = r.Size * 1.2
370 }
371 result.Baseline = baseline
372 result.LineHeight = height
373 if len(text) == 0 {
374 return result, nil
375 }
376 limit := fixed.Int26_6(1 << 30)
377 if width > 0 {
378 limit = fixed.Int26_6(math.Round(width * 64))
379 }
380 wrapped, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, limit, text, e.scratch.iterate(outs))
381 if len(wrapped) > 0 {
382 result.Lines = make([]Line, 0, len(wrapped))
383 }
384 for idx, line := range wrapped {
385 ordered, err := e.scratch.visual.order(text, line, r.Direction)
386 if err != nil {
387 return result, err
388 }
389 dst := Line{Baseline: baseline + float64(idx)*height, Height: height, Direction: direction}
390 glyphCount := 0
391 for _, o := range ordered {
392 glyphCount += len(o.output.Glyphs)
393 }
394 var glyphs []Glyph
395 if len(ordered) > 0 {
396 dst.Runs = make([]Run, 0, len(ordered))
397 glyphs = make([]Glyph, 0, glyphCount)
398 }
399 for _, item := range ordered {
400 o := item.output
401 f := reverse[o.Face]
402 run := Run{Face: f, Direction: o.Direction, Level: item.level, Start: o.Runes.Offset, End: o.Runes.Offset + o.Runes.Count, X: dst.Width, Y: dst.Baseline, Advance: float64(o.Advance) / 64}
403 cursor := 0.0
404 first := len(glyphs)
405 for _, g := range o.Glyphs {
406 glyphs = append(glyphs, Glyph{ID: g.GlyphID, X: run.X + cursor + float64(g.XOffset)/64, Y: run.Y - float64(g.YOffset)/64, Advance: float64(g.Advance) / 64, Cluster: g.TextIndex(), Missing: g.GlyphID == 0})
407 cursor += float64(g.Advance) / 64
408 }
409 if len(glyphs) > first {
410 run.Glyphs = glyphs[first:len(glyphs):len(glyphs)]
411 }
412 dst.Runs = append(dst.Runs, run)
413 dst.Width += run.Advance
414 }
415 if dst.Width > result.Width {
416 result.Width = dst.Width
417 }
418 result.Lines = append(result.Lines, dst)
419 }
420 result.Height = float64(len(result.Lines)) * height
421 return result, nil
422}