@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.
@@ -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,10 @@ 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
+ findFontFamiliesWithFeatureSettings,
45
+ resolveFeatureSettings,
46
+ } = require('./fontFeatureHelpers');
285
47
 
286
48
  const allInitialValues = require('./initialValueByProp');
287
49
  const initialValueByProp = {
@@ -398,16 +160,16 @@ function computeSnappedGlobalEntries(declarations, globalTextByProps) {
398
160
  textAndProps,
399
161
  ...snapped,
400
162
  fontVariationSettings: textAndProps.props['font-variation-settings'],
401
- fontSize: textAndProps.props['font-size'],
402
163
  });
403
164
  }
404
165
  }
405
166
  return entries;
406
167
  }
407
168
 
408
- // Build global font usage templates and per-page indices from
409
- // snapped entries. Mutates the declCache entry for declKey in place.
410
- function getOrComputeGlobalFontUsages(
169
+ // Fill in fontUsageTemplates/pageTextIndex/preloadIndex on the cached
170
+ // declarations entry. No-op on repeat calls results are shared across
171
+ // pages that resolve to the same @font-face set.
172
+ function populateGlobalFontUsages(
411
173
  cached,
412
174
  accumulatedFontFaceDeclarations,
413
175
  text
@@ -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
 
@@ -552,88 +310,117 @@ function getOrComputeGlobalFontUsages(
552
310
  cached.preloadIndex = textAndPropsToFontUrl;
553
311
  }
554
312
 
555
- // Extract font tracing (worker pool + sequential) to reduce
556
- // cyclomatic complexity of collectTextsByPage.
313
+ // Trace fonts across the given pages. Uses a worker pool when the workload
314
+ // justifies the thread-startup overhead; otherwise falls back to sequential
315
+ // in-process tracing (required when a HeadlessBrowser is driving things).
557
316
  async function tracePages(
558
317
  pagesNeedingFullTrace,
559
- { headlessBrowser, concurrency, console, memoizedGetCssRulesByProperty }
318
+ {
319
+ headlessBrowser,
320
+ concurrency,
321
+ console,
322
+ memoizedGetCssRulesByProperty,
323
+ debug = false,
324
+ }
560
325
  ) {
326
+ const totalPages = pagesNeedingFullTrace.length;
327
+ if (totalPages === 0) return;
328
+
561
329
  const useWorkerPool =
562
- !headlessBrowser &&
563
- pagesNeedingFullTrace.length >= MIN_PAGES_FOR_WORKER_POOL;
330
+ !headlessBrowser && totalPages >= MIN_PAGES_FOR_WORKER_POOL;
331
+
332
+ const progress = createPageProgress({
333
+ total: totalPages,
334
+ console,
335
+ label: 'Tracing fonts',
336
+ });
564
337
 
565
338
  if (useWorkerPool) {
566
339
  const maxWorkers =
567
340
  concurrency > 0 ? concurrency : Math.min(os.cpus().length, 8);
568
- const numWorkers = Math.min(maxWorkers, pagesNeedingFullTrace.length);
341
+ const numWorkers = Math.min(maxWorkers, totalPages);
569
342
  const pool = new FontTracerPool(numWorkers);
570
343
  await pool.init();
571
344
 
572
345
  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}`
346
+ progress.banner(
347
+ ` Tracing fonts across ${totalPages} pages using ${numWorkers} worker${numWorkers === 1 ? '' : 's'}...`
348
+ );
349
+ await Promise.all(
350
+ pagesNeedingFullTrace.map(async (pd) => {
351
+ const pageStart = debug ? Date.now() : 0;
352
+ try {
353
+ pd.textByProps = await pool.trace(
354
+ pd.htmlOrSvgAsset.text || '',
355
+ pd.stylesheetsWithPredicates
586
356
  );
357
+ } catch (err) {
358
+ if (console) {
359
+ console.warn(
360
+ `Worker fontTracer failed for ${pd.htmlOrSvgAsset.url}, falling back to main thread: ${err.message}`
361
+ );
362
+ }
363
+ pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
364
+ stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
365
+ getCssRulesByProperty: memoizedGetCssRulesByProperty,
366
+ asset: pd.htmlOrSvgAsset,
367
+ });
587
368
  }
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);
369
+ const idx = progress.tick();
370
+ logTracedPage(
371
+ console,
372
+ debug,
373
+ idx,
374
+ totalPages,
375
+ pd.htmlOrSvgAsset,
376
+ pageStart
377
+ );
378
+ })
379
+ );
380
+ progress.done();
601
381
  } finally {
602
382
  await pool.destroy();
603
383
  }
604
- } else if (pagesNeedingFullTrace.length > 0) {
605
- const totalPages = pagesNeedingFullTrace.length;
606
- const showProgress = totalPages >= 10 && console;
384
+ } else {
385
+ progress.banner(
386
+ ` Tracing fonts across ${totalPages} page${totalPages === 1 ? '' : 's'} (single-threaded${headlessBrowser ? ' + headless browser' : ''})...`
387
+ );
607
388
  for (let pi = 0; pi < totalPages; pi++) {
608
389
  const pd = pagesNeedingFullTrace[pi];
390
+ const pageStart = debug ? Date.now() : 0;
609
391
  pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
610
392
  stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
611
393
  getCssRulesByProperty: memoizedGetCssRulesByProperty,
612
394
  asset: pd.htmlOrSvgAsset,
613
- propsToReturn: PROPS_TO_RETURN,
614
395
  });
615
396
  if (headlessBrowser) {
616
397
  pd.textByProps.push(
617
398
  ...(await headlessBrowser.tracePage(pd.htmlOrSvgAsset))
618
399
  );
619
400
  }
620
- if (showProgress && (pi + 1) % 10 === 0) {
621
- console.log(` Tracing fonts: ${pi + 1}/${totalPages} pages...`);
622
- }
401
+ const idx = progress.tick();
402
+ logTracedPage(
403
+ console,
404
+ debug,
405
+ idx,
406
+ totalPages,
407
+ pd.htmlOrSvgAsset,
408
+ pageStart
409
+ );
623
410
  }
411
+ progress.done();
624
412
  }
625
413
  }
626
414
 
627
- // Extract fast-path text extraction to reduce collectTextsByPage complexity.
628
- // Pages sharing the same CSS configuration reuse the representative's
629
- // props and only extract visible text content.
415
+ // For each page that shares a representative's CSS configuration, copy the
416
+ // representative's font-variant props and overlay this page's visible text.
417
+ // Returns the number of pages that had to fall back to a full trace
418
+ // (because inline style attributes made the fast path unsafe).
630
419
  function processFastPathPages(
631
420
  fastPathPages,
632
- { memoizedGetCssRulesByProperty, console, debug, subTimings }
421
+ { memoizedGetCssRulesByProperty }
633
422
  ) {
634
- if (fastPathPages.length === 0) return;
635
-
636
- const fastPathStart = Date.now();
423
+ if (fastPathPages.length === 0) return 0;
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,238 +528,205 @@ 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
+ return fastPathFallbacks;
750
532
  }
751
533
 
752
- async function collectTextsByPage(
753
- assetGraph,
754
- htmlOrSvgAssets,
755
- {
756
- text,
757
- console,
758
- dynamic = false,
759
- debug = false,
760
- concurrency,
761
- chromeArgs = [],
762
- } = {}
763
- ) {
764
- const htmlOrSvgAssetTextsWithProps = [];
765
-
766
- const memoizedGetCssRulesByProperty = memoizeSync(getCssRulesByProperty);
767
-
768
- const fontFaceDeclarationsByHtmlOrSvgAsset = new Map();
769
-
770
- // Cache stylesheet-dependent results for pages with identical CSS
771
- // configurations.
772
- const stylesheetResultCache = new Map();
534
+ // Pre-build an index of stylesheet-related relations by source asset
535
+ // to avoid repeated assetGraph.findRelations scans (O(allRelations) each).
536
+ const STYLESHEET_REL_TYPES = [
537
+ 'HtmlStyle',
538
+ 'SvgStyle',
539
+ 'CssImport',
540
+ 'HtmlConditionalComment',
541
+ 'HtmlNoscript',
542
+ ];
773
543
 
774
- // Pre-build an index of stylesheet-related relations by source asset
775
- // to avoid repeated assetGraph.findRelations scans (O(allRelations) each).
776
- const stylesheetRelTypes = [
777
- 'HtmlStyle',
778
- 'SvgStyle',
779
- 'CssImport',
780
- 'HtmlConditionalComment',
781
- 'HtmlNoscript',
782
- ];
783
- const stylesheetRelsByFromAsset = new Map();
544
+ function indexStylesheetRelations(assetGraph) {
545
+ const byFromAsset = new Map();
784
546
  for (const relation of assetGraph.findRelations({
785
- type: {
786
- $in: stylesheetRelTypes,
787
- },
547
+ type: { $in: STYLESHEET_REL_TYPES },
788
548
  })) {
789
- let arr = stylesheetRelsByFromAsset.get(relation.from);
549
+ let arr = byFromAsset.get(relation.from);
790
550
  if (!arr) {
791
551
  arr = [];
792
- stylesheetRelsByFromAsset.set(relation.from, arr);
552
+ byFromAsset.set(relation.from, arr);
793
553
  }
794
554
  arr.push(relation);
795
555
  }
556
+ return byFromAsset;
557
+ }
796
558
 
797
- // Build a cache key by traversing stylesheet relations, capturing
798
- // both asset identity and relation context (media, conditionalComment,
799
- // noscript) that affect gatherStylesheetsWithPredicates output.
800
- function buildStylesheetKey(htmlOrSvgAsset, skipNonFontInlineCss) {
801
- const keyParts = [];
802
- const visited = new Set();
803
- (function traverse(asset, isNoscript) {
804
- if (visited.has(asset)) return;
805
- if (!asset.isLoaded) return;
806
- visited.add(asset);
807
- for (const relation of stylesheetRelsByFromAsset.get(asset) || []) {
808
- if (relation.type === 'HtmlNoscript') {
809
- traverse(relation.to, true);
810
- } else if (relation.type === 'HtmlConditionalComment') {
811
- keyParts.push(`cc:${relation.condition}`);
812
- traverse(relation.to, isNoscript);
813
- } else {
814
- const target = relation.to;
815
- if (
816
- skipNonFontInlineCss &&
817
- target.isInline &&
818
- target.type === 'Css' &&
819
- !fontRelevantCssRegex.test(target.text || '')
820
- ) {
821
- continue;
822
- }
823
- const media = relation.media || '';
824
- keyParts.push(`${target.id}:${media}:${isNoscript ? 'ns' : ''}`);
825
- traverse(target, isNoscript);
559
+ // Build a cache key by traversing stylesheet relations, capturing
560
+ // both asset identity and relation context (media, conditionalComment,
561
+ // noscript) that affect gatherStylesheetsWithPredicates output.
562
+ function buildStylesheetKey(
563
+ htmlOrSvgAsset,
564
+ skipNonFontInlineCss,
565
+ stylesheetRelsByFromAsset
566
+ ) {
567
+ const keyParts = [];
568
+ const visited = new Set();
569
+ (function traverse(asset, isNoscript) {
570
+ if (visited.has(asset)) return;
571
+ if (!asset.isLoaded) return;
572
+ visited.add(asset);
573
+ for (const relation of stylesheetRelsByFromAsset.get(asset) || []) {
574
+ if (relation.type === 'HtmlNoscript') {
575
+ traverse(relation.to, true);
576
+ } else if (relation.type === 'HtmlConditionalComment') {
577
+ keyParts.push(`cc:${relation.condition}`);
578
+ traverse(relation.to, isNoscript);
579
+ } else {
580
+ const target = relation.to;
581
+ if (
582
+ skipNonFontInlineCss &&
583
+ target.isInline &&
584
+ target.type === 'Css' &&
585
+ !fontRelevantCssRegex.test(target.text || '')
586
+ ) {
587
+ continue;
826
588
  }
589
+ const media = relation.media || '';
590
+ keyParts.push(`${target.id}:${media}:${isNoscript ? 'ns' : ''}`);
591
+ traverse(target, isNoscript);
827
592
  }
828
- })(htmlOrSvgAsset, false);
829
- return keyParts.join('\x1d');
830
- }
831
-
832
- function getOrComputeStylesheetResults(htmlOrSvgAsset) {
833
- const key = buildStylesheetKey(htmlOrSvgAsset);
834
- if (stylesheetResultCache.has(key)) {
835
- return stylesheetResultCache.get(key);
836
593
  }
594
+ })(htmlOrSvgAsset, false);
595
+ return keyParts.join('\x1d');
596
+ }
837
597
 
838
- const stylesheetsWithPredicates = gatherStylesheetsWithPredicates(
839
- htmlOrSvgAsset.assetGraph,
840
- htmlOrSvgAsset,
841
- stylesheetRelsByFromAsset
842
- );
598
+ // Walk the stylesheet graph rooted at htmlOrSvgAsset and collect every
599
+ // @font-face declaration into a flat list, preserving the CSS relation node
600
+ // so callers can correlate declarations back to their source rules.
601
+ function collectFontFaceDeclarations(
602
+ htmlOrSvgAsset,
603
+ stylesheetRelsByFromAsset
604
+ ) {
605
+ const accumulatedFontFaceDeclarations = [];
606
+ const visitedAssets = new Set();
607
+ (function traverseForFontFace(asset) {
608
+ if (visitedAssets.has(asset)) return;
609
+ visitedAssets.add(asset);
610
+
611
+ if (asset.type === 'Css' && asset.isLoaded) {
612
+ const seenNodes = new Set();
613
+ const fontRelations = asset.outgoingRelations.filter(
614
+ (relation) => relation.type === 'CssFontFaceSrc'
615
+ );
843
616
 
844
- // Compute accumulatedFontFaceDeclarations by traversing CSS relations
845
- const accumulatedFontFaceDeclarations = [];
846
- {
847
- const visitedAssets = new Set();
848
- (function traverseForFontFace(asset) {
849
- if (visitedAssets.has(asset)) return;
850
- visitedAssets.add(asset);
851
-
852
- if (asset.type === 'Css' && asset.isLoaded) {
853
- const seenNodes = new Set();
854
- const fontRelations = asset.outgoingRelations.filter(
855
- (relation) => relation.type === 'CssFontFaceSrc'
856
- );
617
+ for (const fontRelation of fontRelations) {
618
+ const node = fontRelation.node;
619
+ if (seenNodes.has(node)) continue;
620
+ seenNodes.add(node);
857
621
 
858
- for (const fontRelation of fontRelations) {
859
- const node = fontRelation.node;
860
- if (seenNodes.has(node)) continue;
861
- seenNodes.add(node);
862
-
863
- const fontFaceDeclaration = {
864
- relations: fontRelations.filter((r) => r.node === node),
865
- ...initialValueByProp,
866
- };
867
-
868
- node.walkDecls((declaration) => {
869
- const propName = declaration.prop.toLowerCase();
870
- fontFaceDeclaration[propName] =
871
- propName === 'font-family'
872
- ? cssFontParser.parseFontFamily(declaration.value)[0]
873
- : declaration.value;
874
- });
875
- // Disregard incomplete @font-face declarations (must contain font-family and src per spec):
876
- if (fontFaceDeclaration['font-family'] && fontFaceDeclaration.src) {
877
- accumulatedFontFaceDeclarations.push(fontFaceDeclaration);
878
- }
879
- }
880
- }
622
+ const fontFaceDeclaration = {
623
+ relations: fontRelations.filter((r) => r.node === node),
624
+ ...initialValueByProp,
625
+ };
881
626
 
882
- // Traverse children using the pre-built index
883
- const rels = stylesheetRelsByFromAsset.get(asset) || [];
884
- for (const rel of rels) {
885
- if (
886
- fontFaceTraversalTypes.has(rel.type) ||
887
- (rel.to && rel.to.type === 'Html' && rel.to.isInline)
888
- ) {
889
- traverseForFontFace(rel.to);
890
- }
627
+ node.walkDecls((declaration) => {
628
+ const propName = declaration.prop.toLowerCase();
629
+ fontFaceDeclaration[propName] =
630
+ propName === 'font-family'
631
+ ? cssFontParser.parseFontFamily(declaration.value)[0]
632
+ : declaration.value;
633
+ });
634
+ // Disregard incomplete @font-face declarations (must contain font-family and src per spec):
635
+ if (fontFaceDeclaration['font-family'] && fontFaceDeclaration.src) {
636
+ accumulatedFontFaceDeclarations.push(fontFaceDeclaration);
891
637
  }
892
- })(htmlOrSvgAsset);
638
+ }
893
639
  }
894
640
 
895
- // Group @font-face declarations that share family/style/weight but have
896
- // different unicode-range values. Each group's members cover a disjoint
897
- // subset of the Unicode space (common for CJK / large character-set fonts).
898
- const comboGroups = new Map();
899
- for (const fontFace of accumulatedFontFaceDeclarations) {
900
- const comboKey = `${fontFace['font-family']}/${fontFace['font-style']}/${fontFace['font-weight']}`;
901
- if (!comboGroups.has(comboKey)) comboGroups.set(comboKey, []);
902
- comboGroups.get(comboKey).push(fontFace);
903
- }
904
- for (const [comboKey, group] of comboGroups) {
905
- if (group.length <= 1) continue;
906
- const withoutRange = group.filter((d) => !d['unicode-range']);
907
- if (withoutRange.length > 0) {
908
- throw new Error(
909
- `Multiple @font-face with the same font-family/font-style/font-weight combo but missing unicode-range on ${withoutRange.length} of ${group.length} declarations: ${comboKey}`
910
- );
641
+ const rels = stylesheetRelsByFromAsset.get(asset) || [];
642
+ for (const rel of rels) {
643
+ if (
644
+ fontFaceTraversalTypes.has(rel.type) ||
645
+ (rel.to && rel.to.type === 'Html' && rel.to.isInline)
646
+ ) {
647
+ traverseForFontFace(rel.to);
911
648
  }
912
649
  }
650
+ })(htmlOrSvgAsset);
651
+ return accumulatedFontFaceDeclarations;
652
+ }
913
653
 
914
- const featureTagsByFamily = new Map();
915
- const fontFamiliesWithFeatureSettings = findFontFamiliesWithFeatureSettings(
916
- stylesheetsWithPredicates,
917
- featureTagsByFamily
918
- );
919
-
920
- const result = {
921
- accumulatedFontFaceDeclarations,
922
- stylesheetsWithPredicates,
923
- fontFamiliesWithFeatureSettings,
924
- featureTagsByFamily,
925
- fastPathKey: buildStylesheetKey(htmlOrSvgAsset, true),
926
- };
927
- stylesheetResultCache.set(key, result);
928
- return result;
654
+ // Validate that @font-face declarations sharing family/style/weight carry
655
+ // disjoint unicode-range values; throws on incomplete coverage.
656
+ function validateFontFaceComboCoverage(accumulatedFontFaceDeclarations) {
657
+ const comboGroups = new Map();
658
+ for (const fontFace of accumulatedFontFaceDeclarations) {
659
+ const comboKey = `${fontFace['font-family']}/${fontFace['font-style']}/${fontFace['font-weight']}`;
660
+ if (!comboGroups.has(comboKey)) comboGroups.set(comboKey, []);
661
+ comboGroups.get(comboKey).push(fontFace);
662
+ }
663
+ for (const [comboKey, group] of comboGroups) {
664
+ if (group.length <= 1) continue;
665
+ const withoutRange = group.filter((d) => !d['unicode-range']);
666
+ if (withoutRange.length > 0) {
667
+ throw new Error(
668
+ `Multiple @font-face with the same font-family/font-style/font-weight combo but missing unicode-range on ${withoutRange.length} of ${group.length} declarations: ${comboKey}`
669
+ );
670
+ }
929
671
  }
672
+ }
930
673
 
931
- const headlessBrowser =
932
- dynamic && new HeadlessBrowser({ console, chromeArgs });
933
- const globalTextByProps = [];
934
- const subTimings = {};
674
+ function computeStylesheetResults(htmlOrSvgAsset, stylesheetRelsByFromAsset) {
675
+ const stylesheetsWithPredicates = gatherStylesheetsWithPredicates(
676
+ htmlOrSvgAsset.assetGraph,
677
+ htmlOrSvgAsset,
678
+ stylesheetRelsByFromAsset
679
+ );
680
+
681
+ const accumulatedFontFaceDeclarations = collectFontFaceDeclarations(
682
+ htmlOrSvgAsset,
683
+ stylesheetRelsByFromAsset
684
+ );
685
+ validateFontFaceComboCoverage(accumulatedFontFaceDeclarations);
935
686
 
936
- if (debug && console)
937
- console.log('[subfont timing] collectTextsByPage started');
938
- const timingStart = Date.now();
687
+ const featureTagsByFamily = new Map();
688
+ const fontFamiliesWithFeatureSettings = findFontFamiliesWithFeatureSettings(
689
+ stylesheetsWithPredicates,
690
+ featureTagsByFamily
691
+ );
939
692
 
940
- // Pre-compute stylesheet results for all pages
941
- const stylesheetPrecomputeStart = Date.now();
942
- const pageData = [];
943
- for (const htmlOrSvgAsset of htmlOrSvgAssets) {
944
- const {
945
- accumulatedFontFaceDeclarations,
946
- stylesheetsWithPredicates,
947
- fontFamiliesWithFeatureSettings,
948
- featureTagsByFamily,
949
- fastPathKey,
950
- } = getOrComputeStylesheetResults(htmlOrSvgAsset);
951
- fontFaceDeclarationsByHtmlOrSvgAsset.set(
693
+ return {
694
+ accumulatedFontFaceDeclarations,
695
+ stylesheetsWithPredicates,
696
+ fontFamiliesWithFeatureSettings,
697
+ featureTagsByFamily,
698
+ fastPathKey: buildStylesheetKey(
952
699
  htmlOrSvgAsset,
953
- accumulatedFontFaceDeclarations
954
- );
700
+ true,
701
+ stylesheetRelsByFromAsset
702
+ ),
703
+ };
704
+ }
955
705
 
956
- if (accumulatedFontFaceDeclarations.length === 0) {
957
- continue;
958
- }
706
+ // Strip `-subfont-text` nodes from CSS @font-face declarations once the
707
+ // subset planning is done, so they don't leak to the rendered output.
708
+ function stripSubfontTextNodes(fontFaceDeclarationsByHtmlOrSvgAsset) {
709
+ for (const fontFaceDeclarations of fontFaceDeclarationsByHtmlOrSvgAsset.values()) {
710
+ for (const fontFaceDeclaration of fontFaceDeclarations) {
711
+ const firstRelation = fontFaceDeclaration.relations[0];
712
+ const subfontTextNode = firstRelation.node.nodes.find(
713
+ (childNode) =>
714
+ childNode.type === 'decl' &&
715
+ childNode.prop.toLowerCase() === '-subfont-text'
716
+ );
959
717
 
960
- pageData.push({
961
- htmlOrSvgAsset,
962
- accumulatedFontFaceDeclarations,
963
- stylesheetsWithPredicates,
964
- fontFamiliesWithFeatureSettings,
965
- featureTagsByFamily,
966
- stylesheetCacheKey: fastPathKey,
967
- });
718
+ if (subfontTextNode) {
719
+ subfontTextNode.remove();
720
+ firstRelation.from.markDirty();
721
+ }
722
+ }
968
723
  }
724
+ }
969
725
 
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
- );
974
-
975
- // Group pages by stylesheet cache key — pages sharing the same CSS
976
- // configuration produce identical font-tracer props, only text differs.
726
+ // Split trace work: with a headless browser every page needs a full trace
727
+ // (dynamic content); otherwise one representative per stylesheet group is
728
+ // traced and the rest use fast-path text extraction.
729
+ function planTracing(pageData, hasHeadlessBrowser) {
977
730
  const pagesByStylesheetKey = new Map();
978
731
  for (const pd of pageData) {
979
732
  let group = pagesByStylesheetKey.get(pd.stylesheetCacheKey);
@@ -986,7 +739,7 @@ async function collectTextsByPage(
986
739
 
987
740
  const pagesNeedingFullTrace = [];
988
741
  const fastPathPages = [];
989
- if (headlessBrowser) {
742
+ if (hasHeadlessBrowser) {
990
743
  for (const pd of pageData) {
991
744
  pagesNeedingFullTrace.push(pd);
992
745
  }
@@ -1000,91 +753,37 @@ async function collectTextsByPage(
1000
753
  }
1001
754
  }
1002
755
 
1003
- if (debug && console)
1004
- console.log(
1005
- `[subfont timing] CSS groups: ${pagesByStylesheetKey.size} unique, ${pagesNeedingFullTrace.length} to trace, ${fastPathPages.length} fast-path`
1006
- );
1007
-
1008
- const tracingStart = Date.now();
1009
- try {
1010
- await tracePages(pagesNeedingFullTrace, {
1011
- headlessBrowser,
1012
- concurrency,
1013
- console,
1014
- memoizedGetCssRulesByProperty,
1015
- });
1016
-
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
- );
1022
-
1023
- processFastPathPages(fastPathPages, {
1024
- memoizedGetCssRulesByProperty,
1025
- console,
1026
- debug,
1027
- subTimings,
1028
- });
1029
-
1030
- const assembleStart = Date.now();
1031
- for (const pd of pageData) {
1032
- for (const textByPropsEntry of pd.textByProps) {
1033
- textByPropsEntry.htmlOrSvgAsset = pd.htmlOrSvgAsset;
1034
- }
1035
- // Use a loop instead of push(...spread) to avoid stack overflow on large sites
1036
- for (const entry of pd.textByProps) {
1037
- globalTextByProps.push(entry);
1038
- }
1039
- htmlOrSvgAssetTextsWithProps.push({
1040
- htmlOrSvgAsset: pd.htmlOrSvgAsset,
1041
- textByProps: pd.textByProps,
1042
- accumulatedFontFaceDeclarations: pd.accumulatedFontFaceDeclarations,
1043
- fontFamiliesWithFeatureSettings: pd.fontFamiliesWithFeatureSettings,
1044
- featureTagsByFamily: pd.featureTagsByFamily,
1045
- });
1046
- }
1047
-
1048
- subTimings['Result assembly'] = Date.now() - assembleStart;
1049
- if (debug && console) {
1050
- console.log(
1051
- `[subfont timing] Result assembly: ${subTimings['Result assembly']}ms`
1052
- );
1053
- console.log(
1054
- `[subfont timing] Total tracing+extraction+assembly: ${
1055
- Date.now() - tracingStart
1056
- }ms`
1057
- );
1058
- }
1059
- } finally {
1060
- if (headlessBrowser) {
1061
- await headlessBrowser.close();
1062
- }
1063
- }
1064
-
1065
- const postProcessStart = Date.now();
756
+ return {
757
+ pagesNeedingFullTrace,
758
+ fastPathPages,
759
+ uniqueGroupCount: pagesByStylesheetKey.size,
760
+ };
761
+ }
1066
762
 
1067
- // Consolidated cache for per-declarations-key data.
763
+ // Iterate every traced page, snap its text against the @font-face set, and
764
+ // emit fully-formed per-page fontUsages (one entry per font URL + props).
765
+ // Caching is per declarations-key (declCache) and per raw pageText
766
+ // (uniqueCharsCache) so sites with many similar pages stay linear.
767
+ function buildPerPageFontUsages(
768
+ htmlOrSvgAssetTextsWithProps,
769
+ globalTextByProps,
770
+ text
771
+ ) {
1068
772
  const declCache = new Map();
1069
-
1070
- const perPageLoopStart = Date.now();
773
+ const uniqueCharsCache = new Map();
1071
774
  let snappingTime = 0;
1072
775
  let globalUsageTime = 0;
1073
-
1074
- // Cache uniqueChars results by raw pageText string to avoid recomputing
1075
- const uniqueCharsCache = new Map();
1076
776
  let cloningTime = 0;
1077
777
 
1078
- for (const htmlOrSvgAssetTextsWithPropsEntry of htmlOrSvgAssetTextsWithProps) {
778
+ for (const entry of htmlOrSvgAssetTextsWithProps) {
1079
779
  const {
1080
780
  htmlOrSvgAsset,
1081
781
  textByProps,
1082
782
  accumulatedFontFaceDeclarations,
1083
783
  fontFamiliesWithFeatureSettings,
1084
784
  featureTagsByFamily,
1085
- } = htmlOrSvgAssetTextsWithPropsEntry;
785
+ } = entry;
1086
786
 
1087
- // Get or compute the snapped global entries for this declarations set
1088
787
  const declKey = getDeclarationsKey(accumulatedFontFaceDeclarations);
1089
788
  if (!declCache.has(declKey)) {
1090
789
  const snapStart = Date.now();
@@ -1100,108 +799,260 @@ async function collectTextsByPage(
1100
799
  snappingTime += Date.now() - snapStart;
1101
800
  }
1102
801
 
1103
- // Precompute global font usage templates and indices once per declarations key
1104
802
  const declCacheEntry = declCache.get(declKey);
1105
803
  const globalUsageStart = Date.now();
1106
- getOrComputeGlobalFontUsages(
804
+ populateGlobalFontUsages(
1107
805
  declCacheEntry,
1108
806
  accumulatedFontFaceDeclarations,
1109
807
  text
1110
808
  );
1111
809
  globalUsageTime += Date.now() - globalUsageStart;
1112
810
 
1113
- const fontUsageTemplates = declCacheEntry.fontUsageTemplates;
1114
- const pageTextIndex = declCacheEntry.pageTextIndex;
1115
- const textAndPropsToFontUrl = declCacheEntry.preloadIndex;
811
+ const {
812
+ fontUsageTemplates,
813
+ pageTextIndex,
814
+ preloadIndex: textAndPropsToFontUrl,
815
+ } = declCacheEntry;
1116
816
 
1117
- // Compute preload per fontUrl using inverted index
1118
817
  const preloadFontUrls = new Set();
1119
- for (const entry of textByProps) {
1120
- const fontUrl = textAndPropsToFontUrl.get(entry);
818
+ for (const textByPropsEntry of textByProps) {
819
+ const fontUrl = textAndPropsToFontUrl.get(textByPropsEntry);
1121
820
  if (fontUrl) {
1122
821
  preloadFontUrls.add(fontUrl);
1123
822
  }
1124
823
  }
1125
824
 
1126
- // Build per-page fontUsages from precomputed templates
1127
825
  const cloneStart = Date.now();
1128
826
  const assetTexts = pageTextIndex.get(htmlOrSvgAsset);
1129
- htmlOrSvgAssetTextsWithPropsEntry.fontUsages = fontUsageTemplates.map(
1130
- (template) => {
1131
- const pageTexts = assetTexts
1132
- ? assetTexts.get(template.fontUrl)
1133
- : undefined;
1134
- let pageTextStr = pageTexts ? pageTexts.join('') : '';
1135
- if (template.extraTextsStr) {
1136
- pageTextStr += template.extraTextsStr;
1137
- }
827
+ entry.fontUsages = fontUsageTemplates.map((template) => {
828
+ const pageTexts = assetTexts
829
+ ? assetTexts.get(template.fontUrl)
830
+ : undefined;
831
+ let pageTextStr = pageTexts ? pageTexts.join('') : '';
832
+ if (template.extraTextsStr) {
833
+ pageTextStr += template.extraTextsStr;
834
+ }
1138
835
 
1139
- let pageTextUnique = uniqueCharsCache.get(pageTextStr);
1140
- if (pageTextUnique === undefined) {
1141
- pageTextUnique = uniqueChars(pageTextStr);
1142
- uniqueCharsCache.set(pageTextStr, pageTextUnique);
1143
- }
836
+ let pageTextUnique = uniqueCharsCache.get(pageTextStr);
837
+ if (pageTextUnique === undefined) {
838
+ pageTextUnique = uniqueChars(pageTextStr);
839
+ uniqueCharsCache.set(pageTextStr, pageTextUnique);
840
+ }
1144
841
 
1145
- const { hasFontFeatureSettings, fontFeatureTags } =
1146
- resolveFeatureSettings(
1147
- template.fontFamilies,
1148
- fontFamiliesWithFeatureSettings,
1149
- featureTagsByFamily
1150
- );
842
+ const { hasFontFeatureSettings, fontFeatureTags } =
843
+ resolveFeatureSettings(
844
+ template.fontFamilies,
845
+ fontFamiliesWithFeatureSettings,
846
+ featureTagsByFamily
847
+ );
1151
848
 
1152
- return {
1153
- smallestOriginalSize: template.smallestOriginalSize,
1154
- smallestOriginalFormat: template.smallestOriginalFormat,
1155
- texts: template.texts,
1156
- pageText: pageTextUnique,
1157
- text: template.text,
1158
- props: { ...template.props },
1159
- fontUrl: template.fontUrl,
1160
- fontFamilies: template.fontFamilies,
1161
- fontStyles: template.fontStyles,
1162
- fontStretches: template.fontStretches,
1163
- fontWeights: template.fontWeights,
1164
- fontVariationSettings: template.fontVariationSettings,
1165
- fontSizes: template.fontSizes,
1166
- preload: preloadFontUrls.has(template.fontUrl),
1167
- hasFontFeatureSettings,
1168
- fontFeatureTags,
1169
- };
1170
- }
1171
- );
849
+ return {
850
+ smallestOriginalSize: template.smallestOriginalSize,
851
+ smallestOriginalFormat: template.smallestOriginalFormat,
852
+ texts: template.texts,
853
+ pageText: pageTextUnique,
854
+ text: template.text,
855
+ props: { ...template.props },
856
+ fontUrl: template.fontUrl,
857
+ fontFamilies: template.fontFamilies,
858
+ fontStyles: template.fontStyles,
859
+ fontStretches: template.fontStretches,
860
+ fontWeights: template.fontWeights,
861
+ fontVariationSettings: template.fontVariationSettings,
862
+ preload: preloadFontUrls.has(template.fontUrl),
863
+ hasFontFeatureSettings,
864
+ fontFeatureTags,
865
+ };
866
+ });
1172
867
  cloningTime += Date.now() - cloneStart;
1173
868
  }
1174
869
 
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)`
870
+ return { snappingTime, globalUsageTime, cloningTime };
871
+ }
872
+
873
+ // Run computeStylesheetResults once per page, memoizing the result across
874
+ // pages that resolve to the same set of stylesheets. Pages without any
875
+ // @font-face declarations are recorded in the declarations map but skipped
876
+ // from pageData (nothing to trace or subset for them).
877
+ function precomputeStylesheetsForPages(
878
+ htmlOrSvgAssets,
879
+ stylesheetRelsByFromAsset,
880
+ fontFaceDeclarationsByHtmlOrSvgAsset
881
+ ) {
882
+ const stylesheetResultCache = new Map();
883
+ const pageData = [];
884
+
885
+ for (const htmlOrSvgAsset of htmlOrSvgAssets) {
886
+ const key = buildStylesheetKey(
887
+ htmlOrSvgAsset,
888
+ false,
889
+ stylesheetRelsByFromAsset
1180
890
  );
1181
- if (debug && console)
1182
- console.log(
1183
- `[subfont timing] Post-processing total: ${subTimings['Post-processing total']}ms`
891
+ let result = stylesheetResultCache.get(key);
892
+ if (!result) {
893
+ result = computeStylesheetResults(
894
+ htmlOrSvgAsset,
895
+ stylesheetRelsByFromAsset
896
+ );
897
+ stylesheetResultCache.set(key, result);
898
+ }
899
+
900
+ fontFaceDeclarationsByHtmlOrSvgAsset.set(
901
+ htmlOrSvgAsset,
902
+ result.accumulatedFontFaceDeclarations
1184
903
  );
1185
- if (debug && console)
904
+
905
+ if (result.accumulatedFontFaceDeclarations.length === 0) {
906
+ continue;
907
+ }
908
+
909
+ pageData.push({
910
+ htmlOrSvgAsset,
911
+ accumulatedFontFaceDeclarations: result.accumulatedFontFaceDeclarations,
912
+ stylesheetsWithPredicates: result.stylesheetsWithPredicates,
913
+ fontFamiliesWithFeatureSettings: result.fontFamiliesWithFeatureSettings,
914
+ featureTagsByFamily: result.featureTagsByFamily,
915
+ stylesheetCacheKey: result.fastPathKey,
916
+ });
917
+ }
918
+
919
+ return pageData;
920
+ }
921
+
922
+ // Flatten traced per-page textByProps into a single globalTextByProps array,
923
+ // tagging each entry with its owning asset so downstream code can map text
924
+ // back to the page that rendered it.
925
+ function flattenTracedPagesIntoGlobal(
926
+ pageData,
927
+ htmlOrSvgAssetTextsWithProps,
928
+ globalTextByProps
929
+ ) {
930
+ for (const pd of pageData) {
931
+ for (const textByPropsEntry of pd.textByProps) {
932
+ textByPropsEntry.htmlOrSvgAsset = pd.htmlOrSvgAsset;
933
+ }
934
+ // Use a loop instead of push(...spread) to avoid stack overflow on large sites
935
+ for (const entry of pd.textByProps) {
936
+ globalTextByProps.push(entry);
937
+ }
938
+ htmlOrSvgAssetTextsWithProps.push({
939
+ htmlOrSvgAsset: pd.htmlOrSvgAsset,
940
+ textByProps: pd.textByProps,
941
+ accumulatedFontFaceDeclarations: pd.accumulatedFontFaceDeclarations,
942
+ fontFamiliesWithFeatureSettings: pd.fontFamiliesWithFeatureSettings,
943
+ featureTagsByFamily: pd.featureTagsByFamily,
944
+ });
945
+ }
946
+ }
947
+
948
+ async function collectTextsByPage(
949
+ assetGraph,
950
+ htmlOrSvgAssets,
951
+ {
952
+ text,
953
+ console,
954
+ dynamic = false,
955
+ debug = false,
956
+ concurrency,
957
+ chromeArgs = [],
958
+ } = {}
959
+ ) {
960
+ const htmlOrSvgAssetTextsWithProps = [];
961
+ const memoizedGetCssRulesByProperty = memoizeSync(getCssRulesByProperty);
962
+ const fontFaceDeclarationsByHtmlOrSvgAsset = new Map();
963
+ const stylesheetRelsByFromAsset = indexStylesheetRelations(assetGraph);
964
+
965
+ const headlessBrowser =
966
+ dynamic && new HeadlessBrowser({ console, chromeArgs });
967
+ const globalTextByProps = [];
968
+ const subTimings = {};
969
+
970
+ const trackPhase = makePhaseTracker(console, debug);
971
+ const overallPhase = trackPhase('collectTextsByPage');
972
+
973
+ const stylesheetPrecompute = trackPhase('Stylesheet precompute');
974
+ const pageData = precomputeStylesheetsForPages(
975
+ htmlOrSvgAssets,
976
+ stylesheetRelsByFromAsset,
977
+ fontFaceDeclarationsByHtmlOrSvgAsset
978
+ );
979
+ subTimings['Stylesheet precompute'] = stylesheetPrecompute.end(
980
+ `${pageData.length} pages with fonts`
981
+ );
982
+
983
+ // Pages sharing the same CSS configuration produce identical font-tracer
984
+ // props, only text differs — so we trace one representative and fast-path
985
+ // the rest. With --dynamic every page is traced individually.
986
+ const { pagesNeedingFullTrace, fastPathPages, uniqueGroupCount } =
987
+ planTracing(pageData, Boolean(headlessBrowser));
988
+
989
+ // Always surface the per-page work breakdown so users can tell at a
990
+ // glance how much of the run is actual tracing vs cheap CSS-group
991
+ // reuse. The threshold matches createPageProgress's minTotal so it
992
+ // only appears on non-trivial runs.
993
+ if (console && pageData.length >= 5) {
1186
994
  console.log(
1187
- `[subfont timing] collectTextsByPage total: ${Date.now() - timingStart}ms`
995
+ ` ${pageData.length} pages with fonts: ${pagesNeedingFullTrace.length} to trace, ${fastPathPages.length} via cached CSS group (${uniqueGroupCount} unique groups)`
1188
996
  );
997
+ }
1189
998
 
1190
- for (const fontFaceDeclarations of fontFaceDeclarationsByHtmlOrSvgAsset.values()) {
1191
- for (const fontFaceDeclaration of fontFaceDeclarations) {
1192
- const firstRelation = fontFaceDeclaration.relations[0];
1193
- const subfontTextNode = firstRelation.node.nodes.find(
1194
- (childNode) =>
1195
- childNode.type === 'decl' &&
1196
- childNode.prop.toLowerCase() === '-subfont-text'
1197
- );
999
+ const tracingStart = Date.now();
1000
+ const fullTracing = trackPhase(
1001
+ `Full tracing (${pagesNeedingFullTrace.length} pages)`
1002
+ );
1003
+ try {
1004
+ await tracePages(pagesNeedingFullTrace, {
1005
+ headlessBrowser,
1006
+ concurrency,
1007
+ console,
1008
+ memoizedGetCssRulesByProperty,
1009
+ debug,
1010
+ });
1198
1011
 
1199
- if (subfontTextNode) {
1200
- subfontTextNode.remove();
1201
- firstRelation.from.markDirty();
1202
- }
1012
+ subTimings['Full tracing'] = fullTracing.end();
1013
+
1014
+ const fastPathPhase = trackPhase('Fast-path extraction');
1015
+ const fastPathFallbacks = processFastPathPages(fastPathPages, {
1016
+ memoizedGetCssRulesByProperty,
1017
+ });
1018
+ subTimings['Fast-path extraction'] = fastPathPhase.end(
1019
+ `${fastPathPages.length} pages, ${fastPathFallbacks} fell back to full trace`
1020
+ );
1021
+
1022
+ const assemblePhase = trackPhase('Result assembly');
1023
+ flattenTracedPagesIntoGlobal(
1024
+ pageData,
1025
+ htmlOrSvgAssetTextsWithProps,
1026
+ globalTextByProps
1027
+ );
1028
+ subTimings['Result assembly'] = assemblePhase.end();
1029
+ if (debug && console) {
1030
+ console.log(
1031
+ `[subfont timing] Total tracing+extraction+assembly: ${
1032
+ Date.now() - tracingStart
1033
+ }ms`
1034
+ );
1035
+ }
1036
+ } finally {
1037
+ if (headlessBrowser) {
1038
+ await headlessBrowser.close();
1203
1039
  }
1204
1040
  }
1041
+
1042
+ const postProcessPhase = trackPhase('Post-processing total');
1043
+ const perPageLoopPhase = trackPhase('Per-page loop');
1044
+ const { snappingTime, globalUsageTime, cloningTime } = buildPerPageFontUsages(
1045
+ htmlOrSvgAssetTextsWithProps,
1046
+ globalTextByProps,
1047
+ text
1048
+ );
1049
+ subTimings['Per-page loop'] = perPageLoopPhase.end(
1050
+ `snapping: ${snappingTime}ms, globalUsage: ${globalUsageTime}ms, cloning: ${cloningTime}ms`
1051
+ );
1052
+ subTimings['Post-processing total'] = postProcessPhase.end();
1053
+ overallPhase.end();
1054
+
1055
+ stripSubfontTextNodes(fontFaceDeclarationsByHtmlOrSvgAsset);
1205
1056
  return {
1206
1057
  htmlOrSvgAssetTextsWithProps,
1207
1058
  fontFaceDeclarationsByHtmlOrSvgAsset,
@@ -1210,9 +1061,3 @@ async function collectTextsByPage(
1210
1061
  }
1211
1062
 
1212
1063
  module.exports = collectTextsByPage;
1213
-
1214
- // Exported for testing only
1215
- module.exports._extractFeatureTagsFromDecl = extractFeatureTagsFromDecl;
1216
- module.exports._resolveFeatureSettings = resolveFeatureSettings;
1217
- module.exports._findFontFamiliesWithFeatureSettings =
1218
- findFontFamiliesWithFeatureSettings;