@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/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
- let phaseStart = Date.now();
201
+ const loadAssetsPhase = trackPhase('loadAssets');
196
202
  await assetGraph.loadAssets(inputUrls);
197
- outerTimings.loadAssets = Date.now() - phaseStart;
198
- if (debug) log(`[subfont timing] loadAssets: ${outerTimings.loadAssets}ms`);
203
+ outerTimings.loadAssets = loadAssetsPhase.end();
199
204
 
200
- phaseStart = Date.now();
205
+ const populatePhase = trackPhase('populate (initial)');
201
206
  await assetGraph.populate({
202
207
  followRelations: followRelationsQuery,
203
208
  });
204
- outerTimings['populate (initial)'] = Date.now() - phaseStart;
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
- phaseStart = Date.now();
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 subsetFontsTotal = Date.now() - phaseStart;
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
- asset.url = `${assetGraph.root}subfont/${
317
- asset.baseName || 'index'
318
- }-${asset.md5Hex.slice(0, 10)}${
319
- asset.extension || asset.defaultExtension
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'] = Date.now() - phaseStart;
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
- phaseStart = Date.now();
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
- outerTimings.writeAssetsToDisc = Date.now() - phaseStart;
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
- `[subfont timing] writeAssetsToDisc: ${outerTimings.writeAssetsToDisc}ms`
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'] = Date.now() - phaseStart;
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) => _waiters.push(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
- // Serialize fontverter.convert calls the wawoff2 module (used internally by
75
- // fontverter for WOFF2 compression) has a shared WASM instance whose memory
76
- // is corrupted by concurrent calls. Only woff2 paths need this; woff and
77
- // truetype conversions use separate synchronous/JS libraries that are safe
78
- // to call in parallel.
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
- // Serialize through convertLimiter when the source is woff2 — toSfnt
234
- // would call wawoff2.decompress which isn't concurrency-safe.
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
- // Only serialize through convertLimiter when targeting woff2
283
- // woff and truetype conversions don't use wawoff2.
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
- ? convertLimiter(() =>
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);