@flighthq/textlayout 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +13 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/richTextContent.d.ts +6 -0
  6. package/dist/richTextContent.d.ts.map +1 -0
  7. package/dist/richTextContent.js +435 -0
  8. package/dist/richTextContent.js.map +1 -0
  9. package/dist/richTextMetrics.d.ts +9 -0
  10. package/dist/richTextMetrics.d.ts.map +1 -0
  11. package/dist/richTextMetrics.js +44 -0
  12. package/dist/richTextMetrics.js.map +1 -0
  13. package/dist/richTextQuery.d.ts +14 -0
  14. package/dist/richTextQuery.d.ts.map +1 -0
  15. package/dist/richTextQuery.js +205 -0
  16. package/dist/richTextQuery.js.map +1 -0
  17. package/dist/textBounds.d.ts +13 -0
  18. package/dist/textBounds.d.ts.map +1 -0
  19. package/dist/textBounds.js +42 -0
  20. package/dist/textBounds.js.map +1 -0
  21. package/dist/textFormat.d.ts +7 -0
  22. package/dist/textFormat.d.ts.map +1 -0
  23. package/dist/textFormat.js +24 -0
  24. package/dist/textFormat.js.map +1 -0
  25. package/dist/textFormatRange.d.ts +3 -0
  26. package/dist/textFormatRange.d.ts.map +1 -0
  27. package/dist/textFormatRange.js +4 -0
  28. package/dist/textFormatRange.js.map +1 -0
  29. package/dist/textLayout.d.ts +8 -0
  30. package/dist/textLayout.d.ts.map +1 -0
  31. package/dist/textLayout.js +632 -0
  32. package/dist/textLayout.js.map +1 -0
  33. package/dist/textLayoutGroup.d.ts +3 -0
  34. package/dist/textLayoutGroup.d.ts.map +1 -0
  35. package/dist/textLayoutGroup.js +17 -0
  36. package/dist/textLayoutGroup.js.map +1 -0
  37. package/dist/textLayoutMeasure.d.ts +4 -0
  38. package/dist/textLayoutMeasure.d.ts.map +1 -0
  39. package/dist/textLayoutMeasure.js +23 -0
  40. package/dist/textLayoutMeasure.js.map +1 -0
  41. package/dist/textLayoutRuntime.d.ts +4 -0
  42. package/dist/textLayoutRuntime.d.ts.map +1 -0
  43. package/dist/textLayoutRuntime.js +11 -0
  44. package/dist/textLayoutRuntime.js.map +1 -0
  45. package/dist/textLineBreaks.d.ts +8 -0
  46. package/dist/textLineBreaks.d.ts.map +1 -0
  47. package/dist/textLineBreaks.js +36 -0
  48. package/dist/textLineBreaks.js.map +1 -0
  49. package/dist/textMetrics.d.ts +4 -0
  50. package/dist/textMetrics.d.ts.map +1 -0
  51. package/dist/textMetrics.js +12 -0
  52. package/dist/textMetrics.js.map +1 -0
  53. package/package.json +38 -0
  54. package/src/richTextContent.test.ts +201 -0
  55. package/src/richTextMetrics.test.ts +124 -0
  56. package/src/richTextQuery.test.ts +196 -0
  57. package/src/textBounds.test.ts +110 -0
  58. package/src/textFormat.test.ts +66 -0
  59. package/src/textFormatRange.test.ts +9 -0
  60. package/src/textLayout.test.ts +661 -0
  61. package/src/textLayoutGroup.test.ts +30 -0
  62. package/src/textLayoutMeasure.test.ts +36 -0
  63. package/src/textLayoutRuntime.test.ts +30 -0
  64. package/src/textLineBreaks.test.ts +73 -0
  65. package/src/textMetrics.test.ts +19 -0
