@turntrout/subfont 1.5.1 → 1.7.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/CHANGELOG.md +7 -0
- package/README.md +52 -21
- package/lib/FontTracerPool.js +49 -1
- package/lib/HeadlessBrowser.js +11 -3
- package/lib/collectTextsByPage.js +496 -651
- package/lib/concurrencyLimit.js +3 -1
- package/lib/escapeJsStringLiteral.js +13 -0
- package/lib/extractVisibleText.js +6 -2
- package/lib/fontConverter.js +25 -0
- package/lib/fontConverterWorker.js +16 -0
- package/lib/fontFaceHelpers.js +16 -4
- package/lib/fontFeatureHelpers.js +249 -0
- package/lib/fontTracerWorker.js +0 -10
- package/lib/gatherStylesheetsWithPredicates.js +4 -5
- package/lib/normalizeFontPropertyValue.js +1 -1
- package/lib/progress.js +101 -0
- package/lib/sfntCache.js +10 -7
- package/lib/subfont.js +80 -52
- package/lib/subsetFontWithGlyphs.js +41 -22
- package/lib/subsetFonts.js +223 -211
- package/lib/subsetGeneration.js +13 -1
- package/lib/unquote.js +9 -4
- package/lib/variationAxes.js +3 -32
- package/lib/warnAboutMissingGlyphs.js +36 -25
- package/lib/wasmQueue.js +6 -2
- package/package.json +2 -2
package/lib/subfont.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
const fsPromises = require('fs/promises');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const pathModule = require('path');
|
|
4
|
+
const sanitizeFilename = require('sanitize-filename');
|
|
4
5
|
const { getMaxConcurrency } = require('./concurrencyLimit');
|
|
5
6
|
const AssetGraph = require('assetgraph');
|
|
6
7
|
const prettyBytes = require('pretty-bytes');
|
|
7
8
|
const urlTools = require('urltools');
|
|
8
9
|
const util = require('util');
|
|
9
10
|
const subsetFonts = require('./subsetFonts');
|
|
11
|
+
const { makePhaseTracker } = require('./progress');
|
|
10
12
|
|
|
11
13
|
class UsageError extends Error {
|
|
12
14
|
constructor(message) {
|
|
@@ -191,21 +193,20 @@ module.exports = async function subfont(
|
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
const outerTimings = {};
|
|
196
|
+
// The tracker writes with console.log (duck-typed). Route it through
|
|
197
|
+
// the silent-aware log wrapper so --silent suppresses phase markers
|
|
198
|
+
// the same way it suppresses other subfont output.
|
|
199
|
+
const trackPhase = makePhaseTracker({ log }, debug);
|
|
194
200
|
|
|
195
|
-
|
|
201
|
+
const loadAssetsPhase = trackPhase('loadAssets');
|
|
196
202
|
await assetGraph.loadAssets(inputUrls);
|
|
197
|
-
outerTimings.loadAssets =
|
|
198
|
-
if (debug) log(`[subfont timing] loadAssets: ${outerTimings.loadAssets}ms`);
|
|
203
|
+
outerTimings.loadAssets = loadAssetsPhase.end();
|
|
199
204
|
|
|
200
|
-
|
|
205
|
+
const populatePhase = trackPhase('populate (initial)');
|
|
201
206
|
await assetGraph.populate({
|
|
202
207
|
followRelations: followRelationsQuery,
|
|
203
208
|
});
|
|
204
|
-
outerTimings['populate (initial)'] =
|
|
205
|
-
if (debug)
|
|
206
|
-
log(
|
|
207
|
-
`[subfont timing] populate (initial): ${outerTimings['populate (initial)']}ms`
|
|
208
|
-
);
|
|
209
|
+
outerTimings['populate (initial)'] = populatePhase.end();
|
|
209
210
|
|
|
210
211
|
const entrypointAssets = assetGraph.findAssets({ isInitial: true });
|
|
211
212
|
const redirectOrigins = new Set();
|
|
@@ -269,7 +270,7 @@ module.exports = async function subfont(
|
|
|
269
270
|
);
|
|
270
271
|
}
|
|
271
272
|
|
|
272
|
-
|
|
273
|
+
const subsetPhase = trackPhase('subsetFonts total');
|
|
273
274
|
const { fontInfo, timings: subsetTimings } = await subsetFonts(assetGraph, {
|
|
274
275
|
inlineCss,
|
|
275
276
|
fontDisplay,
|
|
@@ -285,11 +286,9 @@ module.exports = async function subfont(
|
|
|
285
286
|
chromeArgs: chromeFlags,
|
|
286
287
|
cacheDir,
|
|
287
288
|
});
|
|
289
|
+
const subsetFontsTotal = subsetPhase.end();
|
|
288
290
|
|
|
289
|
-
const
|
|
290
|
-
if (debug) log(`[subfont timing] subsetFonts total: ${subsetFontsTotal}ms`);
|
|
291
|
-
|
|
292
|
-
phaseStart = Date.now();
|
|
291
|
+
const postProcessingPhase = trackPhase('post-subsetFonts processing');
|
|
293
292
|
let sumSizesAfter = 0;
|
|
294
293
|
for (const asset of assetGraph.findAssets(sizeableAssetQuery)) {
|
|
295
294
|
sumSizesAfter += asset.rawSrc.length;
|
|
@@ -313,11 +312,12 @@ module.exports = async function subfont(
|
|
|
313
312
|
assetGraph.info(
|
|
314
313
|
new Error(`Pulling down modified stylesheet ${asset.url}`)
|
|
315
314
|
);
|
|
316
|
-
|
|
317
|
-
asset.baseName || 'index'
|
|
318
|
-
}-${asset.md5Hex.slice(
|
|
319
|
-
|
|
320
|
-
|
|
315
|
+
const safeName =
|
|
316
|
+
sanitizeFilename(asset.baseName || '', { replacement: '_' }) || 'index';
|
|
317
|
+
asset.url = `${assetGraph.root}subfont/${safeName}-${asset.md5Hex.slice(
|
|
318
|
+
0,
|
|
319
|
+
10
|
|
320
|
+
)}${asset.extension || asset.defaultExtension}`;
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
@@ -343,11 +343,7 @@ module.exports = async function subfont(
|
|
|
343
343
|
);
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
-
outerTimings['post-subsetFonts processing'] =
|
|
347
|
-
if (debug)
|
|
348
|
-
log(
|
|
349
|
-
`[subfont timing] post-subsetFonts processing: ${outerTimings['post-subsetFonts processing']}ms`
|
|
350
|
-
);
|
|
346
|
+
outerTimings['post-subsetFonts processing'] = postProcessingPhase.end();
|
|
351
347
|
|
|
352
348
|
if (strict && sawWarning) {
|
|
353
349
|
// In non-silent mode, assetgraph's logEvents normally exits earlier via
|
|
@@ -358,7 +354,7 @@ module.exports = async function subfont(
|
|
|
358
354
|
);
|
|
359
355
|
}
|
|
360
356
|
|
|
361
|
-
|
|
357
|
+
const writePhase = trackPhase('writeAssetsToDisc');
|
|
362
358
|
if (!dryRun) {
|
|
363
359
|
await assetGraph.writeAssetsToDisc(
|
|
364
360
|
{
|
|
@@ -370,31 +366,67 @@ module.exports = async function subfont(
|
|
|
370
366
|
assetGraph.root
|
|
371
367
|
);
|
|
372
368
|
}
|
|
369
|
+
outerTimings.writeAssetsToDisc = writePhase.end();
|
|
373
370
|
|
|
374
|
-
|
|
375
|
-
if (debug)
|
|
371
|
+
const reportingPhase = trackPhase('output reporting');
|
|
372
|
+
if (debug) {
|
|
373
|
+
// One entry per unique (fontUrl, props) variant. A variable-font URL can
|
|
374
|
+
// back multiple variants, so fontUrl alone is too coarse. Codepoint unions
|
|
375
|
+
// and subset sizes are per-font, so the remaining per-page variation
|
|
376
|
+
// worth surfacing is just which pages reference the variant.
|
|
377
|
+
const SAMPLE_PAGES = 5;
|
|
378
|
+
const byVariant = new Map();
|
|
379
|
+
for (const { assetFileName, fontUsages } of fontInfo) {
|
|
380
|
+
for (const fu of fontUsages) {
|
|
381
|
+
const p = fu.props || {};
|
|
382
|
+
const key = [
|
|
383
|
+
fu.fontUrl || '[inline]',
|
|
384
|
+
p['font-family'],
|
|
385
|
+
p['font-weight'],
|
|
386
|
+
p['font-style'],
|
|
387
|
+
p['font-stretch'],
|
|
388
|
+
].join('\0');
|
|
389
|
+
let entry = byVariant.get(key);
|
|
390
|
+
if (!entry) {
|
|
391
|
+
entry = {
|
|
392
|
+
fontUrl: fu.fontUrl,
|
|
393
|
+
props: fu.props,
|
|
394
|
+
preload: fu.preload,
|
|
395
|
+
fullyInstanced: fu.fullyInstanced,
|
|
396
|
+
numAxesPinned: fu.numAxesPinned,
|
|
397
|
+
numAxesReduced: fu.numAxesReduced,
|
|
398
|
+
smallestOriginalFormat: fu.smallestOriginalFormat,
|
|
399
|
+
smallestSubsetFormat: fu.smallestSubsetFormat,
|
|
400
|
+
smallestOriginalSize: fu.smallestOriginalSize,
|
|
401
|
+
smallestSubsetSize: fu.smallestSubsetSize,
|
|
402
|
+
codepoints: fu.codepoints
|
|
403
|
+
? {
|
|
404
|
+
original: fu.codepoints.original.length,
|
|
405
|
+
used: fu.codepoints.used.length,
|
|
406
|
+
unused: fu.codepoints.unused.length,
|
|
407
|
+
}
|
|
408
|
+
: undefined,
|
|
409
|
+
pageCount: 0,
|
|
410
|
+
samplePages: [],
|
|
411
|
+
};
|
|
412
|
+
byVariant.set(key, entry);
|
|
413
|
+
}
|
|
414
|
+
entry.pageCount += 1;
|
|
415
|
+
if (entry.samplePages.length < SAMPLE_PAGES) {
|
|
416
|
+
entry.samplePages.push(assetFileName);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const entry of byVariant.values()) {
|
|
421
|
+
const remaining = entry.pageCount - entry.samplePages.length;
|
|
422
|
+
if (remaining > 0) {
|
|
423
|
+
entry.samplePages.push(`...and ${remaining} more`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
376
426
|
log(
|
|
377
|
-
`
|
|
427
|
+
`Font variants (aggregated across ${fontInfo.length} page${fontInfo.length === 1 ? '' : 's'}):`
|
|
378
428
|
);
|
|
379
|
-
|
|
380
|
-
phaseStart = Date.now();
|
|
381
|
-
if (debug) {
|
|
382
|
-
const compactFontInfo = fontInfo.map(({ fontUsages, ...rest }) => ({
|
|
383
|
-
...rest,
|
|
384
|
-
fontUsages: fontUsages.map(({ codepoints, texts, ...fu }) => ({
|
|
385
|
-
...fu,
|
|
386
|
-
codepoints: codepoints
|
|
387
|
-
? {
|
|
388
|
-
original: `[${codepoints.original.length} codepoints]`,
|
|
389
|
-
used: `[${codepoints.used.length} codepoints]`,
|
|
390
|
-
unused: `[${codepoints.unused.length} codepoints]`,
|
|
391
|
-
page: `[${codepoints.page.length} codepoints]`,
|
|
392
|
-
}
|
|
393
|
-
: undefined,
|
|
394
|
-
texts: texts ? `[${texts.length} entries]` : undefined,
|
|
395
|
-
})),
|
|
396
|
-
}));
|
|
397
|
-
log(util.inspect(compactFontInfo, false, 99));
|
|
429
|
+
log(util.inspect([...byVariant.values()], false, 99));
|
|
398
430
|
}
|
|
399
431
|
|
|
400
432
|
let totalSavings = sumSizesBefore - sumSizesAfter;
|
|
@@ -494,11 +526,7 @@ module.exports = async function subfont(
|
|
|
494
526
|
)}`
|
|
495
527
|
);
|
|
496
528
|
log(`Total savings: ${prettyBytes(totalSavings)}`);
|
|
497
|
-
outerTimings['output reporting'] =
|
|
498
|
-
if (debug)
|
|
499
|
-
log(
|
|
500
|
-
`[subfont timing] output reporting: ${outerTimings['output reporting']}ms`
|
|
501
|
-
);
|
|
529
|
+
outerTimings['output reporting'] = reportingPhase.end();
|
|
502
530
|
|
|
503
531
|
const st = subsetTimings || {};
|
|
504
532
|
const details = st.collectTextsByPageDetails || {};
|
|
@@ -2,6 +2,7 @@ const os = require('os');
|
|
|
2
2
|
const { readFile } = require('fs').promises;
|
|
3
3
|
const fontverter = require('fontverter');
|
|
4
4
|
const { toSfnt } = require('./sfntCache');
|
|
5
|
+
const { convert: convertInWorker } = require('./fontConverter');
|
|
5
6
|
|
|
6
7
|
// hb_subset_sets_t enum values — https://github.com/harfbuzz/harfbuzz/blob/main/src/hb-subset.h
|
|
7
8
|
const HB_SUBSET_SETS_GLYPH_INDEX = 0;
|
|
@@ -51,6 +52,7 @@ async function initPool() {
|
|
|
51
52
|
|
|
52
53
|
// Waiters queue: callers waiting for an idle WASM instance.
|
|
53
54
|
const _waiters = [];
|
|
55
|
+
const ACQUIRE_TIMEOUT_MS = 120_000;
|
|
54
56
|
|
|
55
57
|
async function acquireInstance() {
|
|
56
58
|
await initPool();
|
|
@@ -60,7 +62,23 @@ async function acquireInstance() {
|
|
|
60
62
|
return idle;
|
|
61
63
|
}
|
|
62
64
|
// All instances busy — wait for one to be released.
|
|
63
|
-
return new Promise((resolve) =>
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
const idx = _waiters.indexOf(entry);
|
|
68
|
+
if (idx !== -1) _waiters.splice(idx, 1);
|
|
69
|
+
reject(
|
|
70
|
+
new Error(
|
|
71
|
+
`Timed out waiting for a WASM subsetting instance after ${ACQUIRE_TIMEOUT_MS}ms`
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}, ACQUIRE_TIMEOUT_MS);
|
|
75
|
+
timer.unref();
|
|
76
|
+
const entry = (inst) => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
resolve(inst);
|
|
79
|
+
};
|
|
80
|
+
_waiters.push(entry);
|
|
81
|
+
});
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
function releaseInstance(inst) {
|
|
@@ -71,16 +89,11 @@ function releaseInstance(inst) {
|
|
|
71
89
|
}
|
|
72
90
|
}
|
|
73
91
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
const convertLimiter = require('p-limit')(1);
|
|
80
|
-
|
|
81
|
-
function usesWawoff2(buffer) {
|
|
82
|
-
return buffer.length >= 4 && buffer.toString('ascii', 0, 4) === 'wOF2';
|
|
83
|
-
}
|
|
92
|
+
// woff2 encode/decode uses wawoff2's WASM module, which has a shared
|
|
93
|
+
// instance that corrupts memory under concurrent use. Instead of
|
|
94
|
+
// serializing to p-limit(1) in the main thread, we route woff2
|
|
95
|
+
// operations through fontConverterPool — each worker thread loads its
|
|
96
|
+
// own wawoff2 instance, enabling safe parallel compression.
|
|
84
97
|
|
|
85
98
|
// Re-create on every call — WASM memory.buffer is detached when memory grows,
|
|
86
99
|
// so a cached Uint8Array would silently read/write stale data.
|
|
@@ -88,9 +101,11 @@ function getHeapu8(exports) {
|
|
|
88
101
|
return new Uint8Array(exports.memory.buffer);
|
|
89
102
|
}
|
|
90
103
|
|
|
104
|
+
// >>> 0 keeps the accumulator unsigned; without it, tags whose first byte
|
|
105
|
+
// exceeds 0x7F would overflow into negative i32 territory after << 24.
|
|
91
106
|
function HB_TAG(str) {
|
|
92
107
|
return str.split('').reduce(function (a, ch) {
|
|
93
|
-
return (a << 8) + ch.charCodeAt(0);
|
|
108
|
+
return ((a << 8) >>> 0) + ch.charCodeAt(0);
|
|
94
109
|
}, 0);
|
|
95
110
|
}
|
|
96
111
|
|
|
@@ -216,6 +231,14 @@ function extractSubsetFont(exports, subset) {
|
|
|
216
231
|
// Fresh view AFTER the WASM calls above — memory.buffer may have been
|
|
217
232
|
// detached by a grow during hb_face_reference_blob / hb_blob_get_data.
|
|
218
233
|
const heapu8 = getHeapu8(exports);
|
|
234
|
+
|
|
235
|
+
if (offset + subsetByteLength > heapu8.byteLength) {
|
|
236
|
+
exports.hb_blob_destroy(result);
|
|
237
|
+
throw new Error(
|
|
238
|
+
`WASM returned out-of-bounds offset ${offset} + length ${subsetByteLength} (heap size ${heapu8.byteLength})`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
219
242
|
const subsetFont = Buffer.from(
|
|
220
243
|
heapu8.subarray(offset, offset + subsetByteLength)
|
|
221
244
|
);
|
|
@@ -230,11 +253,8 @@ async function subsetFontWithGlyphs(
|
|
|
230
253
|
) {
|
|
231
254
|
// Reuse cached sfnt conversion when available (same buffer may have
|
|
232
255
|
// been converted by getFontInfo or collectFeatureGlyphIds already).
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
const ttf = usesWawoff2(originalFont)
|
|
236
|
-
? await convertLimiter(() => toSfnt(originalFont))
|
|
237
|
-
: await toSfnt(originalFont);
|
|
256
|
+
// sfntCache routes woff2 decompression through the worker pool.
|
|
257
|
+
const ttf = await toSfnt(originalFont);
|
|
238
258
|
|
|
239
259
|
const inst = await acquireInstance();
|
|
240
260
|
const { exports } = inst;
|
|
@@ -279,12 +299,11 @@ async function subsetFontWithGlyphs(
|
|
|
279
299
|
released = true;
|
|
280
300
|
releaseInstance(inst);
|
|
281
301
|
|
|
282
|
-
//
|
|
283
|
-
//
|
|
302
|
+
// Route woff2 compression to a worker thread (each spawns its own
|
|
303
|
+
// wawoff2 WASM instance). Non-woff2 formats use JS-based converters
|
|
304
|
+
// that are safe to call concurrently in the main thread.
|
|
284
305
|
return targetFormat === 'woff2'
|
|
285
|
-
?
|
|
286
|
-
fontverter.convert(subsetFont, targetFormat, 'truetype')
|
|
287
|
-
)
|
|
306
|
+
? convertInWorker(subsetFont, targetFormat, 'truetype')
|
|
288
307
|
: fontverter.convert(subsetFont, targetFormat, 'truetype');
|
|
289
308
|
} finally {
|
|
290
309
|
if (!released) releaseInstance(inst);
|