@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,3 @@
1
+ import type { TextFormat, TextLayoutGroup } from '@flighthq/types';
2
+ export declare function createTextLayoutGroup(format: TextFormat, startIndex: number, endIndex: number): TextLayoutGroup;
3
+ //# sourceMappingURL=textLayoutGroup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textLayoutGroup.d.ts","sourceRoot":"","sources":["../src/textLayoutGroup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEnE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,eAAe,CAe/G"}
@@ -0,0 +1,17 @@
1
+ export function createTextLayoutGroup(format, startIndex, endIndex) {
2
+ return {
3
+ ascent: 0,
4
+ descent: 0,
5
+ endIndex,
6
+ format,
7
+ height: 0,
8
+ leading: 0,
9
+ lineIndex: 0,
10
+ offsetX: 0,
11
+ offsetY: 0,
12
+ positions: [],
13
+ startIndex,
14
+ width: 0,
15
+ };
16
+ }
17
+ //# sourceMappingURL=textLayoutGroup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textLayoutGroup.js","sourceRoot":"","sources":["../src/textLayoutGroup.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,qBAAqB,CAAC,MAAkB,EAAE,UAAkB,EAAE,QAAgB;IAC5F,OAAO;QACL,MAAM,EAAE,CAAC;QACT,OAAO,EAAE,CAAC;QACV,QAAQ;QACR,MAAM;QACN,MAAM,EAAE,CAAC;QACT,OAAO,EAAE,CAAC;QACV,SAAS,EAAE,CAAC;QACZ,OAAO,EAAE,CAAC;QACV,OAAO,EAAE,CAAC;QACV,SAAS,EAAE,EAAE;QACb,UAAU;QACV,KAAK,EAAE,CAAC;KACT,CAAC;AACJ,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { TextMeasureFunction } from '@flighthq/types';
2
+ export declare function getTextLayoutMeasureProvider(): TextMeasureFunction | null;
3
+ export declare function setTextLayoutMeasureProvider(measure: TextMeasureFunction | null): void;
4
+ //# sourceMappingURL=textLayoutMeasure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textLayoutMeasure.d.ts","sourceRoot":"","sources":["../src/textLayoutMeasure.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAS3D,wBAAgB,4BAA4B,IAAI,mBAAmB,GAAG,IAAI,CAIzE;AAKD,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,GAAG,IAAI,CAEtF"}
@@ -0,0 +1,23 @@
1
+ import { getTextShaperBackend, measureText } from '@flighthq/textshaper';
2
+ // Resolves the text-measurement provider text-layout uses to turn characters into advances. Shaping
3
+ // is now owned by the @flighthq/textshaper seam: when a shaper backend is registered (the canvas
4
+ // backend from @flighthq/textshaper-canvas, or a future HarfBuzz one), this returns measureText,
5
+ // which is itself a TextMeasureFunction. An explicitly set provider (setTextLayoutMeasureProvider) still
6
+ // takes precedence — it is the direct-injection escape hatch for tests and bespoke hosts. Null when
7
+ // neither exists, exactly as before, so ensureRichTextLayout leaves the layout stale until shaping is
8
+ // available.
9
+ export function getTextLayoutMeasureProvider() {
10
+ if (_measureProvider !== null)
11
+ return _measureProvider;
12
+ if (getTextShaperBackend() !== null)
13
+ return measureText;
14
+ return null;
15
+ }
16
+ // Installs an explicit measure provider, bypassing the shaper seam. Pass null to clear it and fall
17
+ // back to the registered shaper backend. Prefer setTextShaperBackend for normal setup; this remains
18
+ // for direct injection (tests, a host wiring its own measure without a full TextShaperBackend).
19
+ export function setTextLayoutMeasureProvider(measure) {
20
+ _measureProvider = measure;
21
+ }
22
+ let _measureProvider = null;
23
+ //# sourceMappingURL=textLayoutMeasure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textLayoutMeasure.js","sourceRoot":"","sources":["../src/textLayoutMeasure.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAGzE,oGAAoG;AACpG,iGAAiG;AACjG,iGAAiG;AACjG,yGAAyG;AACzG,oGAAoG;AACpG,sGAAsG;AACtG,aAAa;AACb,MAAM,UAAU,4BAA4B;IAC1C,IAAI,gBAAgB,KAAK,IAAI;QAAE,OAAO,gBAAgB,CAAC;IACvD,IAAI,oBAAoB,EAAE,KAAK,IAAI;QAAE,OAAO,WAAW,CAAC;IACxD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,mGAAmG;AACnG,oGAAoG;AACpG,gGAAgG;AAChG,MAAM,UAAU,4BAA4B,CAAC,OAAmC;IAC9E,gBAAgB,GAAG,OAAO,CAAC;AAC7B,CAAC;AAED,IAAI,gBAAgB,GAA+B,IAAI,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { TextLabelRuntime, TextLayoutResult } from '@flighthq/types';
2
+ export declare function clearTextLayoutResult(runtime: TextLabelRuntime): void;
3
+ export declare function getTextLayoutResult(runtime: TextLabelRuntime): TextLayoutResult;
4
+ //# sourceMappingURL=textLayoutRuntime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textLayoutRuntime.d.ts","sourceRoot":"","sources":["../src/textLayoutRuntime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAI1E,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAErE;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,gBAAgB,GAAG,gBAAgB,CAK/E"}
@@ -0,0 +1,11 @@
1
+ import { createTextLayoutResult } from './textLayout';
2
+ export function clearTextLayoutResult(runtime) {
3
+ runtime.textLayout = null;
4
+ }
5
+ export function getTextLayoutResult(runtime) {
6
+ if (runtime.textLayout === null) {
7
+ runtime.textLayout = createTextLayoutResult();
8
+ }
9
+ return runtime.textLayout;
10
+ }
11
+ //# sourceMappingURL=textLayoutRuntime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textLayoutRuntime.js","sourceRoot":"","sources":["../src/textLayoutRuntime.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAEtD,MAAM,UAAU,qBAAqB,CAAC,OAAyB;IAC7D,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;AAC5B,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,OAAyB;IAC3D,IAAI,OAAO,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;QAChC,OAAO,CAAC,UAAU,GAAG,sBAAsB,EAAE,CAAC;IAChD,CAAC;IACD,OAAO,OAAO,CAAC,UAAU,CAAC;AAC5B,CAAC"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Binary search for the first break index at or after `startIndex`.
3
+ * Returns -1 when no such break exists. The `lineBreaks` array must be sorted
4
+ * in ascending order (as produced by `getTextLineBreaks`).
5
+ */
6
+ export declare function getTextLineBreakIndex(lineBreaks: readonly number[], startIndex?: number): number;
7
+ export declare function getTextLineBreaks(out: number[], text: string): void;
8
+ //# sourceMappingURL=textLineBreaks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textLineBreaks.d.ts","sourceRoot":"","sources":["../src/textLineBreaks.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,EAAE,UAAU,SAAI,GAAG,MAAM,CAe3F;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAanE"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Binary search for the first break index at or after `startIndex`.
3
+ * Returns -1 when no such break exists. The `lineBreaks` array must be sorted
4
+ * in ascending order (as produced by `getTextLineBreaks`).
5
+ */
6
+ export function getTextLineBreakIndex(lineBreaks, startIndex = 0) {
7
+ if (lineBreaks.length === 0)
8
+ return -1;
9
+ let lo = 0;
10
+ let hi = lineBreaks.length - 1;
11
+ let result = -1;
12
+ while (lo <= hi) {
13
+ const mid = (lo + hi) >>> 1;
14
+ if (lineBreaks[mid] >= startIndex) {
15
+ result = lineBreaks[mid];
16
+ hi = mid - 1;
17
+ }
18
+ else {
19
+ lo = mid + 1;
20
+ }
21
+ }
22
+ return result;
23
+ }
24
+ export function getTextLineBreaks(out, text) {
25
+ out.length = 0;
26
+ let index = -1;
27
+ while (index < text.length) {
28
+ const lf = text.indexOf('\n', index + 1);
29
+ const cr = text.indexOf('\r', index + 1);
30
+ if (lf === -1 && cr === -1)
31
+ break;
32
+ index = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf);
33
+ out.push(index);
34
+ }
35
+ }
36
+ //# sourceMappingURL=textLineBreaks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textLineBreaks.js","sourceRoot":"","sources":["../src/textLineBreaks.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,UAA6B,EAAE,UAAU,GAAG,CAAC;IACjF,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC,CAAC;IACvC,IAAI,EAAE,GAAG,CAAC,CAAC;IACX,IAAI,EAAE,GAAG,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC;IAC/B,IAAI,MAAM,GAAG,CAAC,CAAC,CAAC;IAChB,OAAO,EAAE,IAAI,EAAE,EAAE,CAAC;QAChB,MAAM,GAAG,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC;QAC5B,IAAI,UAAU,CAAC,GAAG,CAAC,IAAI,UAAU,EAAE,CAAC;YAClC,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC;YACzB,EAAE,GAAG,GAAG,GAAG,CAAC,CAAC;QACf,CAAC;aAAM,CAAC;YACN,EAAE,GAAG,GAAG,GAAG,CAAC,CAAC;QACf,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,GAAa,EAAE,IAAY;IAC3D,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,KAAK,GAAG,CAAC,CAAC,CAAC;IAEf,OAAO,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;QAC3B,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;QACzC,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;QAEzC,IAAI,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YAAE,MAAM;QAElC,KAAK,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QAC3D,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAClB,CAAC;AACH,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { TextLayoutResult, TextMetrics } from '@flighthq/types';
2
+ export declare function createTextMetrics(): TextMetrics;
3
+ export declare function getTextMetrics(out: TextMetrics, layout: Readonly<TextLayoutResult>): void;
4
+ //# sourceMappingURL=textMetrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textMetrics.d.ts","sourceRoot":"","sources":["../src/textMetrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAErE,wBAAgB,iBAAiB,IAAI,WAAW,CAE/C;AAKD,wBAAgB,cAAc,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,QAAQ,CAAC,gBAAgB,CAAC,GAAG,IAAI,CAIzF"}
@@ -0,0 +1,12 @@
1
+ export function createTextMetrics() {
2
+ return { height: 0, numLines: 0, width: 0 };
3
+ }
4
+ // Fills `out` with the measured content size from a computed layout (the glyph extent, ceil'd to whole
5
+ // pixels to match computeRichTextTextWidth/Height). Pure read — call after the layout is current, e.g. via
6
+ // ensureRichTextLayout.
7
+ export function getTextMetrics(out, layout) {
8
+ out.width = Math.ceil(layout.textWidth);
9
+ out.height = Math.ceil(layout.textHeight);
10
+ out.numLines = layout.numLines;
11
+ }
12
+ //# sourceMappingURL=textMetrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textMetrics.js","sourceRoot":"","sources":["../src/textMetrics.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,iBAAiB;IAC/B,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;AAC9C,CAAC;AAED,uGAAuG;AACvG,2GAA2G;AAC3G,wBAAwB;AACxB,MAAM,UAAU,cAAc,CAAC,GAAgB,EAAE,MAAkC;IACjF,GAAG,CAAC,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACxC,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAC1C,GAAG,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;AACjC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@flighthq/textlayout",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "dependencies": {
30
+ "@flighthq/textshaper": "0.1.0",
31
+ "@flighthq/types": "0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.3.0"
35
+ },
36
+ "description": "Renderer-agnostic text layout engine: measures and positions glyphs into layout groups",
37
+ "sideEffects": false
38
+ }
@@ -0,0 +1,201 @@
1
+ import type { RichTextData, RichTextRuntime } from '@flighthq/types';
2
+
3
+ import {
4
+ clearRichTextContent,
5
+ computeRichTextContent,
6
+ createRichTextContent,
7
+ getRichTextContent,
8
+ } from './richTextContent';
9
+
10
+ function createData(data: Partial<RichTextData> = {}): RichTextData {
11
+ return {
12
+ autoSize: 'none',
13
+ background: false,
14
+ backgroundColor: 0xffffff,
15
+ border: false,
16
+ borderColor: 0,
17
+ condenseWhite: false,
18
+ defaultTextFormat: {},
19
+ height: 100,
20
+ htmlText: '',
21
+ maxChars: -1,
22
+ mouseWheelEnabled: true,
23
+ multiline: true,
24
+ scrollH: 0,
25
+ scrollV: 1,
26
+ selectable: true,
27
+ styleSheet: null,
28
+ text: '',
29
+ textColor: 0,
30
+ textFormat: {},
31
+ textFormatRanges: [],
32
+ width: 100,
33
+ wordWrap: false,
34
+ ...data,
35
+ };
36
+ }
37
+
38
+ function createRuntime(): RichTextRuntime {
39
+ return { richTextContent: null, textLayout: null } as RichTextRuntime;
40
+ }
41
+
42
+ describe('clearRichTextContent', () => {
43
+ it('clears the attached content', () => {
44
+ const runtime = createRuntime();
45
+ getRichTextContent(runtime);
46
+ clearRichTextContent(runtime);
47
+ expect(runtime.richTextContent).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe('computeRichTextContent', () => {
52
+ it('uses plain text when htmlText is empty', () => {
53
+ const content = createRichTextContent();
54
+ computeRichTextContent(content, createData({ text: 'hello', textColor: 0x336699 }));
55
+ expect(content.text).toBe('hello');
56
+ expect(content.formatRanges).toHaveLength(1);
57
+ expect(content.formatRanges[0].format.color).toBe(0x336699);
58
+ });
59
+
60
+ it('prefers htmlText over text', () => {
61
+ const content = createRichTextContent();
62
+ computeRichTextContent(content, createData({ htmlText: '<b>rich</b>', text: 'plain' }));
63
+ expect(content.text).toBe('rich');
64
+ expect(content.formatRanges[0].format.bold).toBe(true);
65
+ });
66
+
67
+ it('decodes entities and br tags', () => {
68
+ const content = createRichTextContent();
69
+ computeRichTextContent(content, createData({ htmlText: 'A&amp;B<br>C' }));
70
+ expect(content.text).toBe('A&B\nC');
71
+ });
72
+
73
+ it('applies inline font tags', () => {
74
+ const content = createRichTextContent();
75
+ computeRichTextContent(content, createData({ htmlText: '<font face="Arial" color="#ff00aa" size="18">Hi</font>' }));
76
+ expect(content.formatRanges[0].format.font).toBe('Arial');
77
+ expect(content.formatRanges[0].format.color).toBe(0xff00aa);
78
+ expect(content.formatRanges[0].format.size).toBe(18);
79
+ });
80
+
81
+ it('applies nested text style tags', () => {
82
+ const content = createRichTextContent();
83
+ computeRichTextContent(content, createData({ htmlText: '<b>bold <i>both</i></b> normal' }));
84
+ expect(content.text).toBe('bold both normal');
85
+ expect(content.formatRanges[0].format.bold).toBe(true);
86
+ expect(content.formatRanges[1].format.bold).toBe(true);
87
+ expect(content.formatRanges[1].format.italic).toBe(true);
88
+ expect(content.formatRanges[2].format.bold).toBeUndefined();
89
+ });
90
+
91
+ it('applies paragraph and textformat attributes', () => {
92
+ const content = createRichTextContent();
93
+ computeRichTextContent(
94
+ content,
95
+ createData({ htmlText: '<p align="center"><textformat leftmargin="4" leading="3">Hi</textformat></p>' }),
96
+ );
97
+ expect(content.text).toBe('Hi\n');
98
+ expect(content.formatRanges[0].format.align).toBe('center');
99
+ expect(content.formatRanges[0].format.leftMargin).toBe(4);
100
+ expect(content.formatRanges[0].format.leading).toBe(3);
101
+ });
102
+
103
+ it('applies anchor link attributes', () => {
104
+ const content = createRichTextContent();
105
+ computeRichTextContent(content, createData({ htmlText: '<a href="https://example.com" target="_blank">Link</a>' }));
106
+ expect(content.text).toBe('Link');
107
+ expect(content.formatRanges[0].format.url).toBe('https://example.com');
108
+ expect(content.formatRanges[0].format.target).toBe('_blank');
109
+ });
110
+
111
+ it('applies style sheet selectors and inline CSS', () => {
112
+ const content = createRichTextContent();
113
+ computeRichTextContent(
114
+ content,
115
+ createData({
116
+ htmlText: '<p class="callout" style="font-weight:bold;text-decoration:underline">Styled</p>',
117
+ styleSheet: {
118
+ '.callout': { color: 0x445566, size: 20 },
119
+ p: { align: 'right' },
120
+ },
121
+ }),
122
+ );
123
+ expect(content.formatRanges[0].format.align).toBe('right');
124
+ expect(content.formatRanges[0].format.bold).toBe(true);
125
+ expect(content.formatRanges[0].format.color).toBe(0x445566);
126
+ expect(content.formatRanges[0].format.size).toBe(20);
127
+ expect(content.formatRanges[0].format.underline).toBe(true);
128
+ });
129
+
130
+ it('condenses whitespace', () => {
131
+ const content = createRichTextContent();
132
+ computeRichTextContent(content, createData({ condenseWhite: true, htmlText: ' A \n\t B ' }));
133
+ expect(content.text).toBe('A B ');
134
+ });
135
+
136
+ it('honors maxChars', () => {
137
+ const content = createRichTextContent();
138
+ computeRichTextContent(content, createData({ htmlText: '<b>Hello</b> world', maxChars: 7 }));
139
+ expect(content.text).toBe('Hello w');
140
+ expect(content.formatRanges[content.formatRanges.length - 1].end).toBe(7);
141
+ });
142
+
143
+ it('masks every character when a password character is supplied', () => {
144
+ const content = createRichTextContent();
145
+ computeRichTextContent(content, createData({ htmlText: '<b>ignored</b>', text: 'secret' }), '*');
146
+ expect(content.text).toBe('******');
147
+ expect(content.formatRanges[0].format.bold).toBeUndefined();
148
+ });
149
+
150
+ it('falls back to the bullet mask when the password character is empty', () => {
151
+ const content = createRichTextContent();
152
+ computeRichTextContent(content, createData({ text: 'ab' }), '');
153
+ expect(content.text).toBe('••');
154
+ });
155
+
156
+ it('applies serialized text format ranges over plain text', () => {
157
+ const content = createRichTextContent();
158
+ computeRichTextContent(
159
+ content,
160
+ createData({
161
+ text: 'hello',
162
+ textFormatRanges: [{ start: 1, end: 4, format: { italic: true } }],
163
+ }),
164
+ );
165
+ expect(content.formatRanges.map((range) => [range.start, range.end, range.format.italic])).toEqual([
166
+ [0, 1, undefined],
167
+ [1, 4, true],
168
+ [4, 5, undefined],
169
+ ]);
170
+ });
171
+
172
+ it('merges serialized text format ranges over htmlText', () => {
173
+ const content = createRichTextContent();
174
+ computeRichTextContent(
175
+ content,
176
+ createData({
177
+ htmlText: '<b>hello</b>',
178
+ textFormatRanges: [{ start: 1, end: 4, format: { color: 0xff0000 } }],
179
+ }),
180
+ );
181
+ expect(content.formatRanges[1].format.bold).toBe(true);
182
+ expect(content.formatRanges[1].format.color).toBe(0xff0000);
183
+ });
184
+ });
185
+
186
+ describe('createRichTextContent', () => {
187
+ it('creates empty text and ranges', () => {
188
+ const content = createRichTextContent();
189
+ expect(content.text).toBe('');
190
+ expect(content.formatRanges).toEqual([]);
191
+ });
192
+ });
193
+
194
+ describe('getRichTextContent', () => {
195
+ it('attaches reusable content to runtime state', () => {
196
+ const runtime = createRuntime();
197
+ const content = getRichTextContent(runtime);
198
+ expect(runtime.richTextContent).toBe(content);
199
+ expect(getRichTextContent(runtime)).toBe(content);
200
+ });
201
+ });
@@ -0,0 +1,124 @@
1
+ import type { RichTextData, TextLayoutResult } from '@flighthq/types';
2
+
3
+ import {
4
+ computeRichTextBottomScrollV,
5
+ computeRichTextLineCount,
6
+ computeRichTextMaxScrollH,
7
+ computeRichTextMaxScrollV,
8
+ computeRichTextTextHeight,
9
+ computeRichTextTextWidth,
10
+ getRichTextScrollYOffset,
11
+ } from './richTextMetrics';
12
+
13
+ function createData(data: Partial<RichTextData> = {}): RichTextData {
14
+ return {
15
+ autoSize: 'none',
16
+ background: false,
17
+ backgroundColor: 0xffffff,
18
+ border: false,
19
+ borderColor: 0,
20
+ condenseWhite: false,
21
+ defaultTextFormat: {},
22
+ height: 100,
23
+ htmlText: '',
24
+ maxChars: -1,
25
+ mouseWheelEnabled: true,
26
+ multiline: true,
27
+ scrollH: 0,
28
+ scrollV: 1,
29
+ selectable: true,
30
+ styleSheet: null,
31
+ text: '',
32
+ textColor: 0,
33
+ textFormat: {},
34
+ textFormatRanges: [],
35
+ width: 200,
36
+ wordWrap: false,
37
+ ...data,
38
+ };
39
+ }
40
+
41
+ function createLayout(layout: Partial<TextLayoutResult> = {}): TextLayoutResult {
42
+ return {
43
+ groups: [],
44
+ lineAscents: [],
45
+ lineDescents: [],
46
+ lineHeights: [],
47
+ lineLeadings: [],
48
+ lineWidths: [],
49
+ numLines: 1,
50
+ textHeight: 20,
51
+ textWidth: 50,
52
+ ...layout,
53
+ };
54
+ }
55
+
56
+ describe('computeRichTextBottomScrollV', () => {
57
+ it('returns the last visible 1-based line', () => {
58
+ const data = createData({ height: 34, scrollV: 2 });
59
+ const layout = createLayout({ lineHeights: [10, 10, 10, 10], numLines: 4 });
60
+ expect(computeRichTextBottomScrollV(data, layout)).toBe(4);
61
+ });
62
+
63
+ it('clamps to the total line count', () => {
64
+ const data = createData({ height: 80, scrollV: 2 });
65
+ const layout = createLayout({ lineHeights: [10, 10, 10], numLines: 3 });
66
+ expect(computeRichTextBottomScrollV(data, layout)).toBe(3);
67
+ });
68
+ });
69
+
70
+ describe('computeRichTextLineCount', () => {
71
+ it('returns the layout line count', () => {
72
+ expect(computeRichTextLineCount(createLayout({ numLines: 3 }))).toBe(3);
73
+ });
74
+ });
75
+
76
+ describe('computeRichTextMaxScrollH', () => {
77
+ it('returns horizontal overflow beyond the visible field width', () => {
78
+ const data = createData({ width: 54 });
79
+ const layout = createLayout({ textWidth: 80 });
80
+ expect(computeRichTextMaxScrollH(data, layout)).toBe(30);
81
+ });
82
+
83
+ it('returns zero when text fits horizontally', () => {
84
+ expect(computeRichTextMaxScrollH(createData({ width: 100 }), createLayout({ textWidth: 50 }))).toBe(0);
85
+ });
86
+ });
87
+
88
+ describe('computeRichTextMaxScrollV', () => {
89
+ it('returns the first scrollV where the final line is visible', () => {
90
+ const data = createData({ height: 34 });
91
+ const layout = createLayout({ lineHeights: [10, 10, 10, 10], numLines: 4 });
92
+ expect(computeRichTextMaxScrollV(data, layout)).toBe(2);
93
+ });
94
+
95
+ it('returns one for a single line', () => {
96
+ expect(computeRichTextMaxScrollV(createData(), createLayout({ numLines: 1 }))).toBe(1);
97
+ });
98
+ });
99
+
100
+ describe('computeRichTextTextHeight', () => {
101
+ it('returns the ceil text height', () => {
102
+ expect(computeRichTextTextHeight(createLayout({ textHeight: 12.2 }))).toBe(13);
103
+ });
104
+ });
105
+
106
+ describe('computeRichTextTextWidth', () => {
107
+ it('returns the ceil text width', () => {
108
+ expect(computeRichTextTextWidth(createLayout({ textWidth: 21.1 }))).toBe(22);
109
+ });
110
+ });
111
+
112
+ describe('getRichTextScrollYOffset', () => {
113
+ it('returns 0 when firstVisibleLine is 0', () => {
114
+ expect(getRichTextScrollYOffset([10, 12, 14], 0)).toBe(0);
115
+ });
116
+
117
+ it('returns the sum of the first N line heights', () => {
118
+ expect(getRichTextScrollYOffset([10, 12, 14], 2)).toBe(22);
119
+ });
120
+
121
+ it('clamps to the available line count', () => {
122
+ expect(getRichTextScrollYOffset([10, 12], 5)).toBe(22);
123
+ });
124
+ });