@flighthq/textshaper 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 (45) hide show
  1. package/dist/_textShaperHooks.d.ts +4 -0
  2. package/dist/_textShaperHooks.d.ts.map +1 -0
  3. package/dist/_textShaperHooks.js +5 -0
  4. package/dist/_textShaperHooks.js.map +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +8 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/textShaper.d.ts +5 -0
  10. package/dist/textShaper.d.ts.map +1 -0
  11. package/dist/textShaper.js +28 -0
  12. package/dist/textShaper.js.map +1 -0
  13. package/dist/textShaperCache.d.ts +9 -0
  14. package/dist/textShaperCache.d.ts.map +1 -0
  15. package/dist/textShaperCache.js +54 -0
  16. package/dist/textShaperCache.js.map +1 -0
  17. package/dist/textShaperCluster.d.ts +5 -0
  18. package/dist/textShaperCluster.d.ts.map +1 -0
  19. package/dist/textShaperCluster.js +73 -0
  20. package/dist/textShaperCluster.js.map +1 -0
  21. package/dist/textShaperItemize.d.ts +4 -0
  22. package/dist/textShaperItemize.d.ts.map +1 -0
  23. package/dist/textShaperItemize.js +161 -0
  24. package/dist/textShaperItemize.js.map +1 -0
  25. package/dist/textShaperPool.d.ts +4 -0
  26. package/dist/textShaperPool.d.ts.map +1 -0
  27. package/dist/textShaperPool.js +24 -0
  28. package/dist/textShaperPool.js.map +1 -0
  29. package/dist/textShaperRun.d.ts +15 -0
  30. package/dist/textShaperRun.d.ts.map +1 -0
  31. package/dist/textShaperRun.js +123 -0
  32. package/dist/textShaperRun.js.map +1 -0
  33. package/dist/textShaperSignals.d.ts +5 -0
  34. package/dist/textShaperSignals.d.ts.map +1 -0
  35. package/dist/textShaperSignals.js +27 -0
  36. package/dist/textShaperSignals.js.map +1 -0
  37. package/package.json +38 -0
  38. package/src/_textShaperHooks.test.ts +69 -0
  39. package/src/textShaper.test.ts +42 -0
  40. package/src/textShaperCache.test.ts +161 -0
  41. package/src/textShaperCluster.test.ts +113 -0
  42. package/src/textShaperItemize.test.ts +114 -0
  43. package/src/textShaperPool.test.ts +36 -0
  44. package/src/textShaperRun.test.ts +363 -0
  45. package/src/textShaperSignals.test.ts +84 -0
