Skip to content

Commit 34782eb

Browse files
committed
support font embeding
1 parent 5c5f68b commit 34782eb

6 files changed

Lines changed: 236 additions & 13 deletions

File tree

src/background/service-worker.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FORMATS, CATEGORIES } from '../shared/formats.js';
22
import { extensionApi } from '../shared/extension-api.js';
33
import { base64ToBytes } from '../shared/export-transfer.js';
4+
import { serializeFontAssetsForTransfer } from '../shared/font-assets.js';
45
import { mergeFrameExtractionResults } from '../shared/frame-merge.js';
56
import { POPUP_STATUS_PORT_NAME } from '../shared/popup-status.js';
67

@@ -133,6 +134,7 @@ async function startExport(tabId, format, exportOptions = DEFAULT_EXPORT_OPTIONS
133134
const precomputedExport = selectPrecomputedExportForTransfer(
134135
await collectMergedIrForTab(tabId, format, normalizedOptions)
135136
);
137+
const transferReadyPrecomputedExport = serializePrecomputedExportForTransfer(precomputedExport);
136138

137139
// 1. Set the requested format and precomputed IR in the content-script world
138140
await extensionApi.scripting.executeScript({
@@ -158,7 +160,7 @@ async function startExport(tabId, format, exportOptions = DEFAULT_EXPORT_OPTIONS
158160
delete globalThis.__web2vector_export_options;
159161
}
160162
},
161-
args: [format, precomputedExport, normalizedOptions],
163+
args: [format, transferReadyPrecomputedExport, normalizedOptions],
162164
});
163165

164166
// 2. Lazy-load writer bundle when required
@@ -233,6 +235,18 @@ function selectPrecomputedExportForTransfer(precomputedExport) {
233235
return null;
234236
}
235237

