@turntrout/subfont 1.4.0 → 1.5.1

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.
@@ -20,7 +20,7 @@ const {
20
20
  } = require('./fontFaceHelpers');
21
21
 
22
22
  const fontRelevantCssRegex =
23
- /font-family|font-weight|font-style|font-stretch|font-display|font-size|@font-face|font-variation|font-feature/i;
23
+ /font-family|font-weight|font-style|font-stretch|font-display|@font-face|font-variation|font-feature/i;
24
24
 
25
25
  // font-tracer defaults omit font-size. We need it for opsz axis mapping.
26
26
  const PROPS_TO_RETURN = [
@@ -4,7 +4,9 @@ const os = require('os');
4
4
  const WORKER_MEMORY_BYTES = 50 * 1024 * 1024; // 50 MB
5
5
 
6
6
  function getMaxConcurrency() {
7
- return Math.max(1, Math.floor(os.totalmem() / WORKER_MEMORY_BYTES));
7
+ const byMemory = Math.floor(os.freemem() / WORKER_MEMORY_BYTES);
8
+ const byCpu = os.cpus().length * 4;
9
+ return Math.max(1, Math.min(byMemory, byCpu));
8
10
  }
9
11
 
10
12
  module.exports = { WORKER_MEMORY_BYTES, getMaxConcurrency };
@@ -111,7 +111,7 @@ module.exports = function parseCommandLineOptions(argv) {
111
111
  },
112
112
  })
113
113
  .options('concurrency', {
114
- describe: `Maximum number of worker threads for parallel font tracing. Defaults to the number of CPU cores (max 8). Upper bound: ${maxConcurrency} (based on available memory)`,
114
+ describe: `Maximum number of worker threads for parallel font tracing. Defaults to the number of CPU cores (max 8). Upper bound: ${maxConcurrency} (based on free memory and CPU count)`,
115
115
  type: 'number',
116
116
  })
