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