@flighthq/textshaper-canvas 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.
- package/dist/canvasTextShaper.d.ts +7 -0
- package/dist/canvasTextShaper.d.ts.map +1 -0
- package/dist/canvasTextShaper.js +166 -0
- package/dist/canvasTextShaper.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/canvasTextShaper.test.ts +192 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TextShaperBackend } from '@flighthq/types';
|
|
2
|
+
export declare function clearCanvasTextShaperBackendCache(backend: CanvasTextShaperBackend): void;
|
|
3
|
+
export declare function createCanvasTextShaperBackend(): CanvasTextShaperBackend;
|
|
4
|
+
export interface CanvasTextShaperBackend extends TextShaperBackend {
|
|
5
|
+
clearCache(): void;
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=canvasTextShaper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvasTextShaper.d.ts","sourceRoot":"","sources":["../src/canvasTextShaper.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAA2B,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAMlF,wBAAgB,iCAAiC,CAAC,OAAO,EAAE,uBAAuB,GAAG,IAAI,CAExF;AAoBD,wBAAgB,6BAA6B,IAAI,uBAAuB,CAqGvE;AAMD,MAAM,WAAW,uBAAwB,SAAQ,iBAAiB;IAChE,UAAU,IAAI,IAAI,CAAC;CACpB"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { computeTextFormatFontString } from '@flighthq/text';
|
|
2
|
+
// Clears the advance cache on a backend returned by createCanvasTextShaperBackend. Call this after
|
|
3
|
+
// a webfont finishes loading — document.fonts.ready resolves, FontFaceObserver fires, etc. — so
|
|
4
|
+
// previously-cached advances are recomputed with the new font metrics. This is a no-op on the
|
|
5
|
+
// sentinel backend returned when no canvas context is available.
|
|
6
|
+
export function clearCanvasTextShaperBackendCache(backend) {
|
|
7
|
+
backend.clearCache();
|
|
8
|
+
}
|
|
9
|
+
// Builds the Canvas 2D text-shaper backend: advances-only shaping over a private context's
|
|
10
|
+
// measureText, plus font-level metrics derived from TextMetrics bounding-box fields. Install it
|
|
11
|
+
// once during setup via setTextShaperBackend(createCanvasTextShaperBackend()) so text-layout can
|
|
12
|
+
// measure text for metrics and autoSize bounds outside the render pass.
|
|
13
|
+
//
|
|
14
|
+
// Uses the same canvas measureText and font string (computeTextFormatFontString) the renderers use,
|
|
15
|
+
// so shaped advances match what gets rasterized. Each backend instance owns exactly one private
|
|
16
|
+
// canvas + 2D context — no shared global state, no top-level side effects. The canvas is either
|
|
17
|
+
// an OffscreenCanvas (worker-safe, no DOM required) or a detached HTMLCanvasElement. In
|
|
18
|
+
// non-DOM, non-worker environments where neither is available, createCanvasTextShaperBackend()
|
|
19
|
+
// returns a sentinel backend that yields -1 for advances and null for metrics.
|
|
20
|
+
//
|
|
21
|
+
// Advance results are memoized in a per-backend LRU cache keyed by (font-string, text). Call
|
|
22
|
+
// clearCanvasTextShaperBackendCache(backend) to invalidate after a webfont loads and changes
|
|
23
|
+
// metrics for previously-measured strings.
|
|
24
|
+
//
|
|
25
|
+
// This is the extraction of the former createCanvasTextMeasure — the SDK's existing measurement,
|
|
26
|
+
// formalized as a TextShaperBackend.
|
|
27
|
+
export function createCanvasTextShaperBackend() {
|
|
28
|
+
const ctx = _createContext();
|
|
29
|
+
if (ctx === null) {
|
|
30
|
+
return _createSentinelBackend();
|
|
31
|
+
}
|
|
32
|
+
// One-time feature-detect: modern Canvas letterSpacing/wordSpacing (Chrome 99+, Firefox 117+).
|
|
33
|
+
// Older engines simply do not apply the property; we set it unconditionally on modern engines.
|
|
34
|
+
const supportsLetterSpacing = 'letterSpacing' in ctx;
|
|
35
|
+
const supportsWordSpacing = 'wordSpacing' in ctx;
|
|
36
|
+
const supportsDirection = 'direction' in ctx;
|
|
37
|
+
// Bounded advance cache: keyed by `${fontString}\x00${text}`. Evicts the oldest entry when the
|
|
38
|
+
// cache reaches _CACHE_MAX_SIZE, keeping allocation bounded on hot-path layout measurement.
|
|
39
|
+
const cache = new Map();
|
|
40
|
+
const backend = {
|
|
41
|
+
clearCache() {
|
|
42
|
+
cache.clear();
|
|
43
|
+
},
|
|
44
|
+
getFontMetrics(format) {
|
|
45
|
+
const fontString = computeTextFormatFontString(format);
|
|
46
|
+
ctx.font = fontString;
|
|
47
|
+
// Probe strings chosen to expose the font's cap-height, x-height, and descender ink extents.
|
|
48
|
+
const capMeasure = ctx.measureText('H');
|
|
49
|
+
const xMeasure = ctx.measureText('x');
|
|
50
|
+
// 'g' carries a descender; its actualBoundingBoxDescent is a real below-baseline extent,
|
|
51
|
+
// unlike 'H' (descent ~0), so the fallback descent does not collapse to zero on engines
|
|
52
|
+
// that lack fontBoundingBox* (Firefox before 116, older Safari).
|
|
53
|
+
const descenderMeasure = ctx.measureText('g');
|
|
54
|
+
// fontBoundingBoxAscent/Descent are the reliable font-level values (defined even for
|
|
55
|
+
// whitespace-only strings). actualBoundingBoxAscent gives the ink ascent above the baseline.
|
|
56
|
+
const ascent = capMeasure.fontBoundingBoxAscent ?? capMeasure.actualBoundingBoxAscent;
|
|
57
|
+
const descent = capMeasure.fontBoundingBoxDescent ?? descenderMeasure.actualBoundingBoxDescent;
|
|
58
|
+
// Canvas does not expose OS/2 table fields (unitsPerEm, lineGap, underline metrics). We
|
|
59
|
+
// provide size-relative estimates consistent with typical web-font conventions. These are
|
|
60
|
+
// safe for layout; a full-glyph backend (HarfBuzz) will override them with exact values.
|
|
61
|
+
const size = format.size ?? 12;
|
|
62
|
+
return {
|
|
63
|
+
ascent,
|
|
64
|
+
capHeight: capMeasure.actualBoundingBoxAscent,
|
|
65
|
+
descent,
|
|
66
|
+
lineGap: 0,
|
|
67
|
+
underlinePosition: -(size * 0.1),
|
|
68
|
+
underlineThickness: Math.max(1, size * 0.05),
|
|
69
|
+
// Canvas cannot read OS/2 font units. Returning the identity (unitsPerEm === size) makes
|
|
70
|
+
// FontMetrics' documented inverse — divide by size / unitsPerEm to recover font units — a
|
|
71
|
+
// safe no-op (it divides by 1) rather than a divide-by-zero. A full-glyph backend that can
|
|
72
|
+
// read the font (HarfBuzz) overrides this with the real units-per-em.
|
|
73
|
+
unitsPerEm: size,
|
|
74
|
+
xHeight: xMeasure.actualBoundingBoxAscent,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
measureText(text, format) {
|
|
78
|
+
const fontString = computeTextFormatFontString(format);
|
|
79
|
+
// The key must encode every field that changes the measured advance. computeTextFormatFontString
|
|
80
|
+
// covers italic/bold/size/family, but letterSpacing is set on the context below and is NOT in
|
|
81
|
+
// the font string — omitting it would return the first call's width for a later call with a
|
|
82
|
+
// different letterSpacing on the same (font, text). wordSpacing and direction are constant
|
|
83
|
+
// today, so letterSpacing is the only extra discriminator the key needs.
|
|
84
|
+
const cacheKey = `${fontString}\x00${format.letterSpacing ?? 0}\x00${text}`;
|
|
85
|
+
const cached = cache.get(cacheKey);
|
|
86
|
+
if (cached !== undefined)
|
|
87
|
+
return cached;
|
|
88
|
+
ctx.font = fontString;
|
|
89
|
+
// Plumb letterSpacing and wordSpacing so measured advances match the Canvas renderer which
|
|
90
|
+
// also sets these properties when drawing. Guard with the one-time feature-detect so older
|
|
91
|
+
// engines silently no-op rather than throwing.
|
|
92
|
+
if (supportsLetterSpacing) {
|
|
93
|
+
ctx['letterSpacing'] = `${format.letterSpacing ?? 0}px`;
|
|
94
|
+
}
|
|
95
|
+
if (supportsWordSpacing) {
|
|
96
|
+
ctx['wordSpacing'] = `0px`;
|
|
97
|
+
}
|
|
98
|
+
// Set direction explicitly so RTL advances match rasterization. TextFormat does not carry a
|
|
99
|
+
// direction field today; we default to 'ltr' here and document the limitation. When
|
|
100
|
+
// TextFormat gains a direction field, replace this hardcoded value.
|
|
101
|
+
if (supportsDirection) {
|
|
102
|
+
ctx['direction'] = 'ltr';
|
|
103
|
+
}
|
|
104
|
+
const width = ctx.measureText(text).width;
|
|
105
|
+
// Evict oldest entry when the cache is full.
|
|
106
|
+
if (cache.size >= _CACHE_MAX_SIZE) {
|
|
107
|
+
cache.delete(cache.keys().next().value);
|
|
108
|
+
}
|
|
109
|
+
cache.set(cacheKey, width);
|
|
110
|
+
return width;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return backend;
|
|
114
|
+
}
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Internal helpers
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Max number of (font, text) pairs held in the per-backend advance cache. Chosen to cover a
|
|
119
|
+
// typical paragraph of mixed formats without unbounded growth. ~2 KB at 64 bytes/key average.
|
|
120
|
+
const _CACHE_MAX_SIZE = 512;
|
|
121
|
+
// Returns a 2D context from OffscreenCanvas (worker-safe, no DOM) when available, falling back
|
|
122
|
+
// to a detached HTMLCanvasElement (DOM required). Returns null when neither is available — the
|
|
123
|
+
// sentinel backend path.
|
|
124
|
+
function _createContext() {
|
|
125
|
+
// Prefer OffscreenCanvas: available in Workers and modern browsers without touching the DOM.
|
|
126
|
+
if (typeof OffscreenCanvas !== 'undefined') {
|
|
127
|
+
try {
|
|
128
|
+
const offscreen = new OffscreenCanvas(0, 0);
|
|
129
|
+
const ctx = offscreen.getContext('2d');
|
|
130
|
+
if (ctx !== null)
|
|
131
|
+
return ctx;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// OffscreenCanvas exists but getContext('2d') failed; fall through to HTMLCanvasElement.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Fall back to a detached HTMLCanvasElement in DOM environments.
|
|
138
|
+
if (typeof document !== 'undefined') {
|
|
139
|
+
try {
|
|
140
|
+
const canvas = document.createElement('canvas');
|
|
141
|
+
return canvas.getContext('2d');
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// DOM present but canvas creation failed; return null to trigger sentinel path.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
// Returns a sentinel backend that yields -1 for measureText and null for getFontMetrics. Used in
|
|
150
|
+
// non-DOM, non-OffscreenCanvas environments (SSR, workers without OffscreenCanvas support) so
|
|
151
|
+
// callers receive the documented sentinel values rather than a throw at construction time. The
|
|
152
|
+
// clearCache no-op satisfies the CanvasTextShaperBackend contract.
|
|
153
|
+
function _createSentinelBackend() {
|
|
154
|
+
return {
|
|
155
|
+
clearCache() {
|
|
156
|
+
// No-op: no cache in the sentinel path.
|
|
157
|
+
},
|
|
158
|
+
getFontMetrics(_format) {
|
|
159
|
+
return null;
|
|
160
|
+
},
|
|
161
|
+
measureText(_text, _format) {
|
|
162
|
+
return -1;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=canvasTextShaper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvasTextShaper.js","sourceRoot":"","sources":["../src/canvasTextShaper.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flighthq/textshaper-canvas",
|
|
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/text": "0.1.0",
|
|
31
|
+
"@flighthq/textshaper": "0.1.0",
|
|
32
|
+
"@flighthq/types": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"typescript": "^5.3.0"
|
|
36
|
+
},
|
|
37
|
+
"description": "Canvas 2D text-shaper backend: advances-only shaping via measureText",
|
|
38
|
+
"sideEffects": false
|
|
39
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { getTextShaperBackend, setTextShaperBackend } from '@flighthq/textshaper';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CanvasTextShaperBackend,
|
|
5
|
+
clearCanvasTextShaperBackendCache,
|
|
6
|
+
createCanvasTextShaperBackend,
|
|
7
|
+
} from './canvasTextShaper';
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
setTextShaperBackend(null);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('CanvasTextShaperBackend', () => {
|
|
14
|
+
it('satisfies the TextShaperBackend interface', () => {
|
|
15
|
+
const backend = createCanvasTextShaperBackend();
|
|
16
|
+
expect(typeof backend.measureText).toBe('function');
|
|
17
|
+
expect(typeof backend.getFontMetrics).toBe('function');
|
|
18
|
+
expect(typeof backend.clearCache).toBe('function');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('clearCanvasTextShaperBackendCache', () => {
|
|
23
|
+
it('clears the advance cache so subsequent calls re-measure', () => {
|
|
24
|
+
const backend = createCanvasTextShaperBackend();
|
|
25
|
+
const first = backend.measureText('hello', { size: 16 });
|
|
26
|
+
// Warm the cache, then clear and re-measure — should produce the same result (not a stale value)
|
|
27
|
+
clearCanvasTextShaperBackendCache(backend);
|
|
28
|
+
const second = backend.measureText('hello', { size: 16 });
|
|
29
|
+
expect(second).toBeCloseTo(first, 5);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is a no-op on the sentinel backend', () => {
|
|
33
|
+
// Sentinel is returned when document is unavailable. We cannot easily simulate that in jsdom,
|
|
34
|
+
// but we can verify clearCanvasTextShaperBackendCache is callable and does not throw.
|
|
35
|
+
const backend = createCanvasTextShaperBackend();
|
|
36
|
+
expect(() => clearCanvasTextShaperBackendCache(backend)).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('createCanvasTextShaperBackend', () => {
|
|
41
|
+
it('returns a backend whose measureText reports a non-negative width', () => {
|
|
42
|
+
const backend = createCanvasTextShaperBackend();
|
|
43
|
+
expect(backend.measureText('hello', {})).toBeGreaterThanOrEqual(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns a greater advance for longer text at the same format', () => {
|
|
47
|
+
const backend = createCanvasTextShaperBackend();
|
|
48
|
+
const short = backend.measureText('a', { size: 16 });
|
|
49
|
+
const longer = backend.measureText('aaa', { size: 16 });
|
|
50
|
+
expect(longer).toBeGreaterThan(short);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns a number for any font size', () => {
|
|
54
|
+
// jsdom measureText always returns 0; we only verify type and non-negative sentinel behavior.
|
|
55
|
+
const backend = createCanvasTextShaperBackend();
|
|
56
|
+
const small = backend.measureText('hello', { size: 12 });
|
|
57
|
+
const large = backend.measureText('hello', { size: 24 });
|
|
58
|
+
expect(typeof small).toBe('number');
|
|
59
|
+
expect(typeof large).toBe('number');
|
|
60
|
+
expect(small).toBeGreaterThanOrEqual(0);
|
|
61
|
+
expect(large).toBeGreaterThanOrEqual(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('two backends do not share a context — independent font state', () => {
|
|
65
|
+
const a = createCanvasTextShaperBackend();
|
|
66
|
+
const b = createCanvasTextShaperBackend();
|
|
67
|
+
// Both should yield the same measurement for the same input (they use the same algorithm),
|
|
68
|
+
// but they are independent instances. Mutating one should not change the other.
|
|
69
|
+
const widthA = a.measureText('hello', { size: 16 });
|
|
70
|
+
const widthB = b.measureText('hello', { size: 16 });
|
|
71
|
+
expect(widthA).toBeCloseTo(widthB, 5);
|
|
72
|
+
// Measuring with a different format on backend a does not change b's next measurement.
|
|
73
|
+
a.measureText('hello', { size: 48, bold: true });
|
|
74
|
+
const widthB2 = b.measureText('hello', { size: 16 });
|
|
75
|
+
expect(widthB2).toBeCloseTo(widthB, 5);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('installs into the textshaper seam', () => {
|
|
79
|
+
const backend = createCanvasTextShaperBackend();
|
|
80
|
+
setTextShaperBackend(backend);
|
|
81
|
+
expect(getTextShaperBackend()).toBe(backend);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('measureText uses the advance cache — same key returns immediately', () => {
|
|
85
|
+
const backend = createCanvasTextShaperBackend();
|
|
86
|
+
const first = backend.measureText('cached', { size: 16, font: 'serif' });
|
|
87
|
+
const second = backend.measureText('cached', { size: 16, font: 'serif' });
|
|
88
|
+
// Both calls must return the same value (cached path must not change the result).
|
|
89
|
+
expect(second).toBe(first);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('measureText returns -1 when letterSpacing is set (sentinel or jsdom limitation is acceptable)', () => {
|
|
93
|
+
// jsdom's measureText returns 0 for all text, so we just check non-throw and type.
|
|
94
|
+
const backend = createCanvasTextShaperBackend();
|
|
95
|
+
const result = backend.measureText('hello', { letterSpacing: 2 });
|
|
96
|
+
expect(typeof result).toBe('number');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('measureText with letterSpacing=0 and non-zero produce number results', () => {
|
|
100
|
+
const backend = createCanvasTextShaperBackend();
|
|
101
|
+
const noSpacing = backend.measureText('hello', { letterSpacing: 0 });
|
|
102
|
+
const withSpacing = backend.measureText('hello', { letterSpacing: 4 });
|
|
103
|
+
expect(typeof noSpacing).toBe('number');
|
|
104
|
+
expect(typeof withSpacing).toBe('number');
|
|
105
|
+
expect(noSpacing).toBeGreaterThanOrEqual(0);
|
|
106
|
+
expect(withSpacing).toBeGreaterThanOrEqual(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('keys the advance cache on letterSpacing — differing letterSpacing does not collapse to one entry', () => {
|
|
110
|
+
// jsdom's measureText returns 0 for every string, so a wrong width is invisible; instead pin
|
|
111
|
+
// the fix by observing the underlying measureText call count. Two calls that differ only in
|
|
112
|
+
// letterSpacing must each reach measureText (distinct cache keys); a repeated identical call
|
|
113
|
+
// must hit the cache (no second measureText). A spy on the 2D context proves both.
|
|
114
|
+
const realGetContext = HTMLCanvasElement.prototype.getContext;
|
|
115
|
+
const measureSpy = vi.fn((_text: string) => ({ width: 0 }) as TextMetrics);
|
|
116
|
+
const fakeContext = {
|
|
117
|
+
font: '',
|
|
118
|
+
letterSpacing: '0px',
|
|
119
|
+
wordSpacing: '0px',
|
|
120
|
+
direction: 'ltr',
|
|
121
|
+
measureText: measureSpy,
|
|
122
|
+
};
|
|
123
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(fakeContext as never);
|
|
124
|
+
try {
|
|
125
|
+
const backend = createCanvasTextShaperBackend();
|
|
126
|
+
if (backend.measureText('probe', {}) === -1) return; // sentinel path; nothing to assert
|
|
127
|
+
|
|
128
|
+
measureSpy.mockClear();
|
|
129
|
+
backend.measureText('same', { size: 16, letterSpacing: 0 });
|
|
130
|
+
backend.measureText('same', { size: 16, letterSpacing: 0 });
|
|
131
|
+
// Identical key — second call is served from cache.
|
|
132
|
+
expect(measureSpy).toHaveBeenCalledTimes(1);
|
|
133
|
+
|
|
134
|
+
backend.measureText('same', { size: 16, letterSpacing: 4 });
|
|
135
|
+
// Different letterSpacing — distinct key, must re-measure rather than return the cached 0-spacing width.
|
|
136
|
+
expect(measureSpy).toHaveBeenCalledTimes(2);
|
|
137
|
+
} finally {
|
|
138
|
+
HTMLCanvasElement.prototype.getContext = realGetContext;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('createCanvasTextShaperBackend — getFontMetrics', () => {
|
|
144
|
+
it('returns an object with the expected FontMetrics fields', () => {
|
|
145
|
+
const backend = createCanvasTextShaperBackend();
|
|
146
|
+
const metrics = backend.getFontMetrics!({ size: 16, font: 'serif' });
|
|
147
|
+
if (metrics === null) return; // sentinel path; skip field checks
|
|
148
|
+
|
|
149
|
+
expect(typeof metrics.ascent).toBe('number');
|
|
150
|
+
expect(typeof metrics.capHeight).toBe('number');
|
|
151
|
+
expect(typeof metrics.descent).toBe('number');
|
|
152
|
+
expect(typeof metrics.lineGap).toBe('number');
|
|
153
|
+
expect(typeof metrics.underlinePosition).toBe('number');
|
|
154
|
+
expect(typeof metrics.underlineThickness).toBe('number');
|
|
155
|
+
expect(typeof metrics.unitsPerEm).toBe('number');
|
|
156
|
+
expect(typeof metrics.xHeight).toBe('number');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('ascent and descent are non-negative', () => {
|
|
160
|
+
const backend = createCanvasTextShaperBackend();
|
|
161
|
+
const metrics = backend.getFontMetrics!({ size: 16 });
|
|
162
|
+
if (metrics === null) return;
|
|
163
|
+
expect(metrics.ascent).toBeGreaterThanOrEqual(0);
|
|
164
|
+
expect(metrics.descent).toBeGreaterThanOrEqual(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('reports a non-zero unitsPerEm (identity size) so the documented inverse never divides by zero', () => {
|
|
168
|
+
const backend = createCanvasTextShaperBackend();
|
|
169
|
+
const metrics = backend.getFontMetrics!({ size: 16 });
|
|
170
|
+
if (metrics === null) return;
|
|
171
|
+
// Canvas cannot read OS/2 units; it returns the identity unitsPerEm === size, making the
|
|
172
|
+
// documented "divide by size / unitsPerEm" conversion a safe no-op (divide by 1) instead of /0.
|
|
173
|
+
expect(metrics.unitsPerEm).toBe(16);
|
|
174
|
+
const defaultMetrics = backend.getFontMetrics!({});
|
|
175
|
+
if (defaultMetrics === null) return;
|
|
176
|
+
// No explicit size falls back to the 12px default — still non-zero.
|
|
177
|
+
expect(defaultMetrics.unitsPerEm).toBe(12);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('underlineThickness is positive', () => {
|
|
181
|
+
const backend = createCanvasTextShaperBackend();
|
|
182
|
+
const metrics = backend.getFontMetrics!({ size: 16 });
|
|
183
|
+
if (metrics === null) return;
|
|
184
|
+
expect(metrics.underlineThickness).toBeGreaterThan(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns null or an object — never throws', () => {
|
|
188
|
+
const backend = createCanvasTextShaperBackend();
|
|
189
|
+
expect(() => backend.getFontMetrics!({})).not.toThrow();
|
|
190
|
+
expect(() => backend.getFontMetrics!({ size: 12, bold: true, italic: true })).not.toThrow();
|
|
191
|
+
});
|
|
192
|
+
});
|