117
117
  .check((argv) => {
@@ -121,7 +121,7 @@ module.exports = function parseCommandLineOptions(argv) {
121
121
  }
122
122
  if (argv.concurrency > maxConcurrency) {
123
123
  throw new Error(
124
- `--concurrency must not exceed ${maxConcurrency} (each worker uses ~50 MB; system has ${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB total)`
124
+ `--concurrency must not exceed ${maxConcurrency} (each worker uses ~50 MB; ${Math.round(os.freemem() / (1024 * 1024 * 1024))} GB free, ${os.cpus().length} CPUs)`
125
125
  );
126
126
  }
127
127
  }
package/lib/subfont.js CHANGED
@@ -49,7 +49,7 @@ module.exports = async function subfont(
49
49
  const maxConcurrency = getMaxConcurrency();
50
50
  if (concurrency !== undefined && concurrency > maxConcurrency) {
51
51
  throw new UsageError(
52
- `--concurrency must not exceed ${maxConcurrency} (each worker uses ~50 MB; system has ${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB total)`
52
+ `--concurrency must not exceed ${maxConcurrency} (each worker uses ~50 MB; ${Math.round(os.freemem() / (1024 * 1024 * 1024))} GB free, ${os.cpus().length} CPUs)`
53
53
  );
54
54
  }
55
55
 
@@ -29,7 +29,7 @@ function compileModule() {
29
29
 
30
30
  const _pool = []; // Array of { exports, busy: boolean }
31
31
  let _poolReady;
32
- const POOL_SIZE = Math.min(os.cpus().length, 8);
32
+ const POOL_SIZE = Math.max(1, Math.min(os.cpus().length, 8));
33
33
 
34
34
  async function initPool() {
35
35
  if (!_poolReady) {
@@ -73,9 +73,15 @@ function releaseInstance(inst) {
73
73
 
74
74
  // Serialize fontverter.convert calls — the wawoff2 module (used internally by
75
75
  // fontverter for WOFF2 compression) has a shared WASM instance whose memory
76
- // is corrupted by concurrent calls.
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.
77
79
  const convertLimiter = require('p-limit')(1);
78
80
 
81
+ function usesWawoff2(buffer) {
82
+ return buffer.length >= 4 && buffer.toString('ascii', 0, 4) === 'wOF2';
83
+ }
84
+
79
85
  // Re-create on every call — WASM memory.buffer is detached when memory grows,
80
86
  // so a cached Uint8Array would silently read/write stale data.
81
87
  function getHeapu8(exports) {
@@ -224,7 +230,11 @@ async function subsetFontWithGlyphs(
224
230
  ) {
225
231
  // Reuse cached sfnt conversion when available (same buffer may have
226
232
  // been converted by getFontInfo or collectFeatureGlyphIds already).
227
- const ttf = await toSfnt(originalFont);
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);
228
238
 
229
239
  const inst = await acquireInstance();
230
240
  const { exports } = inst;
@@ -269,12 +279,19 @@ async function subsetFontWithGlyphs(
269
279
  released = true;
270
280
  releaseInstance(inst);
271
281
 
272
- return convertLimiter(() =>
273
- fontverter.convert(subsetFont, targetFormat, 'truetype')
274
- );
282
+ // Only serialize through convertLimiter when targeting woff2 —
283
+ // woff and truetype conversions don't use wawoff2.
284
+ return targetFormat === 'woff2'
285
+ ? convertLimiter(() =>
286
+ fontverter.convert(subsetFont, targetFormat, 'truetype')
287
+ )
288
+ : fontverter.convert(subsetFont, targetFormat, 'truetype');
275
289
  } finally {
276
290
  if (!released) releaseInstance(inst);
277
291
  }
278
292
  }
279
293
 
294
+ // Pre-warm the WASM pool: call early to overlap compilation with other work.
295
+ subsetFontWithGlyphs.warmup = () => initPool();
296
+
280
297
  module.exports = subsetFontWithGlyphs;
@@ -28,6 +28,7 @@ const {
28
28
  } = require('./fontFaceHelpers');
29
29
  const { getVariationAxisUsage } = require('./variationAxes');
30
30
  const { getSubsetsForFontUsage } = require('./subsetGeneration');
31
+ const subsetFontWithGlyphs = require('./subsetFontWithGlyphs');
31
32
 
32
33
  const googleFontsCssUrlRegex = /^(?:https?:)?\/\/fonts\.googleapis\.com\/css/;
33
34
 
@@ -385,6 +386,13 @@ async function subsetFonts(
385
386
  fontDisplay = undefined;
386
387
  }
387
388
 
389
+ // Pre-warm the WASM pool: start compiling harfbuzz WASM while
390
+ // collectTextsByPage traces fonts. Compilation (~50-200ms) overlaps
391
+ // with tracing work rather than appearing on the critical path.
392
+ // Catch silently — the error will surface when subsetFontWithGlyphs
393
+ // is actually called, where it's properly handled.
394
+ subsetFontWithGlyphs.warmup().catch(() => {});
395
+
388
396
  const subsetUrl = urltools.ensureTrailingSlash(assetGraph.root + subsetPath);
389
397
 
390
398
  const timings = {};
@@ -9,6 +9,20 @@ const subsetFontWithGlyphs = require('./subsetFontWithGlyphs');
9
9
  // entries (e.g. after adding hinting removal or table stripping).
10
10
  const SUBSET_CACHE_VERSION = '2';
11
11
 
12
+ // Cache the SHA-256 hash state after feeding SUBSET_CACHE_VERSION + fontBuffer.
13
+ // For a font with 2 target formats this halves the hashing work on large buffers.
14
+ // Uses WeakMap so entries are garbage-collected when the buffer is released.
15
+ const fontBufferHashPrefixes = new WeakMap();
16
+ function getFontBufferHashPrefix(fontBuffer) {
17
+ if (!fontBufferHashPrefixes.has(fontBuffer)) {
18
+ const hash = crypto.createHash('sha256');
19
+ hash.update(SUBSET_CACHE_VERSION);
20
+ hash.update(fontBuffer);
21
+ fontBufferHashPrefixes.set(fontBuffer, hash);
22
+ }
23
+ return fontBufferHashPrefixes.get(fontBuffer);
24
+ }
25
+
12
26
  function subsetCacheKey(
13
27
  fontBuffer,
14
28
  text,
@@ -16,9 +30,10 @@ function subsetCacheKey(
16
30
  variationAxes,
17
31
  featureGlyphIds
18
32
  ) {
19
- const hash = crypto.createHash('sha256');
20
- hash.update(SUBSET_CACHE_VERSION);
21
- hash.update(fontBuffer);
33
+ // Clone the pre-computed prefix (version + font buffer) and append
34
+ // the remaining fields. hash.copy() is O(1) — just copies the
35
+ // internal digest state, avoiding re-hashing the entire font buffer.
36
+ const hash = getFontBufferHashPrefix(fontBuffer).copy();
22
37
  hash.update(text);
23
38
  hash.update(targetFormat);
24
39
  if (variationAxes) hash.update(JSON.stringify(variationAxes));
@@ -163,85 +178,99 @@ async function getSubsetsForFontUsage(
163
178
  const subsetPromiseMap = new Map();
164
179
  const subsetInfoByFontUrl = new Map();
165
180
 
166
- for (const [fontUrl, fontUsage] of canonicalFontUsageByUrl) {
167
- const fontBuffer = originalFontBuffers.get(fontUrl);
168
- if (!fontBuffer) continue;
169
- const text = fontUsage.text;
181
+ // Process fonts concurrently each font's feature glyph collection
182
+ // and subset queuing run in parallel, so fonts without feature settings
183
+ // don't wait behind fonts that need collectFeatureGlyphIds.
184
+ await Promise.all(
185
+ [...canonicalFontUsageByUrl].map(async ([fontUrl, fontUsage]) => {
186
+ const fontBuffer = originalFontBuffers.get(fontUrl);
187
+ if (!fontBuffer) return;
188
+ const text = fontUsage.text;
170
189
 
171
- const bounds = variationAxisBoundsCache.get(fontUrl);
172
- const subsetInfo = bounds
173
- ? {
174
- variationAxes: bounds.variationAxes,
175
- fullyInstanced: bounds.fullyInstanced,
176
- numAxesPinned: bounds.numAxesPinned,
177
- numAxesReduced: bounds.numAxesReduced,
190
+ const bounds = variationAxisBoundsCache.get(fontUrl);
191
+ const subsetInfo = bounds
192
+ ? {
193
+ variationAxes: bounds.variationAxes,
194
+ fullyInstanced: bounds.fullyInstanced,
195
+ numAxesPinned: bounds.numAxesPinned,
196
+ numAxesReduced: bounds.numAxesReduced,
197
+ }
198
+ : {
199
+ variationAxes: undefined,
200
+ fullyInstanced: false,
201
+ numAxesPinned: 0,
202
+ numAxesReduced: 0,
203
+ };
204
+ subsetInfoByFontUrl.set(fontUrl, subsetInfo);
205
+
206
+ let featureGlyphIds;
207
+ if (fontUsage.hasFontFeatureSettings && fontBuffer) {
208
+ try {
209
+ featureGlyphIds = await collectFeatureGlyphIds(
210
+ fontBuffer,
211
+ text,
212
+ fontUsage.fontFeatureTags
213
+ );
214
+ } catch (err) {
215
+ // Feature glyph collection failed — continue without feature
216
+ // glyphs rather than blocking all fonts (Promise.all would
217
+ // reject entirely if this propagated).
218
+ err.asset = err.asset || fontAssetsByUrl.get(fontUrl);
219
+ assetGraph.warn(err);
178
220
  }
179
- : {
180
- variationAxes: undefined,
181
- fullyInstanced: false,
182
- numAxesPinned: 0,
183
- numAxesReduced: 0,
184
- };
185
- subsetInfoByFontUrl.set(fontUrl, subsetInfo);
221
+ }
186
222
 
187
- let featureGlyphIds;
188
- if (fontUsage.hasFontFeatureSettings && fontBuffer) {
189
- featureGlyphIds = await collectFeatureGlyphIds(
190
- fontBuffer,
191
- text,
192
- fontUsage.fontFeatureTags
193
- );
194
- }
223
+ for (const targetFormat of formats) {
224
+ const promiseId = getSubsetPromiseId(
225
+ fontUsage,
226
+ targetFormat,
227
+ subsetInfo.variationAxes
228
+ );
195
229
 
196
- for (const targetFormat of formats) {
197
- const promiseId = getSubsetPromiseId(
198
- fontUsage,
199
- targetFormat,
200
- subsetInfo.variationAxes
201
- );
230
+ if (!subsetPromiseMap.has(promiseId)) {
231
+ const cacheKey = diskCache
232
+ ? subsetCacheKey(
233
+ fontBuffer,
234
+ text,
235
+ targetFormat,
236
+ subsetInfo.variationAxes,
237
+ featureGlyphIds
238
+ )
239
+ : null;
240
+ const cachedResult = diskCache ? await diskCache.get(cacheKey) : null;
202
241
 
203
- if (!subsetPromiseMap.has(promiseId)) {
204
- const cacheKey = diskCache
205
- ? subsetCacheKey(
206
- fontBuffer,
207
- text,
242
+ if (cachedResult) {
243
+ subsetPromiseMap.set(promiseId, Promise.resolve(cachedResult));
244
+ } else {
245
+ const subsetCall = subsetFontWithGlyphs(fontBuffer, text, {
208
246
  targetFormat,
209
- subsetInfo.variationAxes,
210
- featureGlyphIds
211
- )
212
- : null;
213
- const cachedResult = diskCache ? await diskCache.get(cacheKey) : null;
247
+ glyphIds: featureGlyphIds,
248
+ variationAxes: subsetInfo.variationAxes,
249
+ });
214
250
 
215
- if (cachedResult) {
216
- subsetPromiseMap.set(promiseId, Promise.resolve(cachedResult));
217
- } else {
218
- const subsetCall = subsetFontWithGlyphs(fontBuffer, text, {
219
- targetFormat,
220
- glyphIds: featureGlyphIds,
221
- variationAxes: subsetInfo.variationAxes,
222
- });
223
-
224
- subsetPromiseMap.set(
225
- promiseId,
226
- subsetCall
227
- .then(async (result) => {
228
- if (diskCache && result) {
229
- // Fire-and-forget: cache writes are best-effort.
230
- // Errors are handled inside set(); the catch is a
231
- // safety net against unhandled rejections.
232
- diskCache.set(cacheKey, result).catch(() => {});
233
- }
234
- return result;
235
- })
236
- .catch((err) => {
237
- err.asset = err.asset || fontAssetsByUrl.get(fontUrl);
238
- assetGraph.warn(err);
239
- })
240
- );
251
+ subsetPromiseMap.set(
252
+ promiseId,
253
+ subsetCall
254
+ .then(async (result) => {
255
+ if (diskCache && result) {
256
+ // Fire-and-forget: cache writes are best-effort.
257
+ // Errors are handled inside set(); the catch is a
258
+ // safety net against unhandled rejections.
259
+ diskCache.set(cacheKey, result).catch(() => {});
260
+ }
261
+ return result;
262
+ })
263
+ .catch((err) => {
264
+ err.asset = err.asset || fontAssetsByUrl.get(fontUrl);
265
+ assetGraph.warn(err);
266
+ return null;
267
+ })
268
+ );
269
+ }
241
270
  }
242
271
  }
243
- }
244
- }
272
+ })
273
+ );
245
274
 
246
275
  // Await all subset promises
247
276
  const resolvedSubsets = new Map(
package/lib/wasmQueue.js CHANGED
@@ -5,7 +5,10 @@
5
5
  let queue = Promise.resolve();
6
6
 
7
7
  function enqueue(fn) {
8
- return (queue = queue.then(fn, fn));
8
+ return (queue = queue.then(
9
+ () => fn(),
10
+ () => fn()
11
+ ));
9
12
  }
10
13
 
11
14
  module.exports = enqueue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turntrout/subfont",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Automatically subset web fonts to only the characters used on your pages. Fork of Munter/subfont with modern defaults.",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"