Skip to content

Commit 4ff061b

Browse files
matthargettCopilot
andauthored
Add DOM-free font offset fallback (#18463)
## What - Falls back from DOM layout font metrics to canvas text metrics when DOM layout is unavailable or returns zero-sized bounds. - Uses a CSS pixel-size estimate as the final fallback. - Adds focused unit coverage for the zero-layout path. ## Why Native runtimes need GUI resize-to-fit code to work without carrying validation-only font metric shims. ## Validation - `npx vitest run --project=unit packages/dev/core/test/unit/Engines/engine.common.test.ts` --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent d2db55e commit 4ff061b

2 files changed

Lines changed: 152 additions & 1 deletion

File tree

packages/dev/core/src/Engines/engine.common.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,24 @@ export function _CommonDispose(commonEngine: AbstractEngine, canvas: Nullable<HT
143143
* @returns an object containing ascent, height and descent
144144
*/
145145
export function GetFontOffset(font: string): { ascent: number; height: number; descent: number } {
146+
const domFontOffset = GetFontOffsetFromDom(font);
147+
if (domFontOffset) {
148+
return domFontOffset;
149+
}
150+
151+
const canvasFontOffset = GetFontOffsetFromCanvas(font);
152+
if (canvasFontOffset) {
153+
return canvasFontOffset;
154+
}
155+
156+
return GetFallbackFontOffset(font);
157+
}
158+
159+
function GetFontOffsetFromDom(font: string): Nullable<{ ascent: number; height: number; descent: number }> {
160+
if (!IsDocumentAvailable() || !document.body) {
161+
return null;
162+
}
163+
146164
const text = document.createElement("span");
147165
text.textContent = "Hg";
148166
text.style.font = font;
@@ -169,7 +187,57 @@ export function GetFontOffset(font: string): { ascent: number; height: number; d
169187
} finally {
170188
document.body.removeChild(div);
171189
}
172-
return { ascent: fontAscent, height: fontHeight, descent: fontHeight - fontAscent };
190+
191+
const offset = { ascent: fontAscent, height: fontHeight, descent: fontHeight - fontAscent };
192+
return IsValidFontOffset(offset) ? offset : null;
193+
}
194+
195+
function GetFontOffsetFromCanvas(font: string): Nullable<{ ascent: number; height: number; descent: number }> {
196+
let canvas: Nullable<OffscreenCanvas | HTMLCanvasElement> = null;
197+
try {
198+
if (typeof OffscreenCanvas !== "undefined") {
199+
canvas = new OffscreenCanvas(64, 64);
200+
} else if (IsDocumentAvailable() && typeof document.createElement === "function") {
201+
canvas = document.createElement("canvas");
202+
canvas.width = 64;
203+
canvas.height = 64;
204+
}
205+
206+
const context = canvas?.getContext("2d") as Nullable<CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D>;
207+
if (!context) {
208+
return null;
209+
}
210+
211+
context.font = font;
212+
const metrics = context.measureText("Hg");
213+
const ascent = Number(metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent);
214+
const descent = Number(metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent);
215+
const offset = { ascent, height: ascent + descent, descent };
216+
return IsValidFontOffset(offset) ? offset : null;
217+
} catch {
218+
return null;
219+
} finally {
220+
const disposableCanvas = canvas as Nullable<{ dispose?: () => void }>;
221+
if (typeof disposableCanvas?.dispose === "function") {
222+
disposableCanvas.dispose();
223+
}
224+
}
225+
}
226+
227+
function GetFallbackFontOffset(font: string): { ascent: number; height: number; descent: number } {
228+
const size = Math.max(1, GetCssPixelFontSize(font));
229+
const ascent = size * 0.8;
230+
const descent = size * 0.2;
231+
return { ascent, height: ascent + descent, descent };
232+
}
233+
234+
function GetCssPixelFontSize(font: string): number {
235+
const match = /(?:^|\s)([0-9]+(?:\.[0-9]+)?)px(?:\/|\s|$)/.exec(String(font || ""));
236+
return match ? Number(match[1]) : 16;
237+
}
238+
239+
function IsValidFontOffset(offset: { ascent: number; height: number; descent: number }): boolean {
240+
return Number.isFinite(offset.ascent) && Number.isFinite(offset.height) && Number.isFinite(offset.descent) && offset.height > 0;
173241
}
174242

175243
/** @internal */
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { GetFontOffset } from "core/Engines/engine.common";
6+
import { afterEach, describe, expect, it, vi } from "vitest";
7+
8+
describe("GetFontOffset", () => {
9+
const originalOffscreenCanvas = globalThis.OffscreenCanvas;
10+
11+
afterEach(() => {
12+
vi.restoreAllMocks();
13+
Object.defineProperty(globalThis, "OffscreenCanvas", {
14+
configurable: true,
15+
writable: true,
16+
value: originalOffscreenCanvas,
17+
});
18+
});
19+
20+
function mockDomZeroBoundsWithCanvasMetrics(metrics: Partial<TextMetrics>): void {
21+
Object.defineProperty(globalThis, "OffscreenCanvas", {
22+
configurable: true,
23+
writable: true,
24+
value: undefined,
25+
});
26+
27+
const createElement = document.createElement.bind(document);
28+
vi.spyOn(document, "createElement").mockImplementation(((tagName: string) => {
29+
if (tagName.toLowerCase() === "canvas") {
30+
return {
31+
width: 0,
32+
height: 0,
33+
getContext: () => ({
34+
font: "",
35+
measureText: () => metrics,
36+
}),
37+
} as unknown as HTMLElement;
38+
}
39+
40+
const element = createElement(tagName);
41+
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
42+
x: 0,
43+
y: 0,
44+
top: 0,
45+
left: 0,
46+
right: 0,
47+
bottom: 0,
48+
width: 0,
49+
height: 0,
50+
toJSON: () => ({}),
51+
});
52+
return element;
53+
}) as typeof document.createElement);
54+
}
55+
56+
it("falls back to canvas text metrics when DOM layout reports zero font bounds", () => {
57+
mockDomZeroBoundsWithCanvasMetrics({
58+
fontBoundingBoxAscent: 17,
59+
fontBoundingBoxDescent: 5,
60+
});
61+
62+
expect(GetFontOffset("24px droidsans")).toEqual({
63+
ascent: 17,
64+
height: 22,
65+
descent: 5,
66+
});
67+
});
68+
69+
it("prefers actual canvas text bounds over font bounds", () => {
70+
mockDomZeroBoundsWithCanvasMetrics({
71+
actualBoundingBoxAscent: 13,
72+
actualBoundingBoxDescent: 4,
73+
fontBoundingBoxAscent: 17,
74+
fontBoundingBoxDescent: 5,
75+
});
76+
77+
expect(GetFontOffset("24px droidsans")).toEqual({
78+
ascent: 13,
79+
height: 17,
80+
descent: 4,
81+
});
82+
});
83+
});

0 commit comments

Comments
 (0)