238+
function serializePrecomputedExportForTransfer(precomputedExport) {
239+
if (!Array.isArray(precomputedExport?.ir)) {
240+
return null;
241+
}
242+
243+
const fontAssets = serializeFontAssetsForTransfer(precomputedExport.fontAssets);
244+
245+
return fontAssets
246+
? { ...precomputedExport, fontAssets }
247+
: { ...precomputedExport };
248+
}
249+
236250
function estimateTransferSize(value, seen = new Set()) {
237251
if (value == null) return 0;
238252

src/content/run-export.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { calculateExportSize } from './export-size.js';
1010
import { extensionApi } from '../shared/extension-api.js';
1111
import { EXPORT_STREAM_CHUNK_BYTES, createExportTransferId } from '../shared/export-transfer.js';
12+
import { normalizeTransferredFontAssets } from '../shared/font-assets.js';
1213
import {
1314
collectInaccessibleIframeDiagnostics,
1415
collectPotentiallyTaintedImageDiagnostics,
@@ -407,9 +408,5 @@ function shouldCollectFontAssets(format, exportOptions) {
407408
}
408409

409410
function normalizeFontAssets(fontAssets) {
410-
if (!fontAssets || !Array.isArray(fontAssets.faces) || fontAssets.faces.length === 0) {
411-
return undefined;
412-
}
413-
414-
return { faces: fontAssets.faces };
411+
return normalizeTransferredFontAssets(fontAssets);
415412
}

src/shared/font-assets.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
function coerceByte(value) {
2+
const number = Number(value);
3+
if (!Number.isFinite(number)) return 0;
4+
if (number <= 0) return 0;
5+
if (number >= 255) return 255;
6+
return Math.trunc(number);
7+
}
8+
9+
function toUint8Array(value) {
10+
if (value instanceof Uint8Array) {
11+
return value;
12+
}
13+
14+
if (ArrayBuffer.isView(value)) {
15+
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
16+
}
17+
18+
if (value instanceof ArrayBuffer) {
19+
return new Uint8Array(value.slice(0));
20+
}
21+
22+
if (Array.isArray(value)) {
23+
return Uint8Array.from(value, coerceByte);
24+
}
25+
26+
if (value && typeof value === 'object') {
27+
if (Array.isArray(value.data)) {
28+
return Uint8Array.from(value.data, coerceByte);
29+
}
30+
31+
const numericKeys = Object.keys(value)
32+
.filter((key) => /^\d+$/.test(key))
33+
.sort((left, right) => Number(left) - Number(right));
34+
35+
if (numericKeys.length > 0) {
36+
return Uint8Array.from(numericKeys.map((key) => coerceByte(value[key])));
37+
}
38+
}
39+
40+
return null;
41+
}
42+
43+
function toTransferableByteArray(value) {
44+
const bytes = toUint8Array(value);
45+
return bytes ? Array.from(bytes) : null;
46+
}
47+
48+
export function serializeFontAssetsForTransfer(fontAssets) {
49+
if (!fontAssets || !Array.isArray(fontAssets.faces) || fontAssets.faces.length === 0) {
50+
return undefined;
51+
}
52+
53+
const faces = fontAssets.faces
54+
.map((face) => {
55+
const sources = Array.isArray(face?.sources)
56+
? face.sources
57+
.map((source) => {
58+
const data = toTransferableByteArray(source?.data);
59+
if (!data) return null;
60+
61+
return {
62+
...source,
63+
data,
64+
};
65+
})
66+
.filter(Boolean)
67+
: [];
68+
69+
if (sources.length === 0) {
70+
return null;
71+
}
72+
73+
return {
74+
...face,
75+
sources,
76+
};
77+
})
78+
.filter(Boolean);
79+
80+
return faces.length > 0 ? { faces } : undefined;
81+
}
82+
83+
export function normalizeTransferredFontAssets(fontAssets) {
84+
if (!fontAssets || !Array.isArray(fontAssets.faces) || fontAssets.faces.length === 0) {
85+
return undefined;
86+
}
87+
88+
const faces = fontAssets.faces
89+
.map((face) => {
90+
const sources = Array.isArray(face?.sources)
91+
? face.sources
92+
.map((source) => {
93+
const data = toUint8Array(source?.data);
94+
if (!data) return null;
95+
96+
return {
97+
...source,
98+
data,
99+
};
100+
})
101+
.filter(Boolean)
102+
: [];
103+
104+
if (sources.length === 0) {
105+
return null;
106+
}
107+
108+
return {
109+
...face,
110+
sources,
111+
};
112+
})
113+
.filter(Boolean);
114+
115+
return faces.length > 0 ? { faces } : undefined;
116+
}

src/shared/frame-merge.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { normalizeTransferredFontAssets } from './font-assets.js';
2+
13
const IDENTITY_TRANSFORM = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
24

35
export function mergeFrameExtractionResults(frameResults, options = {}) {
@@ -32,13 +34,7 @@ function normalizeFrameResults(frameResults) {
3234
}
3335

3436
function normalizeFontAssets(fontAssets) {
35-
if (!fontAssets || !Array.isArray(fontAssets.faces) || fontAssets.faces.length === 0) {
36-
return undefined;
37-
}
38-
39-
return {
40-
faces: fontAssets.faces.filter((face) => Array.isArray(face?.sources) && face.sources.length > 0),
41-
};
37+
return normalizeTransferredFontAssets(fontAssets);
4238
}
4339

4440
function mergeFontAssets(frameResults) {

tests/font-assets.test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
normalizeTransferredFontAssets,
5+
serializeFontAssetsForTransfer,
6+
} from '../src/shared/font-assets.js';
7+
8+
describe('normalizeTransferredFontAssets', () => {
9+
it('preserves existing Uint8Array sources', () => {
10+
const fontAssets = normalizeTransferredFontAssets({
11+
faces: [{
12+
family: 'Font Awesome 6 Free',
13+
weight: '900',
14+
sources: [{
15+
format: 'ttf',
16+
mimeType: 'font/ttf',
17+
data: new Uint8Array([1, 2, 3]),
18+
}],
19+
}],
20+
});
21+
22+
expect(fontAssets?.faces).toHaveLength(1);
23+
expect(fontAssets?.faces[0].sources[0].data).toBeInstanceOf(Uint8Array);
24+
expect(Array.from(fontAssets?.faces[0].sources[0].data ?? [])).toEqual([1, 2, 3]);
25+
});
26+
27+
it('rebuilds JSON-serialized typed arrays from numeric-key objects', () => {
28+
const fontAssets = normalizeTransferredFontAssets({
29+
faces: [{
30+
family: 'Font Awesome 6 Brands',
31+
weight: '400',
32+
sources: [{
33+
format: 'ttf',
34+
mimeType: 'font/ttf',
35+
data: { 0: 9, 1: 10, 2: 255 },
36+
}],
37+
}],
38+
});
39+
40+
expect(fontAssets?.faces).toHaveLength(1);
41+
expect(fontAssets?.faces[0].sources[0].data).toBeInstanceOf(Uint8Array);
42+
expect(Array.from(fontAssets?.faces[0].sources[0].data ?? [])).toEqual([9, 10, 255]);
43+
});
44+
45+
it('drops sources with unusable byte payloads', () => {
46+
const fontAssets = normalizeTransferredFontAssets({
47+
faces: [{
48+
family: 'Broken Font',
49+
sources: [{
50+
format: 'ttf',
51+
mimeType: 'font/ttf',
52+
data: null,
53+
}],
54+
}],
55+
});
56+
57+
expect(fontAssets).toBeUndefined();
58+
});
59+
60+
it('serializes Uint8Array sources into executeScript-safe arrays', () => {
61+
const fontAssets = serializeFontAssetsForTransfer({
62+
faces: [{
63+
family: 'Font Awesome 6 Free',
64+
weight: '900',
65+
sources: [{
66+
format: 'ttf',
67+
mimeType: 'font/ttf',
68+
data: new Uint8Array([1, 2, 3]),
69+
}],
70+
}],
71+
});
72+
73+
expect(fontAssets?.faces).toHaveLength(1);
74+
expect(Array.isArray(fontAssets?.faces[0].sources[0].data)).toBe(true);
75+
expect(fontAssets?.faces[0].sources[0].data).toEqual([1, 2, 3]);
76+
});
77+
78+
it('round-trips transfer serialization back to Uint8Array', () => {
79+
const transferred = serializeFontAssetsForTransfer({
80+
faces: [{
81+
family: 'Font Awesome 6 Brands',
82+
weight: '400',
83+
sources: [{
84+
format: 'ttf',
85+
mimeType: 'font/ttf',
86+
data: new Uint8Array([9, 10, 11]),
87+
}],
88+
}],
89+
});
90+
const normalized = normalizeTransferredFontAssets(transferred);
91+
92+
expect(normalized?.faces).toHaveLength(1);
93+
expect(normalized?.faces[0].sources[0].data).toBeInstanceOf(Uint8Array);
94+
expect(Array.from(normalized?.faces[0].sources[0].data ?? [])).toEqual([9, 10, 11]);
95+
});
96+
});

tests/service-worker.test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,10 @@ describe('service-worker message handling', () => {
180180
]),
181181
}),
182182
}));
183+
184+
const sourceData = setterCall[0].args[1].fontAssets.faces[0].sources[0].data;
185+
expect(Array.isArray(sourceData)).toBe(true);
186+
expect(sourceData).toEqual([1, 2, 3, 4]);
183187
}, { timeout: 2000 });
184188
});
185189

0 commit comments

Comments
 (0)