@@ -0,0 +1,363 @@
1
+ import type { FontMetrics, GlyphExtents, ShapedRun, TextShaperBackend } from '@flighthq/types';
2
+
3
+ import { setTextShaperBackend } from './textShaper';
4
+ import {
5
+ clearShapedRun,
6
+ createShapedRun,
7
+ getCodePointForGlyph,
8
+ getFontMetrics,
9
+ getFontMetricsInto,
10
+ getFontUnitScale,
11
+ getGlyphExtents,
12
+ getGlyphExtentsBatch,
13
+ getGlyphExtentsInto,
14
+ getGlyphIndexForCodePoint,
15
+ getGlyphName,
16
+ shapeTextRun,
17
+ shapeTextRunInto,
18
+ } from './textShaperRun';
19
+
20
+ const _testGlyphs = [
21
+ { cluster: 0, glyphId: 10, xAdvance: 8, xOffset: 0, yAdvance: 0, yOffset: 0 },
22
+ { cluster: 1, glyphId: 20, xAdvance: 7, xOffset: 0, yAdvance: 0, yOffset: 0 },
23
+ ];
24
+
25
+ const _testRun: ShapedRun = {
26
+ advanceWidth: 15,
27
+ direction: 'LeftToRight',
28
+ font: null,
29
+ glyphCount: 2,
30
+ glyphs: _testGlyphs,
31
+ script: 'Latn',
32
+ };
33
+
34
+ const _testMetrics: FontMetrics = {
35
+ ascent: 10,
36
+ capHeight: 8,
37
+ descent: 3,
38
+ lineGap: 1,
39
+ underlinePosition: -2,
40
+ underlineThickness: 1,
41
+ unitsPerEm: 1000,
42
+ xHeight: 5,
43
+ };
44
+
45
+ const _testExtents: GlyphExtents = { height: 10, width: 6, xBearing: 0, yBearing: -8 };
46
+
47
+ function _makeFullBackend(): TextShaperBackend {
48
+ return {
49
+ // Code point 65 ('A') maps to glyph 10 and back; everything else is unknown.
50
+ getCodePointForGlyph: (id) => (id === 10 ? 65 : -1),
51
+ getFontMetrics: () => ({ ..._testMetrics }),
52
+ getGlyphExtents: (id) => (id === 10 ? { ..._testExtents } : null),
53
+ getGlyphIndexForCodePoint: (cp) => (cp === 65 ? 10 : -1),
54
+ getGlyphName: (id) => (id === 10 ? 'A' : ''),
55
+ measureText: (text) => text.length * 8,
56
+ shapeRun: () => ({ ..._testRun, glyphs: [..._testGlyphs] }),
57
+ };
58
+ }
59
+
60
+ afterEach(() => {
61
+ setTextShaperBackend(null);
62
+ });
63
+
64
+ describe('clearShapedRun', () => {
65
+ it('resets all fields and empties the glyphs array', () => {
66
+ const run = createShapedRun();
67
+ (run as { advanceWidth: number }).advanceWidth = 42;
68
+ (run.glyphs as unknown[]).push(_testGlyphs[0]);
69
+ const returned = clearShapedRun(run);
70
+ expect(run.advanceWidth).toBe(0);
71
+ expect(run.glyphCount).toBe(0);
72
+ expect(run.glyphs).toHaveLength(0);
73
+ expect(run.direction).toBe('LeftToRight');
74
+ expect(run.script).toBe('');
75
+ expect(run.font).toBeNull();
76
+ expect(returned).toBe(run);
77
+ });
78
+
79
+ it('retains the same glyphs array reference after clearing', () => {
80
+ const run = createShapedRun();
81
+ const arr = run.glyphs;
82
+ clearShapedRun(run);
83
+ expect(run.glyphs).toBe(arr);
84
+ });
85
+ });
86
+
87
+ describe('createShapedRun', () => {
88
+ it('returns an empty run with zero advance width', () => {
89
+ const run = createShapedRun();
90
+ expect(run.advanceWidth).toBe(0);
91
+ expect(run.glyphCount).toBe(0);
92
+ expect(run.glyphs).toHaveLength(0);
93
+ expect(run.direction).toBe('LeftToRight');
94
+ expect(run.font).toBeNull();
95
+ expect(run.script).toBe('');
96
+ });
97
+
98
+ it('allocates a new run on each call', () => {
99
+ expect(createShapedRun()).not.toBe(createShapedRun());
100
+ });
101
+ });
102
+
103
+ describe('getCodePointForGlyph', () => {
104
+ it('returns -1 when no backend is set', () => {
105
+ expect(getCodePointForGlyph(10, {})).toBe(-1);
106
+ });
107
+
108
+ it('returns -1 when the backend does not implement getCodePointForGlyph', () => {
109
+ setTextShaperBackend({ measureText: (t) => t.length });
110
+ expect(getCodePointForGlyph(10, {})).toBe(-1);
111
+ });
112
+
113
+ it('delegates to the backend and returns the resolved code point', () => {
114
+ setTextShaperBackend(_makeFullBackend());
115
+ expect(getCodePointForGlyph(10, {})).toBe(65);
116
+ });
117
+
118
+ it('returns -1 for glyph ids the backend cannot reverse-map', () => {
119
+ setTextShaperBackend(_makeFullBackend());
120
+ expect(getCodePointForGlyph(999, {})).toBe(-1);
121
+ });
122
+ });
123
+
124
+ describe('getFontMetrics', () => {
125
+ it('returns null when no backend is set', () => {
126
+ expect(getFontMetrics({})).toBeNull();
127
+ });
128
+
129
+ it('returns null when the backend is advances-only (no getFontMetrics)', () => {
130
+ setTextShaperBackend({ measureText: (t) => t.length });
131
+ expect(getFontMetrics({})).toBeNull();
132
+ });
133
+
134
+ it('delegates to the backend', () => {
135
+ setTextShaperBackend(_makeFullBackend());
136
+ const m = getFontMetrics({ size: 16 });
137
+ expect(m).not.toBeNull();
138
+ expect(m!.ascent).toBe(10);
139
+ expect(m!.unitsPerEm).toBe(1000);
140
+ });
141
+ });
142
+
143
+ describe('getFontMetricsInto', () => {
144
+ it('returns false and does not modify out when no backend is set', () => {
145
+ const out: FontMetrics = { ..._testMetrics, ascent: 99 };
146
+ expect(getFontMetricsInto({}, out)).toBe(false);
147
+ expect(out.ascent).toBe(99);
148
+ });
149
+
150
+ it('writes all fields into out and returns true on success', () => {
151
+ setTextShaperBackend(_makeFullBackend());
152
+ const out = { ..._testMetrics };
153
+ expect(getFontMetricsInto({}, out)).toBe(true);
154
+ expect(out.ascent).toBe(_testMetrics.ascent);
155
+ expect(out.capHeight).toBe(_testMetrics.capHeight);
156
+ });
157
+ });
158
+
159
+ describe('getFontUnitScale', () => {
160
+ it('returns -1 when no backend is set', () => {
161
+ expect(getFontUnitScale({})).toBe(-1);
162
+ });
163
+
164
+ it('returns size / unitsPerEm', () => {
165
+ setTextShaperBackend(_makeFullBackend());
166
+ // default size is 12 per the function; unitsPerEm is 1000.
167
+ expect(getFontUnitScale({})).toBeCloseTo(12 / 1000);
168
+ expect(getFontUnitScale({ size: 20 })).toBeCloseTo(20 / 1000);
169
+ });
170
+ });
171
+
172
+ describe('getGlyphExtents', () => {
173
+ it('returns null when no backend is set', () => {
174
+ expect(getGlyphExtents(10, {})).toBeNull();
175
+ });
176
+
177
+ it('returns null when the backend is advances-only', () => {
178
+ setTextShaperBackend({ measureText: (t) => t.length });
179
+ expect(getGlyphExtents(10, {})).toBeNull();
180
+ });
181
+
182
+ it('delegates to the backend', () => {
183
+ setTextShaperBackend(_makeFullBackend());
184
+ const e = getGlyphExtents(10, {});
185
+ expect(e).not.toBeNull();
186
+ expect(e!.width).toBe(6);
187
+ });
188
+
189
+ it('returns null for unknown glyph ids', () => {
190
+ setTextShaperBackend(_makeFullBackend());
191
+ expect(getGlyphExtents(999, {})).toBeNull();
192
+ });
193
+ });
194
+
195
+ describe('getGlyphExtentsBatch', () => {
196
+ it('returns 0 and writes nothing when no backend is set', () => {
197
+ const out: GlyphExtents[] = [];
198
+ expect(getGlyphExtentsBatch([10, 20], {}, out)).toBe(0);
199
+ });
200
+
201
+ it('returns 0 when the backend is advances-only', () => {
202
+ setTextShaperBackend({ measureText: (t) => t.length });
203
+ const out: GlyphExtents[] = [];
204
+ expect(getGlyphExtentsBatch([10], {}, out)).toBe(0);
205
+ });
206
+
207
+ it('resolves known glyphs and counts only those that resolved', () => {
208
+ setTextShaperBackend(_makeFullBackend());
209
+ const out: GlyphExtents[] = [];
210
+ // 10 resolves; 999 is unknown.
211
+ const resolved = getGlyphExtentsBatch([10, 999], {}, out);
212
+ expect(resolved).toBe(1);
213
+ expect(out).toHaveLength(2);
214
+ expect(out[0].width).toBe(_testExtents.width);
215
+ });
216
+
217
+ it('writes zeroed extents for unknown glyphs so out is fully populated', () => {
218
+ setTextShaperBackend(_makeFullBackend());
219
+ const out: GlyphExtents[] = [];
220
+ getGlyphExtentsBatch([999, 10], {}, out);
221
+ expect(out[0]).toEqual({ height: 0, width: 0, xBearing: 0, yBearing: 0 });
222
+ expect(out[1].height).toBe(_testExtents.height);
223
+ });
224
+
225
+ it('returns 0 for an empty glyph id list without touching the backend', () => {
226
+ setTextShaperBackend(_makeFullBackend());
227
+ const out: GlyphExtents[] = [];
228
+ expect(getGlyphExtentsBatch([], {}, out)).toBe(0);
229
+ expect(out).toHaveLength(0);
230
+ });
231
+ });
232
+
233
+ describe('getGlyphExtentsInto', () => {
234
+ it('returns false and does not modify out when no backend is set', () => {
235
+ const out: GlyphExtents = { height: 1, width: 1, xBearing: 1, yBearing: 1 };
236
+ expect(getGlyphExtentsInto(10, {}, out)).toBe(false);
237
+ expect(out.width).toBe(1);
238
+ });
239
+
240
+ it('writes all fields into out and returns true on success', () => {
241
+ setTextShaperBackend(_makeFullBackend());
242
+ const out: GlyphExtents = { height: 0, width: 0, xBearing: 0, yBearing: 0 };
243
+ expect(getGlyphExtentsInto(10, {}, out)).toBe(true);
244
+ expect(out.width).toBe(_testExtents.width);
245
+ expect(out.height).toBe(_testExtents.height);
246
+ expect(out.yBearing).toBe(_testExtents.yBearing);
247
+ });
248
+ });
249
+
250
+ describe('getGlyphIndexForCodePoint', () => {
251
+ it('returns -1 when no backend is set', () => {
252
+ expect(getGlyphIndexForCodePoint(65, {})).toBe(-1);
253
+ });
254
+
255
+ it('returns -1 when the backend does not implement getGlyphIndexForCodePoint', () => {
256
+ setTextShaperBackend({ measureText: (t) => t.length });
257
+ expect(getGlyphIndexForCodePoint(65, {})).toBe(-1);
258
+ });
259
+
260
+ it('delegates to the backend and returns the glyph id', () => {
261
+ setTextShaperBackend(_makeFullBackend());
262
+ expect(getGlyphIndexForCodePoint(65, {})).toBe(10);
263
+ });
264
+
265
+ it('returns -1 for code points with no glyph in the font', () => {
266
+ setTextShaperBackend(_makeFullBackend());
267
+ expect(getGlyphIndexForCodePoint(0x2603, {})).toBe(-1);
268
+ });
269
+ });
270
+
271
+ describe('getGlyphName', () => {
272
+ it('returns an empty string when no backend is set', () => {
273
+ expect(getGlyphName(10, {})).toBe('');
274
+ });
275
+
276
+ it('returns an empty string when the backend does not implement getGlyphName', () => {
277
+ setTextShaperBackend({ measureText: (t) => t.length });
278
+ expect(getGlyphName(10, {})).toBe('');
279
+ });
280
+
281
+ it('delegates to the backend and returns the PostScript glyph name', () => {
282
+ setTextShaperBackend(_makeFullBackend());
283
+ expect(getGlyphName(10, {})).toBe('A');
284
+ });
285
+
286
+ it('returns an empty string for glyph ids the backend cannot name', () => {
287
+ setTextShaperBackend(_makeFullBackend());
288
+ expect(getGlyphName(999, {})).toBe('');
289
+ });
290
+ });
291
+
292
+ describe('shapeTextRun', () => {
293
+ it('returns null when no backend is set', () => {
294
+ expect(shapeTextRun('hi', {})).toBeNull();
295
+ });
296
+
297
+ it('returns null when the backend is advances-only (no shapeRun)', () => {
298
+ setTextShaperBackend({ measureText: (t) => t.length });
299
+ expect(shapeTextRun('hi', {})).toBeNull();
300
+ });
301
+
302
+ it('delegates to backend.shapeRun', () => {
303
+ setTextShaperBackend(_makeFullBackend());
304
+ const run = shapeTextRun('ab', {});
305
+ expect(run).not.toBeNull();
306
+ expect(run!.glyphCount).toBe(2);
307
+ expect(run!.direction).toBe('LeftToRight');
308
+ });
309
+
310
+ it('passes options to the backend', () => {
311
+ let capturedOptions: unknown;
312
+ setTextShaperBackend({
313
+ measureText: () => 0,
314
+ shapeRun: (_t, _f, opts) => {
315
+ capturedOptions = opts;
316
+ return { ..._testRun, glyphs: [] };
317
+ },
318
+ });
319
+ shapeTextRun('x', {}, { direction: 'RightToLeft', script: 'Arab' });
320
+ expect(capturedOptions).toMatchObject({ direction: 'RightToLeft', script: 'Arab' });
321
+ });
322
+ });
323
+
324
+ describe('shapeTextRunInto', () => {
325
+ it('returns false and does not modify out when no backend is set', () => {
326
+ const out = createShapedRun();
327
+ expect(shapeTextRunInto('hi', {}, out)).toBe(false);
328
+ expect(out.glyphCount).toBe(0);
329
+ });
330
+
331
+ it('writes run fields and glyphs into out, returns true', () => {
332
+ setTextShaperBackend(_makeFullBackend());
333
+ const out = createShapedRun();
334
+ expect(shapeTextRunInto('ab', {}, out)).toBe(true);
335
+ expect(out.advanceWidth).toBe(15);
336
+ expect(out.glyphCount).toBe(2);
337
+ expect(out.glyphs).toHaveLength(2);
338
+ expect(out.glyphs[0].glyphId).toBe(10);
339
+ expect(out.script).toBe('Latn');
340
+ });
341
+
342
+ it('forwards options to the backend', () => {
343
+ let capturedOptions: unknown;
344
+ setTextShaperBackend({
345
+ measureText: () => 0,
346
+ shapeRun: (_t, _f, opts) => {
347
+ capturedOptions = opts;
348
+ return { ..._testRun, glyphs: [..._testGlyphs] };
349
+ },
350
+ });
351
+ const out = createShapedRun();
352
+ shapeTextRunInto('x', {}, out, { direction: 'RightToLeft', script: 'Arab' });
353
+ expect(capturedOptions).toMatchObject({ direction: 'RightToLeft', script: 'Arab' });
354
+ });
355
+
356
+ it('retains the existing glyphs array reference', () => {
357
+ setTextShaperBackend(_makeFullBackend());
358
+ const out = createShapedRun();
359
+ const originalGlyphs = out.glyphs;
360
+ shapeTextRunInto('ab', {}, out);
361
+ expect(out.glyphs).toBe(originalGlyphs);
362
+ });
363
+ });
@@ -0,0 +1,84 @@
1
+ import type { TextShaperBackend } from '@flighthq/types';
2
+
3
+ import { setTextShaperBackend } from './textShaper';
4
+ import { disposeTextShaperSignals, enableTextShaperSignals, getTextShaperSignals } from './textShaperSignals';
5
+
6
+ const _stubBackend: TextShaperBackend = { measureText: () => 0 };
7
+ const _stubBackend2: TextShaperBackend = { measureText: () => 1 };
8
+
9
+ afterEach(() => {
10
+ disposeTextShaperSignals();
11
+ setTextShaperBackend(null);
12
+ });
13
+
14
+ describe('disposeTextShaperSignals', () => {
15
+ it('is a no-op when signals have not been enabled', () => {
16
+ expect(() => disposeTextShaperSignals()).not.toThrow();
17
+ });
18
+ it('clears all listeners after dispose', () => {
19
+ const sigs = enableTextShaperSignals();
20
+ let fired = false;
21
+ sigs.onBackendChanged.emit = () => {
22
+ fired = true;
23
+ };
24
+ disposeTextShaperSignals();
25
+ setTextShaperBackend(_stubBackend);
26
+ expect(fired).toBe(false);
27
+ });
28
+ it('getTextShaperSignals returns null after dispose', () => {
29
+ enableTextShaperSignals();
30
+ disposeTextShaperSignals();
31
+ expect(getTextShaperSignals()).toBeNull();
32
+ });
33
+ });
34
+
35
+ describe('enableTextShaperSignals', () => {
36
+ it('returns a TextShaperSignals entity with onBackendChanged', () => {
37
+ const sigs = enableTextShaperSignals();
38
+ expect(sigs).not.toBeNull();
39
+ expect(typeof sigs.onBackendChanged).toBe('object');
40
+ expect(typeof sigs.onBackendChanged.emit).toBe('function');
41
+ });
42
+ it('is idempotent: returns the same entity on repeat calls', () => {
43
+ const s1 = enableTextShaperSignals();
44
+ const s2 = enableTextShaperSignals();
45
+ expect(s1).toBe(s2);
46
+ });
47
+ });
48
+
49
+ describe('getTextShaperSignals', () => {
50
+ it('returns null before signals are enabled', () => {
51
+ expect(getTextShaperSignals()).toBeNull();
52
+ });
53
+ it('returns the active signals entity after enabling', () => {
54
+ const sigs = enableTextShaperSignals();
55
+ expect(getTextShaperSignals()).toBe(sigs);
56
+ });
57
+ });
58
+
59
+ describe('setTextShaperBackend', () => {
60
+ it('emits onBackendChanged with the new backend when signals are enabled', () => {
61
+ const sigs = enableTextShaperSignals();
62
+ const received: (TextShaperBackend | null)[] = [];
63
+ sigs.onBackendChanged.emit = (b) => received.push(b);
64
+ setTextShaperBackend(_stubBackend);
65
+ expect(received).toEqual([_stubBackend]);
66
+ });
67
+ it('emits onBackendChanged with null when cleared', () => {
68
+ const sigs = enableTextShaperSignals();
69
+ const received: (TextShaperBackend | null)[] = [];
70
+ sigs.onBackendChanged.emit = (b) => received.push(b);
71
+ setTextShaperBackend(_stubBackend);
72
+ setTextShaperBackend(null);
73
+ expect(received).toEqual([_stubBackend, null]);
74
+ });
75
+ it('does not emit when signals are not enabled', () => {
76
+ // Verifies setTextShaperBackend does not throw when no signals enabled.
77
+ expect(() => setTextShaperBackend(_stubBackend2)).not.toThrow();
78
+ });
79
+ it('installs the backend', () => {
80
+ enableTextShaperSignals();
81
+ setTextShaperBackend(_stubBackend);
82
+ expect(_stubBackend).toBeDefined();
83
+ });
84
+ });