@turntrout/subfont 1.5.1 → 1.6.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 CHANGED
@@ -33,3 +33,10 @@ On [TurnTrout.com](https://github.com/alexander-turner/TurnTrout.com) (382 pages
33
33
 
34
34
  - Fixed crash on invalid/corrupt font files during instancing.
35
35
  - Fixed incorrect axis range computation for variable fonts.
36
+ - Fixed OOM / >1h runtimes on large sites. `font-size` was added to
37
+ `font-tracer`'s `propsToReturn` to derive `opsz`, which bucketed every text
38
+ chunk by size and exploded per-page entry counts 10-50x on sites with many
39
+ distinct sizes (headings, dropcaps, smallcaps). `opsz` now falls back to
40
+ pinning at the font default (the pre-regression behaviour); an explicit
41
+ `font-variation-settings: "opsz" …` still narrows the axis. TurnTrout.com
42
+ returned from 46+ min / runner-OOM to ~33 min.
package/README.md CHANGED
@@ -105,6 +105,37 @@ const assetGraph = await subfont(
105
105
 
106
106
  Returns the [Assetgraph](https://github.com/assetgraph/assetgraph) instance.
107
107
 
108
+ ### Parameters
109
+
110
+ `subfont(options, console)` — the second argument is an optional logger (anything
111
+ with `log`, `warn`, and `error` methods — e.g. the global `console`). Pass
112
+ `null` together with `silent: true` to suppress all output.
113
+
114
+ The `options` object accepts the following keys:
115
+
116
+ | Option | Type | Default | Description |
117
+ | --------------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------ |
118
+ | `inputFiles` | `string[]` | `[]` | HTML entry points (file paths or URLs). At least one is required unless `root` is given. |
119
+ | `root` | `string` | deduced | Path or URL to the web root. Deduced from `inputFiles` if omitted. |
120
+ | `canonicalRoot` | `string` | — | URI root where the site will be deployed (used to rewrite absolute URLs). |
121
+ | `output` | `string` | — | Output directory. Mutually exclusive with `inPlace`. |
122
+ | `inPlace` | `boolean` | `false` | Modify input files in place. |
123
+ | `dryRun` | `boolean` | `false` | Trace and compute subsets but do not write any files. |
124
+ | `recursive` | `boolean` | `false` | Crawl linked pages starting from `inputFiles`. |
125
+ | `dynamic` | `boolean` | `false` | Trace JS-rendered content in headless Chrome (via puppeteer). |
126
+ | `fallbacks` | `boolean` | `true` | Keep the full original font for characters outside the subset. |
127
+ | `fontDisplay` | `string` | `'swap'` | `font-display` CSS value: `auto`, `block`, `swap`, `fallback`, or `optional`. |
128
+ | `text` | `string` | — | Extra characters to include in every subset. |
129
+ | `inlineCss` | `boolean` | `false` | Inline the subset `@font-face` CSS into the HTML document. |
130
+ | `relativeUrls` | `boolean` | `false` | Emit relative URLs instead of root-relative URLs. |
131
+ | `sourceMaps` | `boolean` | `false` | Preserve CSS source maps (slower). |
132
+ | `concurrency` | `number` | auto | Max parallel tracing workers. Capped by available memory (~50 MB per worker). |
133
+ | `chromeFlags` | `string[]` | `[]` | Extra Chrome flags forwarded to puppeteer when `dynamic` is set. |
134
+ | `cache` | `boolean \| string` | `false` | Cache subset results between runs. Pass a path to customize the cache directory; `true` uses the OS tmp dir. |
135
+ | `strict` | `boolean` | `false` | Resolve with a non-zero exit (via the CLI) if any warnings are emitted. |
136
+ | `silent` | `boolean` | `false` | Suppress all log output to `console`. |
137
+ | `debug` | `boolean` | `false` | Emit verbose timing and glyph-detection info. |
138
+
108
139
  ## License
109
140
 
110
141
  MIT -- Original work by [Peter Muller (Munter)](https://github.com/Munter/subfont)
@@ -18,22 +18,15 @@ const {
18
18
  uniqueChars,
19
19
  uniqueCharsFromArray,
20
20
  } = require('./fontFaceHelpers');
21
+ const {
22
+ createPageProgress,
23
+ logTracedPage,
24
+ makePhaseTracker,
25
+ } = require('./progress');
21
26
 
22
27
  const fontRelevantCssRegex =
23
28
  /font-family|font-weight|font-style|font-stretch|font-display|@font-face|font-variation|font-feature/i;
24
29
 
25
- // font-tracer defaults omit font-size. We need it for opsz axis mapping.
26
- const PROPS_TO_RETURN = [
27
- 'font-family',
28
- 'font-style',
29
- 'font-weight',
30
- 'font-variant',
31
- 'font-stretch',
32
- 'font-variation-settings',
33
- 'animation-timing-function',
34
- 'font-size',
35
- ];
36
-
37
30
  // The \s before style ensures we don't match data-style or similar.
38
31
  const inlineFontStyleRegex =
39
32
  /(?:^|\s)style\s*=\s*["'][^"']*\b(?:font-family|font-weight|font-style|font-stretch|font\s*:)/i;
@@ -47,241 +40,11 @@ const fontFaceTraversalTypes = new Set(['HtmlStyle', 'SvgStyle', 'CssImport']);
47
40
  // the overhead of worker thread startup exceeds the parallelism benefit).
48
41
  const MIN_PAGES_FOR_WORKER_POOL = 4;
49
42
 
50
- const featureSettingsProps = new Set([
51
- 'font-feature-settings',
52
- 'font-variant-alternates',
53
- 'font-variant-caps',
54
- 'font-variant-east-asian',
55
- 'font-variant-ligatures',
56
- 'font-variant-numeric',
57
- 'font-variant-position',
58
- ]);
59
-
60
- // Map font-variant-* CSS values to their corresponding OpenType feature tags.
61
- const fontVariantToOTTags = {
62
- 'font-variant-ligatures': {
63
- 'common-ligatures': ['liga', 'clig'],
64
- 'no-common-ligatures': ['liga', 'clig'],
65
- 'discretionary-ligatures': ['dlig'],
66
- 'no-discretionary-ligatures': ['dlig'],
67
- 'historical-ligatures': ['hlig'],
68
- 'no-historical-ligatures': ['hlig'],
69
- contextual: ['calt'],
70
- 'no-contextual': ['calt'],
71
- },
72
- 'font-variant-caps': {
73
- 'small-caps': ['smcp'],
74
- 'all-small-caps': ['smcp', 'c2sc'],
75
- 'petite-caps': ['pcap'],
76
- 'all-petite-caps': ['pcap', 'c2pc'],
77
- unicase: ['unic'],
78
- 'titling-caps': ['titl'],
79
- },
80
- 'font-variant-numeric': {
81
- 'lining-nums': ['lnum'],
82
- 'oldstyle-nums': ['onum'],
83
- 'proportional-nums': ['pnum'],
84
- 'tabular-nums': ['tnum'],
85
- 'diagonal-fractions': ['frac'],
86
- 'stacked-fractions': ['afrc'],
87
- ordinal: ['ordn'],
88
- 'slashed-zero': ['zero'],
89
- },
90
- 'font-variant-position': {
91
- sub: ['subs'],
92
- super: ['sups'],
93
- },
94
- 'font-variant-east-asian': {
95
- jis78: ['jp78'],
96
- jis83: ['jp83'],
97
- jis90: ['jp90'],
98
- jis04: ['jp04'],
99
- simplified: ['smpl'],
100
- traditional: ['trad'],
101
- 'proportional-width': ['pwid'],
102
- 'full-width': ['fwid'],
103
- ruby: ['ruby'],
104
- },
105
- };
106
-
107
- // Extract OpenType feature tags referenced by a CSS declaration.
108
- function extractFeatureTagsFromDecl(prop, value) {
109
- const tags = new Set();
110
- const propLower = prop.toLowerCase();
111
-
112
- if (propLower === 'font-feature-settings') {
113
- // Parse quoted 4-letter tags: "liga" 1, 'dlig', etc.
114
- const re = /["']([a-zA-Z0-9]{4})["']/g;
115
- let m;
116
- while ((m = re.exec(value)) !== null) {
117
- tags.add(m[1]);
118
- }
119
- return tags;
120
- }
121
-
122
- if (propLower === 'font-variant-alternates') {
123
- const v = value.toLowerCase();
124
- if (v.includes('historical-forms')) tags.add('hist');
125
- if (/stylistic\s*\(/.test(v)) tags.add('salt');
126
- if (/swash\s*\(/.test(v)) tags.add('swsh');
127
- if (/ornaments\s*\(/.test(v)) tags.add('ornm');
128
- if (/annotation\s*\(/.test(v)) tags.add('nalt');
129
- if (/styleset\s*\(/.test(v)) {
130
- for (let i = 1; i <= 20; i++) {
131
- tags.add(`ss${String(i).padStart(2, '0')}`);
132
- }
133
- }
134
- if (/character-variant\s*\(/.test(v)) {
135
- for (let i = 1; i <= 99; i++) {
136
- tags.add(`cv${String(i).padStart(2, '0')}`);
137
- }
138
- }
139
- return tags;
140
- }
141
-
142
- const mapping = fontVariantToOTTags[propLower];
143
- if (mapping) {
144
- // Split into tokens for exact keyword matching — substring matching
145
- // would falsely trigger e.g. "sub" inside "super".
146
- const tokens = new Set(value.toLowerCase().split(/\s+/));
147
- for (const [keyword, otTags] of Object.entries(mapping)) {
148
- if (tokens.has(keyword)) {
149
- for (const t of otTags) tags.add(t);
150
- }
151
- }
152
- }
153
- return tags;
154
- }
155
-
156
- // Collect feature tags from all feature-related declarations in a CSS rule.
157
- function ruleFeatureTags(rule) {
158
- const tags = new Set();
159
- let hasFeatureDecl = false;
160
- for (const node of rule.nodes) {
161
- if (
162
- node.type === 'decl' &&
163
- featureSettingsProps.has(node.prop.toLowerCase())
164
- ) {
165
- hasFeatureDecl = true;
166
- for (const t of extractFeatureTagsFromDecl(node.prop, node.value)) {
167
- tags.add(t);
168
- }
169
- }
170
- }
171
- return hasFeatureDecl ? tags : null;
172
- }
173
-
174
- function ruleFontFamily(rule) {
175
- for (let i = rule.nodes.length - 1; i >= 0; i--) {
176
- const node = rule.nodes[i];
177
- if (node.type === 'decl' && node.prop.toLowerCase() === 'font-family') {
178
- return node.value;
179
- }
180
- }
181
- return null;
182
- }
183
-
184
- // Add all items from `tags` into the Set stored at `key` in `map`,
185
- // creating the Set if it doesn't exist yet.
186
- function addTagsToMapEntry(map, key, tags) {
187
- let s = map.get(key);
188
- if (!s) {
189
- s = new Set();
190
- map.set(key, s);
191
- }
192
- for (const t of tags) s.add(t);
193
- }
194
-
195
- // Record the OT tags from a single CSS rule into featureTagsByFamily,
196
- // keyed by font-family (or '*' when no font-family is specified).
197
- function recordRuleFeatureTags(rule, featureTagsByFamily) {
198
- const tags = ruleFeatureTags(rule);
199
- if (!tags) return null;
200
-
201
- const fontFamily = ruleFontFamily(rule);
202
- if (!fontFamily) {
203
- if (featureTagsByFamily) addTagsToMapEntry(featureTagsByFamily, '*', tags);
204
- return true; // signals "all families"
205
- }
206
-
207
- const families = cssFontParser.parseFontFamily(fontFamily);
208
- if (featureTagsByFamily) {
209
- for (const family of families) {
210
- addTagsToMapEntry(featureTagsByFamily, family.toLowerCase(), tags);
211
- }
212
- }
213
- return families;
214
- }
215
-
216
- // Determine which font-families use font-feature-settings or font-variant-*.
217
- // Returns null (none detected), a Set of lowercase family names, or true (all).
218
- // Also populates featureTagsByFamily with the OT tags per family (lowercase).
219
- function findFontFamiliesWithFeatureSettings(
220
- stylesheetsWithPredicates,
221
- featureTagsByFamily
222
- ) {
223
- let result = null;
224
- for (const { asset } of stylesheetsWithPredicates) {
225
- if (!asset || !asset.parseTree) continue;
226
- asset.parseTree.walkRules((rule) => {
227
- if (result === true && !featureTagsByFamily) return;
228
-
229
- const recorded = recordRuleFeatureTags(rule, featureTagsByFamily);
230
- if (!recorded) return;
231
-
232
- if (recorded === true) {
233
- result = true;
234
- } else if (result !== true) {
235
- if (!result) result = new Set();
236
- for (const family of recorded) {
237
- result.add(family.toLowerCase());
238
- }
239
- }
240
- });
241
- if (result === true && !featureTagsByFamily) break;
242
- }
243
- return result;
244
- }
245
-
246
- // Determine whether a template's font families use feature settings, and
247
- // collect the corresponding OT feature tags from featureTagsByFamily.
248
- function resolveFeatureSettings(
249
- fontFamilies,
250
- fontFamiliesWithFeatureSettings,
251
- featureTagsByFamily
252
- ) {
253
- let hasFontFeatureSettings = false;
254
- if (fontFamiliesWithFeatureSettings === true) {
255
- hasFontFeatureSettings = true;
256
- } else if (fontFamiliesWithFeatureSettings instanceof Set) {
257
- for (const f of fontFamilies) {
258
- if (fontFamiliesWithFeatureSettings.has(f.toLowerCase())) {
259
- hasFontFeatureSettings = true;
260
- break;
261
- }
262
- }
263
- }
264
-
265
- let fontFeatureTags;
266
- if (hasFontFeatureSettings && featureTagsByFamily) {
267
- const tags = new Set();
268
- const globalTags = featureTagsByFamily.get('*');
269
- if (globalTags) {
270
- for (const t of globalTags) tags.add(t);
271
- }
272
- for (const f of fontFamilies) {
273
- const familyTags = featureTagsByFamily.get(f.toLowerCase());
274
- if (familyTags) {
275
- for (const t of familyTags) tags.add(t);
276
- }
277
- }
278
- if (tags.size > 0) {
279
- fontFeatureTags = [...tags];
280
- }
281
- }
282
-
283
- return { hasFontFeatureSettings, fontFeatureTags };
284
- }
43
+ const {
44
+ extractFeatureTagsFromDecl,
45
+ findFontFamiliesWithFeatureSettings,
46
+ resolveFeatureSettings,
47
+ } = require('./fontFeatureHelpers');
285
48
 
286
49
  const allInitialValues = require('./initialValueByProp');
287
50
  const initialValueByProp = {
@@ -398,7 +161,6 @@ function computeSnappedGlobalEntries(declarations, globalTextByProps) {
398
161
  textAndProps,
399
162
  ...snapped,
400
163
  fontVariationSettings: textAndProps.props['font-variation-settings'],
401
- fontSize: textAndProps.props['font-size'],
402
164
  });
403
165
  }
404
166
  }
@@ -505,9 +267,6 @@ function getOrComputeGlobalFontUsages(
505
267
  .map((e) => e.fontVariationSettings)
506
268
  .filter((fvs) => fvs && fvs.toLowerCase() !== 'normal')
507
269
  );
508
- const fontSizes = new Set(
509
- fontEntries.map((e) => e.fontSize).filter((fs) => fs !== undefined)
510
- );
511
270
  // Use first entry's relations for size computation, or extra's if no entries
512
271
  const fontRelations =
513
272
  fontEntries.length > 0
@@ -543,7 +302,6 @@ function getOrComputeGlobalFontUsages(
543
302
  fontStretches,
544
303
  fontWeights,
545
304
  fontVariationSettings,
546
- fontSizes,
547
305
  });
548
306
  }
549
307
 
@@ -556,71 +314,100 @@ function getOrComputeGlobalFontUsages(
556
314
  // cyclomatic complexity of collectTextsByPage.
557
315
  async function tracePages(
558
316
  pagesNeedingFullTrace,
559
- { headlessBrowser, concurrency, console, memoizedGetCssRulesByProperty }
317
+ {
318
+ headlessBrowser,
319
+ concurrency,
320
+ console,
321
+ memoizedGetCssRulesByProperty,
322
+ debug = false,
323
+ }
560
324
  ) {
325
+ const totalPages = pagesNeedingFullTrace.length;
326
+ if (totalPages === 0) return;
327
+
561
328
  const useWorkerPool =
562
- !headlessBrowser &&
563
- pagesNeedingFullTrace.length >= MIN_PAGES_FOR_WORKER_POOL;
329
+ !headlessBrowser && totalPages >= MIN_PAGES_FOR_WORKER_POOL;
330
+
331
+ const progress = createPageProgress({
332
+ total: totalPages,
333
+ console,
334
+ label: 'Tracing fonts',
335
+ });
564
336
 
565
337
  if (useWorkerPool) {
566
338
  const maxWorkers =
567
339
  concurrency > 0 ? concurrency : Math.min(os.cpus().length, 8);
568
- const numWorkers = Math.min(maxWorkers, pagesNeedingFullTrace.length);
340
+ const numWorkers = Math.min(maxWorkers, totalPages);
569
341
  const pool = new FontTracerPool(numWorkers);
570
342
  await pool.init();
571
343
 
572
344
  try {
573
- const totalPages = pagesNeedingFullTrace.length;
574
- const showProgress = totalPages >= 10 && console;
575
- let tracedCount = 0;
576
- const tracePromises = pagesNeedingFullTrace.map(async (pd) => {
577
- try {
578
- pd.textByProps = await pool.trace(
579
- pd.htmlOrSvgAsset.text || '',
580
- pd.stylesheetsWithPredicates
581
- );
582
- } catch (err) {
583
- if (console) {
584
- console.warn(
585
- `Worker fontTracer failed for ${pd.htmlOrSvgAsset.url}, falling back to main thread: ${err.message}`
345
+ progress.banner(
346
+ ` Tracing fonts across ${totalPages} pages using ${numWorkers} worker${numWorkers === 1 ? '' : 's'}...`
347
+ );
348
+ await Promise.all(
349
+ pagesNeedingFullTrace.map(async (pd) => {
350
+ const pageStart = debug ? Date.now() : 0;
351
+ try {
352
+ pd.textByProps = await pool.trace(
353
+ pd.htmlOrSvgAsset.text || '',
354
+ pd.stylesheetsWithPredicates
586
355
  );
356
+ } catch (err) {
357
+ if (console) {
358
+ console.warn(
359
+ `Worker fontTracer failed for ${pd.htmlOrSvgAsset.url}, falling back to main thread: ${err.message}`
360
+ );
361
+ }
362
+ pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
363
+ stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
364
+ getCssRulesByProperty: memoizedGetCssRulesByProperty,
365
+ asset: pd.htmlOrSvgAsset,
366
+ });
587
367
  }
588
- pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
589
- stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
590
- getCssRulesByProperty: memoizedGetCssRulesByProperty,
591
- asset: pd.htmlOrSvgAsset,
592
- propsToReturn: PROPS_TO_RETURN,
593
- });
594
- }
595
- tracedCount++;
596
- if (showProgress && tracedCount % 10 === 0) {
597
- console.log(` Tracing fonts: ${tracedCount}/${totalPages} pages...`);
598
- }
599
- });
600
- await Promise.all(tracePromises);
368
+ const idx = progress.tick();
369
+ logTracedPage(
370
+ console,
371
+ debug,
372
+ idx,
373
+ totalPages,
374
+ pd.htmlOrSvgAsset,
375
+ pageStart
376
+ );
377
+ })
378
+ );
379
+ progress.done();
601
380
  } finally {
602
381
  await pool.destroy();
603
382
  }
604
- } else if (pagesNeedingFullTrace.length > 0) {
605
- const totalPages = pagesNeedingFullTrace.length;
606
- const showProgress = totalPages >= 10 && console;
383
+ } else {
384
+ progress.banner(
385
+ ` Tracing fonts across ${totalPages} page${totalPages === 1 ? '' : 's'} (single-threaded${headlessBrowser ? ' + headless browser' : ''})...`
386
+ );
607
387
  for (let pi = 0; pi < totalPages; pi++) {
608
388
  const pd = pagesNeedingFullTrace[pi];
389
+ const pageStart = debug ? Date.now() : 0;
609
390
  pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
610
391
  stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
611
392
  getCssRulesByProperty: memoizedGetCssRulesByProperty,
612
393
  asset: pd.htmlOrSvgAsset,
613
- propsToReturn: PROPS_TO_RETURN,
614
394
  });
615
395
  if (headlessBrowser) {
616
396
  pd.textByProps.push(
617
397
  ...(await headlessBrowser.tracePage(pd.htmlOrSvgAsset))
618
398
  );
619
399
  }
620
- if (showProgress && (pi + 1) % 10 === 0) {
621
- console.log(` Tracing fonts: ${pi + 1}/${totalPages} pages...`);
622
- }
400
+ const idx = progress.tick();
401
+ logTracedPage(
402
+ console,
403
+ debug,
404
+ idx,
405
+ totalPages,
406
+ pd.htmlOrSvgAsset,
407
+ pageStart
408
+ );
623
409
  }
410
+ progress.done();
624
411
  }
625
412
  }
626
413
 
@@ -629,11 +416,11 @@ async function tracePages(
629
416
  // props and only extract visible text content.
630
417
  function processFastPathPages(
631
418
  fastPathPages,
632
- { memoizedGetCssRulesByProperty, console, debug, subTimings }
419
+ { memoizedGetCssRulesByProperty, subTimings, trackPhase }
633
420
  ) {
634
421
  if (fastPathPages.length === 0) return;
635
422
 
636
- const fastPathStart = Date.now();
423
+ const fastPathPhase = trackPhase('Fast-path extraction');
637
424
 
638
425
  const repDataCache = new Map();
639
426
  function getRepData(representativePd) {
@@ -682,7 +469,6 @@ function processFastPathPages(
682
469
  stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
683
470
  getCssRulesByProperty: memoizedGetCssRulesByProperty,
684
471
  asset: pd.htmlOrSvgAsset,
685
- propsToReturn: PROPS_TO_RETURN,
686
472
  });
687
473
  continue;
688
474
  }
@@ -742,11 +528,9 @@ function processFastPathPages(
742
528
  });
743
529
  }
744
530
  }
745
- subTimings['Fast-path extraction'] = Date.now() - fastPathStart;
746
- if (debug && console)
747
- console.log(
748
- `[subfont timing] Fast-path text extraction (${fastPathPages.length} pages, ${fastPathFallbacks} fell back to full trace): ${subTimings['Fast-path extraction']}ms`
749
- );
531
+ subTimings['Fast-path extraction'] = fastPathPhase.end(
532
+ `${fastPathPages.length} pages, ${fastPathFallbacks} fell back to full trace`
533
+ );
750
534
  }
751
535
 
752
536
  async function collectTextsByPage(
@@ -933,12 +717,11 @@ async function collectTextsByPage(
933
717
  const globalTextByProps = [];
934
718
  const subTimings = {};
935
719
 
936
- if (debug && console)
937
- console.log('[subfont timing] collectTextsByPage started');
938
- const timingStart = Date.now();
720
+ const trackPhase = makePhaseTracker(console, debug);
721
+ const overallPhase = trackPhase('collectTextsByPage');
939
722
 
940
723
  // Pre-compute stylesheet results for all pages
941
- const stylesheetPrecomputeStart = Date.now();
724
+ const stylesheetPrecompute = trackPhase('Stylesheet precompute');
942
725
  const pageData = [];
943
726
  for (const htmlOrSvgAsset of htmlOrSvgAssets) {
944
727
  const {
@@ -967,10 +750,9 @@ async function collectTextsByPage(
967
750
  });
968
751
  }
969
752
 
970
- if (debug && console)
971
- console.log(
972
- `[subfont timing] Stylesheet precompute: ${(subTimings['Stylesheet precompute'] = Date.now() - stylesheetPrecomputeStart)}ms (${pageData.length} pages with fonts)`
973
- );
753
+ subTimings['Stylesheet precompute'] = stylesheetPrecompute.end(
754
+ `${pageData.length} pages with fonts`
755
+ );
974
756
 
975
757
  // Group pages by stylesheet cache key — pages sharing the same CSS
976
758
  // configuration produce identical font-tracer props, only text differs.
@@ -1000,34 +782,38 @@ async function collectTextsByPage(
1000
782
  }
1001
783
  }
1002
784
 
1003
- if (debug && console)
785
+ // Always surface the per-page work breakdown so users can tell at a
786
+ // glance how much of the run is actual tracing vs cheap CSS-group
787
+ // reuse. The threshold matches createPageProgress's minTotal so it
788
+ // only appears on non-trivial runs.
789
+ if (console && pageData.length >= 5) {
1004
790
  console.log(
1005
- `[subfont timing] CSS groups: ${pagesByStylesheetKey.size} unique, ${pagesNeedingFullTrace.length} to trace, ${fastPathPages.length} fast-path`
791
+ ` ${pageData.length} pages with fonts: ${pagesNeedingFullTrace.length} to trace, ${fastPathPages.length} via cached CSS group (${pagesByStylesheetKey.size} unique groups)`
1006
792
  );
793
+ }
1007
794
 
1008
795
  const tracingStart = Date.now();
796
+ const fullTracing = trackPhase(
797
+ `Full tracing (${pagesNeedingFullTrace.length} pages)`
798
+ );
1009
799
  try {
1010
800
  await tracePages(pagesNeedingFullTrace, {
1011
801
  headlessBrowser,
1012
802
  concurrency,
1013
803
  console,
1014
804
  memoizedGetCssRulesByProperty,
805
+ debug,
1015
806
  });
1016
807
 
1017
- subTimings['Full tracing'] = Date.now() - tracingStart;
1018
- if (debug && console)
1019
- console.log(
1020
- `[subfont timing] Full tracing (${pagesNeedingFullTrace.length} pages): ${subTimings['Full tracing']}ms`
1021
- );
808
+ subTimings['Full tracing'] = fullTracing.end();
1022
809
 
1023
810
  processFastPathPages(fastPathPages, {
1024
811
  memoizedGetCssRulesByProperty,
1025
- console,
1026
- debug,
1027
812
  subTimings,
813
+ trackPhase,
1028
814
  });
1029
815
 
1030
- const assembleStart = Date.now();
816
+ const assemblePhase = trackPhase('Result assembly');
1031
817
  for (const pd of pageData) {
1032
818
  for (const textByPropsEntry of pd.textByProps) {
1033
819
  textByPropsEntry.htmlOrSvgAsset = pd.htmlOrSvgAsset;
@@ -1044,12 +830,8 @@ async function collectTextsByPage(
1044
830
  featureTagsByFamily: pd.featureTagsByFamily,
1045
831
  });
1046
832
  }
1047
-
1048
- subTimings['Result assembly'] = Date.now() - assembleStart;
833
+ subTimings['Result assembly'] = assemblePhase.end();
1049
834
  if (debug && console) {
1050
- console.log(
1051
- `[subfont timing] Result assembly: ${subTimings['Result assembly']}ms`
1052
- );
1053
835
  console.log(
1054
836
  `[subfont timing] Total tracing+extraction+assembly: ${
1055
837
  Date.now() - tracingStart
@@ -1062,12 +844,12 @@ async function collectTextsByPage(
1062
844
  }
1063
845
  }
1064
846
 
1065
- const postProcessStart = Date.now();
847
+ const postProcessPhase = trackPhase('Post-processing total');
1066
848
 
1067
849
  // Consolidated cache for per-declarations-key data.
1068
850
  const declCache = new Map();
1069
851
 
1070
- const perPageLoopStart = Date.now();
852
+ const perPageLoopPhase = trackPhase('Per-page loop');
1071
853
  let snappingTime = 0;
1072
854
  let globalUsageTime = 0;
1073
855
 
@@ -1162,7 +944,6 @@ async function collectTextsByPage(
1162
944
  fontStretches: template.fontStretches,
1163
945
  fontWeights: template.fontWeights,
1164
946
  fontVariationSettings: template.fontVariationSettings,
1165
- fontSizes: template.fontSizes,
1166
947
  preload: preloadFontUrls.has(template.fontUrl),
1167
948
  hasFontFeatureSettings,
1168
949
  fontFeatureTags,
@@ -1172,20 +953,11 @@ async function collectTextsByPage(
1172
953
  cloningTime += Date.now() - cloneStart;
1173
954
  }
1174
955
 
1175
- subTimings['Per-page loop'] = Date.now() - perPageLoopStart;
1176
- subTimings['Post-processing total'] = Date.now() - postProcessStart;
1177
- if (debug && console)
1178
- console.log(
1179
- `[subfont timing] Per-page loop: ${subTimings['Per-page loop']}ms (snapping: ${snappingTime}ms, globalUsage: ${globalUsageTime}ms, cloning: ${cloningTime}ms)`
1180
- );
1181
- if (debug && console)
1182
- console.log(
1183
- `[subfont timing] Post-processing total: ${subTimings['Post-processing total']}ms`
1184
- );
1185
- if (debug && console)
1186
- console.log(
1187
- `[subfont timing] collectTextsByPage total: ${Date.now() - timingStart}ms`
1188
- );
956
+ subTimings['Per-page loop'] = perPageLoopPhase.end(
957
+ `snapping: ${snappingTime}ms, globalUsage: ${globalUsageTime}ms, cloning: ${cloningTime}ms`
958
+ );
959
+ subTimings['Post-processing total'] = postProcessPhase.end();
960
+ overallPhase.end();
1189
961
 
1190
962
  for (const fontFaceDeclarations of fontFaceDeclarationsByHtmlOrSvgAsset.values()) {
1191
963
  for (const fontFaceDeclaration of fontFaceDeclarations) {
@@ -5,7 +5,9 @@ const WORKER_MEMORY_BYTES = 50 * 1024 * 1024; // 50 MB
5
5
 
6
6
  function getMaxConcurrency() {
7
7
  const byMemory = Math.floor(os.freemem() / WORKER_MEMORY_BYTES);
8
- const byCpu = os.cpus().length * 4;
8
+ // Font tracing is CPU-bound (not I/O), so match the pool size to the
9
+ // core count directly — no multiplier.
10
+ const byCpu = os.cpus().length;
9
11
  return Math.max(1, Math.min(byMemory, byCpu));
10
12
  }
11
13