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