@@ -0,0 +1,661 @@
1
+ import { createTextFormatRange } from './textFormatRange';
2
+ import type { TextLayoutParams, TextLayoutResult } from './textLayout';
3
+ import { computeTextLayout, createTextLayoutResult, getTextLayoutIsTruncated, TEXT_LAYOUT_GUTTER } from './textLayout';
4
+
5
+ // Fixed-width measure: every character is 10px regardless of font settings.
6
+ const fixedMeasure = (text: string) => text.length * 10;
7
+
8
+ const fmt = { size: 16 };
9
+ const range = (start: number, end: number) => createTextFormatRange(fmt, start, end);
10
+
11
+ function singleRangeParams(text: string, width = 1000, overrides: object = {}): TextLayoutParams {
12
+ return {
13
+ text,
14
+ formatRanges: [range(0, text.length)],
15
+ width,
16
+ height: 100,
17
+ measure: fixedMeasure,
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ function doLayout(params: TextLayoutParams): TextLayoutResult {
23
+ const out = createTextLayoutResult();
24
+ computeTextLayout(out, params);
25
+ return out;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // createTextLayoutResult
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('computeTextLayout', () => {
33
+ it('returns groups and line metrics for simple text', () => {
34
+ const result = doLayout(singleRangeParams('hi'));
35
+ expect(result.groups).not.toBeNull();
36
+ expect(result.numLines).toBeGreaterThan(0);
37
+ });
38
+
39
+ describe('center alignment', () => {
40
+ it('shifts group offsetX to center within container', () => {
41
+ const text = 'hi'; // 20px, container=100
42
+ const noAlignResult = doLayout(singleRangeParams(text, 100));
43
+ const noAlignOffsetX = noAlignResult.groups[0].offsetX;
44
+ const alignResult = doLayout(
45
+ singleRangeParams(text, 100, {
46
+ formatRanges: [createTextFormatRange({ size: 16, align: 'center' }, 0, text.length)],
47
+ }),
48
+ );
49
+ expect(alignResult.groups[0].offsetX).toBeGreaterThan(noAlignOffsetX);
50
+ });
51
+ });
52
+
53
+ describe('empty input', () => {
54
+ it('returns an empty result for empty text', () => {
55
+ const result = doLayout(singleRangeParams(''));
56
+ expect(result.groups).toHaveLength(0);
57
+ expect(result.numLines).toBe(1);
58
+ });
59
+
60
+ it('returns an empty result when formatRanges is empty', () => {
61
+ const result = doLayout({ text: 'hello', formatRanges: [], width: 200, height: 100, measure: fixedMeasure });
62
+ expect(result.groups).toHaveLength(0);
63
+ });
64
+ });
65
+
66
+ describe('explicit line breaks (multiline)', () => {
67
+ it('splits on \\n when multiline is true', () => {
68
+ const text = 'ab\ncd';
69
+ const result = doLayout(
70
+ singleRangeParams(text, 1000, { multiline: true, formatRanges: [range(0, text.length)] }),
71
+ );
72
+ const lines = result.groups.map((g) => g.lineIndex);
73
+ expect(lines).toContain(0);
74
+ expect(lines).toContain(1);
75
+ expect(result.numLines).toBe(2);
76
+ });
77
+
78
+ it('does not split on \\n when multiline is false', () => {
79
+ const text = 'ab\ncd';
80
+ const result = doLayout(
81
+ singleRangeParams(text, 1000, { multiline: false, formatRanges: [range(0, text.length)] }),
82
+ );
83
+ expect(result.numLines).toBe(1);
84
+ });
85
+
86
+ it('splits on \\r as well', () => {
87
+ const text = 'ab\rcd';
88
+ const result = doLayout(
89
+ singleRangeParams(text, 1000, { multiline: true, formatRanges: [range(0, text.length)] }),
90
+ );
91
+ expect(result.numLines).toBe(2);
92
+ });
93
+
94
+ it('handles multiple consecutive breaks', () => {
95
+ const text = 'a\n\nb';
96
+ const result = doLayout(
97
+ singleRangeParams(text, 1000, { multiline: true, formatRanges: [range(0, text.length)] }),
98
+ );
99
+ expect(result.numLines).toBe(3);
100
+ });
101
+ });
102
+
103
+ describe('line metrics', () => {
104
+ it('reports lineWidths for each line', () => {
105
+ const text = 'ab\ncd';
106
+ const result = doLayout(
107
+ singleRangeParams(text, 1000, { multiline: true, formatRanges: [range(0, text.length)] }),
108
+ );
109
+ expect(result.lineWidths).toHaveLength(2);
110
+ });
111
+
112
+ it('reports lineHeights for each line', () => {
113
+ const text = 'ab\ncd';
114
+ const result = doLayout(
115
+ singleRangeParams(text, 1000, { multiline: true, formatRanges: [range(0, text.length)] }),
116
+ );
117
+ expect(result.lineHeights).toHaveLength(2);
118
+ for (const h of result.lineHeights) expect(h).toBeGreaterThan(0);
119
+ });
120
+
121
+ it('reports textHeight > 0 for non-empty text', () => {
122
+ const result = doLayout(singleRangeParams('hello'));
123
+ expect(result.textHeight).toBeGreaterThan(0);
124
+ });
125
+
126
+ it('reports textWidth > 0 for non-empty text', () => {
127
+ const result = doLayout(singleRangeParams('hello'));
128
+ expect(result.textWidth).toBeGreaterThan(0);
129
+ });
130
+ });
131
+
132
+ describe('multiple format ranges', () => {
133
+ it('produces separate groups for each format range', () => {
134
+ const text = 'helloworld';
135
+ const result = doLayout({
136
+ text,
137
+ formatRanges: [createTextFormatRange({ size: 16 }, 0, 5), createTextFormatRange({ size: 24 }, 5, 10)],
138
+ width: 1000,
139
+ height: 100,
140
+ measure: fixedMeasure,
141
+ });
142
+ // Expect at least 2 groups (one per range).
143
+ expect(result.groups.length).toBeGreaterThanOrEqual(2);
144
+ expect(result.groups[0].format.size).toBe(16);
145
+ expect(result.groups[1].format.size).toBe(24);
146
+ });
147
+ });
148
+
149
+ describe('right alignment', () => {
150
+ it('shifts group offsetX to right-align within container', () => {
151
+ const text = 'hi'; // 20px wide, container=100
152
+ const result = doLayout(
153
+ singleRangeParams(text, 100, {
154
+ formatRanges: [createTextFormatRange({ size: 16, align: 'right' }, 0, text.length)],
155
+ }),
156
+ );
157
+ // With right align the group should be shifted right of the GUTTER start.
158
+ expect(result.groups[0].offsetX).toBeGreaterThan(2);
159
+ });
160
+ });
161
+
162
+ describe('single line', () => {
163
+ it('produces one group for a simple string', () => {
164
+ const result = doLayout(singleRangeParams('hello'));
165
+ expect(result.groups).toHaveLength(1);
166
+ expect(result.groups[0].startIndex).toBe(0);
167
+ expect(result.groups[0].endIndex).toBe(5);
168
+ expect(result.groups[0].lineIndex).toBe(0);
169
+ });
170
+
171
+ it('positions the group at the gutter offset', () => {
172
+ const result = doLayout(singleRangeParams('hi'));
173
+ // baseX = GUTTER (2) + leftMargin (0) + blockIndent (0) + indent (0)
174
+ expect(result.groups[0].offsetX).toBe(2);
175
+ expect(result.groups[0].offsetY).toBe(2); // GUTTER
176
+ });
177
+
178
+ it('sets width to the sum of character advances', () => {
179
+ const result = doLayout(singleRangeParams('abc'));
180
+ // 3 chars × 10px = 30px — but pair-wise logic may produce slightly different
181
+ // values. For a fixed-width font: measure("bc") - measure("c") = 20 - 10 = 10.
182
+ expect(result.groups[0].width).toBeCloseTo(30, 0);
183
+ });
184
+
185
+ it('reports numLines as 1', () => {
186
+ expect(doLayout(singleRangeParams('hello')).numLines).toBe(1);
187
+ });
188
+
189
+ it('stores per-character positions', () => {
190
+ const result = doLayout(singleRangeParams('ab'));
191
+ expect(result.groups[0].positions).toHaveLength(2);
192
+ });
193
+ });
194
+
195
+ describe('word wrap', () => {
196
+ // With fixedMeasure (10px/char) and width=50:
197
+ // "hello world" → "hello " = 60px → wraps; "world" = 50px fits
198
+ it('wraps at word boundary when line exceeds width', () => {
199
+ const text = 'hello world';
200
+ const result = doLayout(
201
+ singleRangeParams(text, 50, { wordWrap: true, multiline: true, formatRanges: [range(0, text.length)] }),
202
+ );
203
+ // Should have groups on at least two lines
204
+ const lineIndices = result.groups.map((g) => g.lineIndex);
205
+ expect(Math.max(...lineIndices)).toBeGreaterThanOrEqual(1);
206
+ });
207
+
208
+ it('does not wrap when word wrap is false even if text exceeds width', () => {
209
+ const text = 'hello world';
210
+ const result = doLayout(singleRangeParams(text, 50, { wordWrap: false, formatRanges: [range(0, text.length)] }));
211
+ expect(result.numLines).toBe(1);
212
+ });
213
+
214
+ it('breaks a single long word that exceeds the wrap width', () => {
215
+ const text = 'abcdefghij'; // 100px, width=50 → should break mid-word
216
+ const result = doLayout(
217
+ singleRangeParams(text, 50, { wordWrap: true, multiline: true, formatRanges: [range(0, text.length)] }),
218
+ );
219
+ expect(result.numLines).toBeGreaterThan(1);
220
+ });
221
+ });
222
+ });
223
+
224
+ describe('computeTextLayout — bullet list items', () => {
225
+ it('emits a bullet glyph group for a format with bullet:true', () => {
226
+ const text = 'item';
227
+ const result = doLayout({
228
+ text,
229
+ formatRanges: [createTextFormatRange({ size: 16, bullet: true }, 0, text.length)],
230
+ width: 200,
231
+ height: 100,
232
+ measure: fixedMeasure,
233
+ multiline: true,
234
+ });
235
+ // There should be a bullet group (zero-length startIndex===endIndex) plus a text group.
236
+ const bulletGroup = result.groups.find((g) => g.startIndex === g.endIndex);
237
+ expect(bulletGroup).toBeDefined();
238
+ expect(bulletGroup!.lineIndex).toBe(0);
239
+ });
240
+
241
+ it('lets an explicit positive indent win over the bullet width (text may overlap the bullet)', () => {
242
+ // Contract: a user-set positive indent is authoritative — it is NOT clamped
243
+ // up to the bullet glyph width. With fixedMeasure the bullet '•' is 10px wide,
244
+ // but indent:1 keeps the text at baseX = GUTTER(2) + indent(1) = 3, inside the
245
+ // bullet's 10px span, so the text deliberately overlaps the bullet.
246
+ const text = 'item';
247
+ const result = doLayout({
248
+ text,
249
+ formatRanges: [createTextFormatRange({ size: 16, bullet: true, indent: 1 }, 0, text.length)],
250
+ width: 200,
251
+ height: 100,
252
+ measure: fixedMeasure,
253
+ multiline: true,
254
+ });
255
+ const bulletGroup = result.groups.find((g) => g.startIndex === g.endIndex);
256
+ const textGroup = result.groups.find((g) => g.startIndex !== g.endIndex);
257
+ expect(bulletGroup).toBeDefined();
258
+ expect(textGroup).toBeDefined();
259
+ expect(bulletGroup!.offsetX).toBe(TEXT_LAYOUT_GUTTER); // bullet at the gutter
260
+ expect(textGroup!.offsetX).toBe(TEXT_LAYOUT_GUTTER + 1); // explicit indent honored, not clamped
261
+ // The text starts inside the bullet's drawn width → overlap is the accepted behavior.
262
+ expect(textGroup!.offsetX).toBeLessThan(bulletGroup!.offsetX + bulletGroup!.width);
263
+ });
264
+ });
265
+
266
+ describe('computeTextLayout — codepoint iteration', () => {
267
+ it('does not split surrogate pairs (astral codepoints)', () => {
268
+ // U+1F600 GRINNING FACE is a surrogate pair (2 UTF-16 code units).
269
+ const emoji = '😀'; // 😀
270
+ const text = emoji + 'ab';
271
+ const result = doLayout({
272
+ text,
273
+ formatRanges: [createTextFormatRange({ size: 16 }, 0, text.length)],
274
+ width: 200,
275
+ height: 100,
276
+ measure: (s) => s.length * 10, // length-based to expose split
277
+ });
278
+ // The emoji is 2 code units but 1 codepoint; positions should reflect
279
+ // that the emoji is one logical character (not split into 2).
280
+ expect(result.groups[0].positions.length).toBe(3); // emoji + 'a' + 'b' = 3 codepoints
281
+ });
282
+ });
283
+
284
+ describe('computeTextLayout — conformance: bullet listMarker:none suppresses glyph', () => {
285
+ it('does not emit a bullet group when listMarker is none', () => {
286
+ const text = 'item';
287
+ const result = doLayout({
288
+ text,
289
+ formatRanges: [createTextFormatRange({ size: 16, bullet: true, listMarker: 'none' }, 0, text.length)],
290
+ width: 200,
291
+ height: 100,
292
+ measure: fixedMeasure,
293
+ multiline: true,
294
+ });
295
+ // With listMarker: 'none', no zero-length bullet group should be emitted.
296
+ const bulletGroups = result.groups.filter((g) => g.startIndex === g.endIndex);
297
+ expect(bulletGroups).toHaveLength(0);
298
+ });
299
+
300
+ it('emits a bullet group when listMarker is absent (default bullet)', () => {
301
+ const text = 'item';
302
+ const result = doLayout({
303
+ text,
304
+ formatRanges: [createTextFormatRange({ size: 16, bullet: true }, 0, text.length)],
305
+ width: 200,
306
+ height: 100,
307
+ measure: fixedMeasure,
308
+ multiline: true,
309
+ });
310
+ const bulletGroups = result.groups.filter((g) => g.startIndex === g.endIndex);
311
+ expect(bulletGroups.length).toBeGreaterThan(0);
312
+ });
313
+ });
314
+
315
+ describe('computeTextLayout — conformance: center alignment golden values', () => {
316
+ it('places a 20px text at offsetX 40 inside a 100px container', () => {
317
+ // container=100, gutter=2, text="hi"=20px
318
+ // slack = 100 - 20 - 2*2 = 76 → shift = 76/2 = 38 → offsetX = 2 + 38 = 40
319
+ const text = 'hi';
320
+ const result = doLayout({
321
+ text,
322
+ formatRanges: [createTextFormatRange({ size: 16, align: 'center' }, 0, text.length)],
323
+ width: 100,
324
+ height: 100,
325
+ measure: fixedMeasure,
326
+ });
327
+ expect(result.groups[0].offsetX).toBe(40);
328
+ });
329
+ });
330
+
331
+ describe('computeTextLayout — conformance: justify multi-paragraph', () => {
332
+ // Two paragraphs separated by \n. Only the non-last lines of each paragraph
333
+ // should be justified; last lines of each paragraph remain left-aligned.
334
+ //
335
+ // Text: "aa bb\ncc dd" with width=100 (available=96 after gutters)
336
+ // "aa bb" fits on one line → that is the last line of para 1 → NOT justified
337
+ // "cc dd" is the last line of para 2 → NOT justified
338
+ it('does not justify the last line of each paragraph in multi-paragraph text', () => {
339
+ const text = 'aa bb\ncc dd';
340
+ const result = doLayout({
341
+ text,
342
+ formatRanges: [createTextFormatRange({ size: 16, align: 'justify' }, 0, text.length)],
343
+ width: 100,
344
+ height: 200,
345
+ measure: fixedMeasure,
346
+ multiline: true,
347
+ });
348
+ expect(result.numLines).toBe(2);
349
+ // Both lines are paragraph-last lines → neither should be shifted by justify.
350
+ const line0Groups = result.groups.filter((g) => g.lineIndex === 0);
351
+ const line1Groups = result.groups.filter((g) => g.lineIndex === 1);
352
+ // Line 0 first group should start at TEXT_LAYOUT_GUTTER (left-aligned).
353
+ expect(line0Groups[0]?.offsetX).toBe(TEXT_LAYOUT_GUTTER);
354
+ // Line 1 first group should also start at TEXT_LAYOUT_GUTTER.
355
+ expect(line1Groups[0]?.offsetX).toBe(TEXT_LAYOUT_GUTTER);
356
+ });
357
+
358
+ it('justifies mid-paragraph lines but not paragraph-last lines when word-wrap splits a paragraph', () => {
359
+ // A single paragraph of "aa bb cc dd" with narrow width=50 so it wraps.
360
+ // Line 0: "aa bb" (wrapped mid-paragraph) — should be justified (2 groups, residual ~46px)
361
+ // Line 1: "cc dd" (last line of para) — should NOT be justified
362
+ const text = 'aa bb cc dd';
363
+ const result = doLayout({
364
+ text,
365
+ formatRanges: [createTextFormatRange({ size: 16, align: 'justify' }, 0, text.length)],
366
+ width: 56, // available=52, 'aa bb' = 50px → fits; 'cc' would push to 56 total → wraps
367
+ height: 200,
368
+ measure: fixedMeasure,
369
+ multiline: true,
370
+ wordWrap: true,
371
+ });
372
+ expect(result.numLines).toBeGreaterThanOrEqual(2);
373
+ // The last line must not be justified (groups remain at their natural left offset).
374
+ const lastLineIdx = result.numLines - 1;
375
+ const lastLineGroups = result.groups.filter((g) => g.lineIndex === lastLineIdx);
376
+ // The very first group on the last line is always at its natural base position.
377
+ if (lastLineGroups.length > 0) {
378
+ expect(lastLineGroups[0].offsetX).toBe(TEXT_LAYOUT_GUTTER);
379
+ }
380
+ });
381
+ });
382
+
383
+ describe('computeTextLayout — conformance: right alignment golden values', () => {
384
+ it('places a 20px text at offsetX 78 inside a 100px container', () => {
385
+ // container=100, gutter=2, text="hi"=20px
386
+ // rightEdge = 100 - 20 - 2*2 = 76 → shift=76 → offsetX = 2 + 76 = 78
387
+ const text = 'hi';
388
+ const result = doLayout({
389
+ text,
390
+ formatRanges: [createTextFormatRange({ size: 16, align: 'right' }, 0, text.length)],
391
+ width: 100,
392
+ height: 100,
393
+ measure: fixedMeasure,
394
+ });
395
+ expect(result.groups[0].offsetX).toBe(78);
396
+ });
397
+ });
398
+
399
+ describe('computeTextLayout — conformance: truncation + word-wrap combined', () => {
400
+ it('clips a long word spanning the maxLines boundary', () => {
401
+ // A very long word on a narrow container forces word-breaking.
402
+ // With maxLines=1 the first broken segment is placed, then truncated.
403
+ const text = 'abcdefghijklmnop'; // 160px with fixedMeasure
404
+ const result = doLayout({
405
+ text,
406
+ formatRanges: [createTextFormatRange({ size: 16 }, 0, text.length)],
407
+ width: 60, // narrow: forces word-break, each segment ~5-6 chars
408
+ height: 200,
409
+ measure: fixedMeasure,
410
+ multiline: true,
411
+ wordWrap: true,
412
+ maxLines: 1,
413
+ });
414
+ // At most 1 line after truncation.
415
+ expect(result.numLines).toBeLessThanOrEqual(1);
416
+ // The last visible group on line 0 must exist.
417
+ const line0Groups = result.groups.filter((g) => g.lineIndex === 0);
418
+ expect(line0Groups.length).toBeGreaterThan(0);
419
+ });
420
+
421
+ it('appends ellipsis when word-wrapped text overflows maxLines', () => {
422
+ const text = 'word one word two word three word four';
423
+ const result = doLayout({
424
+ text,
425
+ formatRanges: [createTextFormatRange({ size: 16 }, 0, text.length)],
426
+ width: 100,
427
+ height: 200,
428
+ measure: fixedMeasure,
429
+ multiline: true,
430
+ wordWrap: true,
431
+ maxLines: 2,
432
+ });
433
+ expect(result.numLines).toBeLessThanOrEqual(2);
434
+ // There must be at least one group (ellipsis or text) on the last line.
435
+ const lastGroups = result.groups.filter((g) => g.lineIndex === result.numLines - 1);
436
+ expect(lastGroups.length).toBeGreaterThan(0);
437
+ });
438
+
439
+ it('truncates a single long word that straddles the maxLines boundary across both truncation paths', () => {
440
+ // A short word "go " wraps via the main-loop path onto line 0, then a single
441
+ // unbroken long word is split by breakLongWord across the remaining lines.
442
+ // With maxLines=2, the long word crosses the line-2 boundary, so the
443
+ // main-loop truncation (after the space-wrap commit) and breakLongWord's own
444
+ // checkTruncation are both reachable in one layout.
445
+ const text = 'go abcdefghijklmnopqrstuvwxyz';
446
+ const result = doLayout({
447
+ text,
448
+ formatRanges: [createTextFormatRange({ size: 16 }, 0, text.length)],
449
+ width: 60, // ~5-6 chars/line → long word spans several broken segments
450
+ height: 200,
451
+ measure: fixedMeasure,
452
+ multiline: true,
453
+ wordWrap: true,
454
+ maxLines: 2,
455
+ });
456
+ // Clipped to at most maxLines.
457
+ expect(result.numLines).toBeLessThanOrEqual(2);
458
+ // The last visible line carries a group (the truncated word segment or ellipsis).
459
+ const lastGroups = result.groups.filter((g) => g.lineIndex === result.numLines - 1);
460
+ expect(lastGroups.length).toBeGreaterThan(0);
461
+ expect(getTextLayoutIsTruncated(result, { ...singleRangeParams(text), maxLines: 2 })).toBe(true);
462
+ });
463
+ });
464
+
465
+ describe('computeTextLayout — justify alignment', () => {
466
+ it('shifts group offsets for justified mid-line (non-last) lines', () => {
467
+ const text = 'a b\nc d';
468
+ const result = doLayout({
469
+ text,
470
+ formatRanges: [createTextFormatRange({ size: 16, align: 'justify' }, 0, text.length)],
471
+ width: 100,
472
+ height: 200,
473
+ measure: fixedMeasure,
474
+ multiline: true,
475
+ });
476
+ // Line 0 is not the last line, so it should be justified (groups shifted / wider).
477
+ // Line 1 is the last line, so it should remain left-aligned.
478
+ expect(result.numLines).toBe(2);
479
+ const line0Groups = result.groups.filter((g) => g.lineIndex === 0);
480
+ // The last group on line 0 should be pushed rightward by justify.
481
+ expect(line0Groups.length).toBeGreaterThan(0);
482
+ });
483
+ });
484
+
485
+ describe('computeTextLayout — justify single-format', () => {
486
+ it('expands space character advances on a wrapped mid-paragraph line', () => {
487
+ // "aa bb cc" with fixedMeasure (10px/char) and narrow width forces word-wrap.
488
+ // Line 0 is mid-paragraph → justified. Space chars should get extra advance.
489
+ // This was broken when justification counted group boundaries instead of space chars.
490
+ const text = 'aa bb cc';
491
+ const result = doLayout({
492
+ text,
493
+ formatRanges: [createTextFormatRange({ size: 16, align: 'justify' }, 0, text.length)],
494
+ width: 80,
495
+ height: 200,
496
+ measure: fixedMeasure,
497
+ multiline: true,
498
+ wordWrap: true,
499
+ });
500
+ expect(result.numLines).toBeGreaterThanOrEqual(2);
501
+ const line0Groups = result.groups.filter((g) => g.lineIndex === 0);
502
+ // Verify at least one group contains a space char whose advance exceeds the
503
+ // natural 10px width — proof that interWord justification distributed space.
504
+ let foundExpandedSpace = false;
505
+ for (const g of line0Groups) {
506
+ for (let ci = 0; ci < g.positions.length; ci++) {
507
+ if (text.charCodeAt(g.startIndex + ci) === 0x20 && g.positions[ci] > 10) {
508
+ foundExpandedSpace = true;
509
+ }
510
+ }
511
+ }
512
+ expect(foundExpandedSpace).toBe(true);
513
+ });
514
+ });
515
+
516
+ describe('computeTextLayout — kerning flag', () => {
517
+ it('skips pair-measurement when kerning is false', () => {
518
+ // With a measure that returns 5 per char, pair "ab" should return 10.
519
+ // With kerning=true: advance of 'a' = measure("ab") - measure("b") = 10 - 5 = 5.
520
+ // With kerning=false: advance of 'a' = measure("a") = 5.
521
+ // Both give the same result here (monospace), so we verify no throw.
522
+ const text = 'ab';
523
+ const resultWith = doLayout(
524
+ singleRangeParams(text, 1000, {
525
+ formatRanges: [createTextFormatRange({ size: 16, kerning: true }, 0, text.length)],
526
+ }),
527
+ );
528
+ const resultWithout = doLayout(
529
+ singleRangeParams(text, 1000, {
530
+ formatRanges: [createTextFormatRange({ size: 16, kerning: false }, 0, text.length)],
531
+ }),
532
+ );
533
+ expect(resultWith.groups[0].positions).toHaveLength(2);
534
+ expect(resultWithout.groups[0].positions).toHaveLength(2);
535
+ });
536
+ });
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // Golden-file / conformance tests — lock in output stability for key paths.
540
+ // These tests compare actual offsetX values to ensure the alignment algorithms
541
+ // produce the expected pixel-exact positions for a fixed-width measure function
542
+ // (10px/char) and a known container width.
543
+ // ---------------------------------------------------------------------------
544
+
545
+ describe('computeTextLayout — maxLines truncation', () => {
546
+ it('clips to maxLines and appends truncation character', () => {
547
+ const text = 'line one\nline two\nline three';
548
+ const result = doLayout({
549
+ text,
550
+ formatRanges: [createTextFormatRange({ size: 16 }, 0, text.length)],
551
+ width: 300,
552
+ height: 200,
553
+ measure: fixedMeasure,
554
+ multiline: true,
555
+ maxLines: 2,
556
+ });
557
+ // Should have at most 2 lines.
558
+ expect(result.numLines).toBeLessThanOrEqual(2);
559
+ // Should contain an ellipsis group on the last visible line.
560
+ const lastLineGroups = result.groups.filter((g) => g.lineIndex === result.numLines - 1);
561
+ expect(lastLineGroups.length).toBeGreaterThan(0);
562
+ });
563
+
564
+ it('getTextLayoutIsTruncated returns true when maxLines clips the layout', () => {
565
+ const text = 'line one\nline two\nline three';
566
+ const params: TextLayoutParams = {
567
+ text,
568
+ formatRanges: [createTextFormatRange({ size: 16 }, 0, text.length)],
569
+ width: 300,
570
+ height: 200,
571
+ measure: fixedMeasure,
572
+ multiline: true,
573
+ maxLines: 2,
574
+ };
575
+ const result = doLayout(params);
576
+ expect(getTextLayoutIsTruncated(result, params)).toBe(true);
577
+ });
578
+
579
+ it('getTextLayoutIsTruncated returns false when maxLines is unlimited', () => {
580
+ const text = 'one line';
581
+ const params: TextLayoutParams = singleRangeParams(text);
582
+ const result = doLayout(params);
583
+ expect(getTextLayoutIsTruncated(result, params)).toBe(false);
584
+ });
585
+ });
586
+
587
+ describe('computeTextLayout — start/end alignment', () => {
588
+ it('treats start as left in ltr direction', () => {
589
+ const text = 'hi';
590
+ const ltrResult = doLayout({
591
+ text,
592
+ formatRanges: [createTextFormatRange({ size: 16, align: 'start' }, 0, text.length)],
593
+ width: 100,
594
+ height: 100,
595
+ measure: fixedMeasure,
596
+ direction: 'LeftToRight',
597
+ });
598
+ expect(ltrResult.groups[0].offsetX).toBe(TEXT_LAYOUT_GUTTER); // left-aligned = GUTTER
599
+ });
600
+
601
+ it('treats end as right in ltr direction', () => {
602
+ const text = 'hi'; // 20px wide
603
+ const ltrResult = doLayout({
604
+ text,
605
+ formatRanges: [createTextFormatRange({ size: 16, align: 'end' }, 0, text.length)],
606
+ width: 100,
607
+ height: 100,
608
+ measure: fixedMeasure,
609
+ direction: 'LeftToRight',
610
+ });
611
+ // Right-aligned: shift = 100 - 20 - 2*2 = 76, so offsetX = 2 + 76 = 78
612
+ expect(ltrResult.groups[0].offsetX).toBeGreaterThan(TEXT_LAYOUT_GUTTER);
613
+ });
614
+
615
+ it('treats start as right in rtl direction', () => {
616
+ const text = 'hi';
617
+ const rtlResult = doLayout({
618
+ text,
619
+ formatRanges: [createTextFormatRange({ size: 16, align: 'start' }, 0, text.length)],
620
+ width: 100,
621
+ height: 100,
622
+ measure: fixedMeasure,
623
+ direction: 'RightToLeft',
624
+ });
625
+ // RTL start = right alignment, so offsetX should be > GUTTER.
626
+ expect(rtlResult.groups[0].offsetX).toBeGreaterThan(TEXT_LAYOUT_GUTTER);
627
+ });
628
+ });
629
+
630
+ describe('createTextLayoutResult', () => {
631
+ it('returns default zero values', () => {
632
+ const result = createTextLayoutResult();
633
+ expect(result.groups).toHaveLength(0);
634
+ expect(result.lineAscents).toHaveLength(0);
635
+ expect(result.lineDescents).toHaveLength(0);
636
+ expect(result.lineHeights).toHaveLength(0);
637
+ expect(result.lineLeadings).toHaveLength(0);
638
+ expect(result.lineWidths).toHaveLength(0);
639
+ expect(result.numLines).toBe(0);
640
+ expect(result.textHeight).toBe(0);
641
+ expect(result.textWidth).toBe(0);
642
+ });
643
+
644
+ it('returns a new object each call', () => {
645
+ expect(createTextLayoutResult()).not.toBe(createTextLayoutResult());
646
+ });
647
+ });
648
+
649
+ describe('getTextLayoutIsTruncated', () => {
650
+ it('returns false when maxLines is -1', () => {
651
+ const params = singleRangeParams('hello');
652
+ const result = doLayout(params);
653
+ expect(getTextLayoutIsTruncated(result, params)).toBe(false);
654
+ });
655
+ });
656
+
657
+ describe('TEXT_LAYOUT_GUTTER', () => {
658
+ it('is a positive number', () => {
659
+ expect(TEXT_LAYOUT_GUTTER).toBeGreaterThan(0);
660
+ });
661
+ });
@@ -0,0 +1,30 @@
1
+ import { createTextLayoutGroup } from './textLayoutGroup';
2
+
3
+ describe('createTextLayoutGroup', () => {
4
+ it('initializes with zero metrics', () => {
5
+ const fmt = { size: 16 };
6
+ const group = createTextLayoutGroup(fmt, 0, 5);
7
+ expect(group.ascent).toBe(0);
8
+ expect(group.descent).toBe(0);
9
+ expect(group.height).toBe(0);
10
+ expect(group.leading).toBe(0);
11
+ expect(group.lineIndex).toBe(0);
12
+ expect(group.offsetX).toBe(0);
13
+ expect(group.offsetY).toBe(0);
14
+ expect(group.width).toBe(0);
15
+ expect(group.positions).toEqual([]);
16
+ });
17
+
18
+ it('stores the provided format, startIndex, and endIndex', () => {
19
+ const fmt = { size: 24 };
20
+ const group = createTextLayoutGroup(fmt, 3, 10);
21
+ expect(group.format).toBe(fmt);
22
+ expect(group.startIndex).toBe(3);
23
+ expect(group.endIndex).toBe(10);
24
+ });
25
+
26
+ it('returns a new object each call', () => {
27
+ const fmt = {};
28
+ expect(createTextLayoutGroup(fmt, 0, 1)).not.toBe(createTextLayoutGroup(fmt, 0, 1));
29
+ });
30
+ });