@turntrout/subfont 1.0.1 → 1.0.3
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/collectTextsByPage.js +239 -229
- package/lib/getCssRulesByProperty.js +91 -16
- package/lib/normalizeFontPropertyValue.js +49 -8
- package/lib/subsetFonts.js +356 -305
- package/package.json +8 -4
|
@@ -352,6 +352,202 @@ function getOrComputeGlobalFontUsages(
|
|
|
352
352
|
cached.preloadIndex = textAndPropsToFontUrl;
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
// Extract font tracing (worker pool + sequential) to reduce
|
|
356
|
+
// cyclomatic complexity of collectTextsByPage.
|
|
357
|
+
async function tracePages(
|
|
358
|
+
pagesNeedingFullTrace,
|
|
359
|
+
{ headlessBrowser, concurrency, console, memoizedGetCssRulesByProperty }
|
|
360
|
+
) {
|
|
361
|
+
const useWorkerPool =
|
|
362
|
+
!headlessBrowser &&
|
|
363
|
+
pagesNeedingFullTrace.length >= MIN_PAGES_FOR_WORKER_POOL;
|
|
364
|
+
|
|
365
|
+
if (useWorkerPool) {
|
|
366
|
+
const maxWorkers =
|
|
367
|
+
concurrency > 0 ? concurrency : Math.min(os.cpus().length, 8);
|
|
368
|
+
const numWorkers = Math.min(maxWorkers, pagesNeedingFullTrace.length);
|
|
369
|
+
const pool = new FontTracerPool(numWorkers);
|
|
370
|
+
await pool.init();
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const totalPages = pagesNeedingFullTrace.length;
|
|
374
|
+
const showProgress = totalPages >= 10 && console;
|
|
375
|
+
let tracedCount = 0;
|
|
376
|
+
const tracePromises = pagesNeedingFullTrace.map(async (pd) => {
|
|
377
|
+
try {
|
|
378
|
+
pd.textByProps = await pool.trace(
|
|
379
|
+
pd.htmlOrSvgAsset.text || '',
|
|
380
|
+
pd.stylesheetsWithPredicates
|
|
381
|
+
);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (console) {
|
|
384
|
+
console.warn(
|
|
385
|
+
`Worker fontTracer failed for ${pd.htmlOrSvgAsset.url}, falling back to main thread: ${err.message}`
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
|
|
389
|
+
stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
|
|
390
|
+
getCssRulesByProperty: memoizedGetCssRulesByProperty,
|
|
391
|
+
asset: pd.htmlOrSvgAsset,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
tracedCount++;
|
|
395
|
+
if (showProgress && tracedCount % 10 === 0) {
|
|
396
|
+
console.log(` Tracing fonts: ${tracedCount}/${totalPages} pages...`);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
await Promise.all(tracePromises);
|
|
400
|
+
await pool.destroy();
|
|
401
|
+
} catch (err) {
|
|
402
|
+
await pool.destroy();
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
} else if (pagesNeedingFullTrace.length > 0) {
|
|
406
|
+
const totalPages = pagesNeedingFullTrace.length;
|
|
407
|
+
const showProgress = totalPages >= 10 && console;
|
|
408
|
+
for (let pi = 0; pi < totalPages; pi++) {
|
|
409
|
+
const pd = pagesNeedingFullTrace[pi];
|
|
410
|
+
pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
|
|
411
|
+
stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
|
|
412
|
+
getCssRulesByProperty: memoizedGetCssRulesByProperty,
|
|
413
|
+
asset: pd.htmlOrSvgAsset,
|
|
414
|
+
});
|
|
415
|
+
if (headlessBrowser) {
|
|
416
|
+
pd.textByProps.push(
|
|
417
|
+
...(await headlessBrowser.tracePage(pd.htmlOrSvgAsset))
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (showProgress && (pi + 1) % 10 === 0) {
|
|
421
|
+
console.log(` Tracing fonts: ${pi + 1}/${totalPages} pages...`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Extract fast-path text extraction to reduce collectTextsByPage complexity.
|
|
428
|
+
// Pages sharing the same CSS configuration reuse the representative's
|
|
429
|
+
// props and only extract visible text content.
|
|
430
|
+
function processFastPathPages(
|
|
431
|
+
fastPathPages,
|
|
432
|
+
{ memoizedGetCssRulesByProperty, console, debug, subTimings }
|
|
433
|
+
) {
|
|
434
|
+
if (fastPathPages.length === 0) return;
|
|
435
|
+
|
|
436
|
+
const fastPathStart = Date.now();
|
|
437
|
+
|
|
438
|
+
const repDataCache = new Map();
|
|
439
|
+
function getRepData(representativePd) {
|
|
440
|
+
if (repDataCache.has(representativePd)) {
|
|
441
|
+
return repDataCache.get(representativePd);
|
|
442
|
+
}
|
|
443
|
+
const repTextByProps = representativePd.textByProps;
|
|
444
|
+
|
|
445
|
+
const uniquePropsMap = new Map();
|
|
446
|
+
const textPerPropsKey = new Map();
|
|
447
|
+
const seenVariantKeys = new Set();
|
|
448
|
+
for (const entry of repTextByProps) {
|
|
449
|
+
const family = entry.props['font-family'] || '';
|
|
450
|
+
const propsKey = fontPropsKey(
|
|
451
|
+
family,
|
|
452
|
+
entry.props['font-weight'] || '',
|
|
453
|
+
entry.props['font-style'] || '',
|
|
454
|
+
entry.props['font-stretch'] || ''
|
|
455
|
+
);
|
|
456
|
+
if (!uniquePropsMap.has(propsKey)) {
|
|
457
|
+
uniquePropsMap.set(propsKey, entry.props);
|
|
458
|
+
textPerPropsKey.set(propsKey, []);
|
|
459
|
+
}
|
|
460
|
+
textPerPropsKey.get(propsKey).push(entry.text);
|
|
461
|
+
if (family) {
|
|
462
|
+
const weight = entry.props['font-weight'] || 'normal';
|
|
463
|
+
const style = entry.props['font-style'] || 'normal';
|
|
464
|
+
const stretch = entry.props['font-stretch'] || 'normal';
|
|
465
|
+
for (const fam of cssFontParser.parseFontFamily(family)) {
|
|
466
|
+
seenVariantKeys.add(
|
|
467
|
+
fontPropsKey(fam.toLowerCase(), weight, style, stretch)
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const data = { uniquePropsMap, textPerPropsKey, seenVariantKeys };
|
|
473
|
+
repDataCache.set(representativePd, data);
|
|
474
|
+
return data;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let fastPathFallbacks = 0;
|
|
478
|
+
for (const pd of fastPathPages) {
|
|
479
|
+
if (hasInlineFontStyles(pd.htmlOrSvgAsset.text || '')) {
|
|
480
|
+
fastPathFallbacks++;
|
|
481
|
+
pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
|
|
482
|
+
stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
|
|
483
|
+
getCssRulesByProperty: memoizedGetCssRulesByProperty,
|
|
484
|
+
asset: pd.htmlOrSvgAsset,
|
|
485
|
+
});
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const { uniquePropsMap, textPerPropsKey, seenVariantKeys } = getRepData(
|
|
490
|
+
pd.representativePd
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// Check if any @font-face variants are unseen by the representative.
|
|
494
|
+
// Only copy Maps when extensions are actually needed.
|
|
495
|
+
let effectivePropsMap = uniquePropsMap;
|
|
496
|
+
let effectiveTextPerPropsKey = textPerPropsKey;
|
|
497
|
+
for (const decl of pd.accumulatedFontFaceDeclarations) {
|
|
498
|
+
const family = decl['font-family'];
|
|
499
|
+
if (!family) continue;
|
|
500
|
+
const weight = decl['font-weight'] || 'normal';
|
|
501
|
+
const style = decl['font-style'] || 'normal';
|
|
502
|
+
const stretch = decl['font-stretch'] || 'normal';
|
|
503
|
+
const variantKey = fontPropsKey(
|
|
504
|
+
family.toLowerCase(),
|
|
505
|
+
weight,
|
|
506
|
+
style,
|
|
507
|
+
stretch
|
|
508
|
+
);
|
|
509
|
+
if (!seenVariantKeys.has(variantKey)) {
|
|
510
|
+
// Lazy-copy on first unseen variant
|
|
511
|
+
if (effectivePropsMap === uniquePropsMap) {
|
|
512
|
+
effectivePropsMap = new Map(uniquePropsMap);
|
|
513
|
+
effectiveTextPerPropsKey = new Map(textPerPropsKey);
|
|
514
|
+
}
|
|
515
|
+
const propsKey = fontPropsKey(
|
|
516
|
+
stringifyFontFamily(family),
|
|
517
|
+
weight,
|
|
518
|
+
style,
|
|
519
|
+
stretch
|
|
520
|
+
);
|
|
521
|
+
if (!effectivePropsMap.has(propsKey)) {
|
|
522
|
+
effectivePropsMap.set(propsKey, {
|
|
523
|
+
'font-family': stringifyFontFamily(family),
|
|
524
|
+
'font-weight': weight,
|
|
525
|
+
'font-style': style,
|
|
526
|
+
'font-stretch': stretch,
|
|
527
|
+
});
|
|
528
|
+
effectiveTextPerPropsKey.set(propsKey, []);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const pageText = extractVisibleText(pd.htmlOrSvgAsset.text || '');
|
|
534
|
+
|
|
535
|
+
pd.textByProps = [];
|
|
536
|
+
for (const [propsKey, props] of effectivePropsMap) {
|
|
537
|
+
const repTexts = effectiveTextPerPropsKey.get(propsKey) || [];
|
|
538
|
+
pd.textByProps.push({
|
|
539
|
+
text: pageText + repTexts.join(''),
|
|
540
|
+
props: { ...props },
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
subTimings['Fast-path extraction'] = Date.now() - fastPathStart;
|
|
545
|
+
if (debug && console)
|
|
546
|
+
console.log(
|
|
547
|
+
`[subfont timing] Fast-path text extraction (${fastPathPages.length} pages, ${fastPathFallbacks} fell back to full trace): ${subTimings['Fast-path extraction']}ms`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
355
551
|
async function collectTextsByPage(
|
|
356
552
|
assetGraph,
|
|
357
553
|
htmlOrSvgAssets,
|
|
@@ -454,39 +650,30 @@ async function collectTextsByPage(
|
|
|
454
650
|
|
|
455
651
|
if (asset.type === 'Css' && asset.isLoaded) {
|
|
456
652
|
const seenNodes = new Set();
|
|
457
|
-
|
|
458
653
|
const fontRelations = asset.outgoingRelations.filter(
|
|
459
654
|
(relation) => relation.type === 'CssFontFaceSrc'
|
|
460
655
|
);
|
|
461
656
|
|
|
462
657
|
for (const fontRelation of fontRelations) {
|
|
463
658
|
const node = fontRelation.node;
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
});
|
|
483
|
-
// Disregard incomplete @font-face declarations (must contain font-family and src per spec):
|
|
484
|
-
if (
|
|
485
|
-
fontFaceDeclaration['font-family'] &&
|
|
486
|
-
fontFaceDeclaration.src
|
|
487
|
-
) {
|
|
488
|
-
accumulatedFontFaceDeclarations.push(fontFaceDeclaration);
|
|
489
|
-
}
|
|
659
|
+
if (seenNodes.has(node)) continue;
|
|
660
|
+
seenNodes.add(node);
|
|
661
|
+
|
|
662
|
+
const fontFaceDeclaration = {
|
|
663
|
+
relations: fontRelations.filter((r) => r.node === node),
|
|
664
|
+
...initialValueByProp,
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
node.walkDecls((declaration) => {
|
|
668
|
+
const propName = declaration.prop.toLowerCase();
|
|
669
|
+
fontFaceDeclaration[propName] =
|
|
670
|
+
propName === 'font-family'
|
|
671
|
+
? cssFontParser.parseFontFamily(declaration.value)[0]
|
|
672
|
+
: declaration.value;
|
|
673
|
+
});
|
|
674
|
+
// Disregard incomplete @font-face declarations (must contain font-family and src per spec):
|
|
675
|
+
if (fontFaceDeclaration['font-family'] && fontFaceDeclaration.src) {
|
|
676
|
+
accumulatedFontFaceDeclarations.push(fontFaceDeclaration);
|
|
490
677
|
}
|
|
491
678
|
}
|
|
492
679
|
}
|
|
@@ -507,24 +694,19 @@ async function collectTextsByPage(
|
|
|
507
694
|
// Group @font-face declarations that share family/style/weight but have
|
|
508
695
|
// different unicode-range values. Each group's members cover a disjoint
|
|
509
696
|
// subset of the Unicode space (common for CJK / large character-set fonts).
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
throw new Error(
|
|
524
|
-
`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}`
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
697
|
+
const comboGroups = new Map();
|
|
698
|
+
for (const fontFace of accumulatedFontFaceDeclarations) {
|
|
699
|
+
const comboKey = `${fontFace['font-family']}/${fontFace['font-style']}/${fontFace['font-weight']}`;
|
|
700
|
+
if (!comboGroups.has(comboKey)) comboGroups.set(comboKey, []);
|
|
701
|
+
comboGroups.get(comboKey).push(fontFace);
|
|
702
|
+
}
|
|
703
|
+
for (const [comboKey, group] of comboGroups) {
|
|
704
|
+
if (group.length <= 1) continue;
|
|
705
|
+
const withoutRange = group.filter((d) => !d['unicode-range']);
|
|
706
|
+
if (withoutRange.length > 0) {
|
|
707
|
+
throw new Error(
|
|
708
|
+
`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}`
|
|
709
|
+
);
|
|
528
710
|
}
|
|
529
711
|
}
|
|
530
712
|
|
|
@@ -617,75 +799,14 @@ async function collectTextsByPage(
|
|
|
617
799
|
`[subfont timing] CSS groups: ${pagesByStylesheetKey.size} unique, ${pagesNeedingFullTrace.length} to trace, ${fastPathPages.length} fast-path`
|
|
618
800
|
);
|
|
619
801
|
|
|
620
|
-
// Use worker pool for parallel fontTracer when there are enough pages
|
|
621
|
-
const useWorkerPool =
|
|
622
|
-
!headlessBrowser &&
|
|
623
|
-
pagesNeedingFullTrace.length >= MIN_PAGES_FOR_WORKER_POOL;
|
|
624
|
-
|
|
625
802
|
const tracingStart = Date.now();
|
|
626
803
|
try {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
try {
|
|
635
|
-
const totalPages = pagesNeedingFullTrace.length;
|
|
636
|
-
const showProgress = totalPages >= 10 && console;
|
|
637
|
-
let tracedCount = 0;
|
|
638
|
-
const tracePromises = pagesNeedingFullTrace.map(async (pd) => {
|
|
639
|
-
try {
|
|
640
|
-
pd.textByProps = await pool.trace(
|
|
641
|
-
pd.htmlOrSvgAsset.text || '',
|
|
642
|
-
pd.stylesheetsWithPredicates
|
|
643
|
-
);
|
|
644
|
-
} catch (err) {
|
|
645
|
-
if (console) {
|
|
646
|
-
console.warn(
|
|
647
|
-
`Worker fontTracer failed for ${pd.htmlOrSvgAsset.url}, falling back to main thread: ${err.message}`
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
|
|
651
|
-
stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
|
|
652
|
-
getCssRulesByProperty: memoizedGetCssRulesByProperty,
|
|
653
|
-
asset: pd.htmlOrSvgAsset,
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
tracedCount++;
|
|
657
|
-
if (showProgress && tracedCount % 10 === 0) {
|
|
658
|
-
console.log(
|
|
659
|
-
` Tracing fonts: ${tracedCount}/${totalPages} pages...`
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
});
|
|
663
|
-
await Promise.all(tracePromises);
|
|
664
|
-
await pool.destroy();
|
|
665
|
-
} catch (err) {
|
|
666
|
-
await pool.destroy();
|
|
667
|
-
throw err;
|
|
668
|
-
}
|
|
669
|
-
} else if (pagesNeedingFullTrace.length > 0) {
|
|
670
|
-
const totalPages = pagesNeedingFullTrace.length;
|
|
671
|
-
const showProgress = totalPages >= 10 && console;
|
|
672
|
-
for (let pi = 0; pi < totalPages; pi++) {
|
|
673
|
-
const pd = pagesNeedingFullTrace[pi];
|
|
674
|
-
pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
|
|
675
|
-
stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
|
|
676
|
-
getCssRulesByProperty: memoizedGetCssRulesByProperty,
|
|
677
|
-
asset: pd.htmlOrSvgAsset,
|
|
678
|
-
});
|
|
679
|
-
if (headlessBrowser) {
|
|
680
|
-
pd.textByProps.push(
|
|
681
|
-
...(await headlessBrowser.tracePage(pd.htmlOrSvgAsset))
|
|
682
|
-
);
|
|
683
|
-
}
|
|
684
|
-
if (showProgress && (pi + 1) % 10 === 0) {
|
|
685
|
-
console.log(` Tracing fonts: ${pi + 1}/${totalPages} pages...`);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
804
|
+
await tracePages(pagesNeedingFullTrace, {
|
|
805
|
+
headlessBrowser,
|
|
806
|
+
concurrency,
|
|
807
|
+
console,
|
|
808
|
+
memoizedGetCssRulesByProperty,
|
|
809
|
+
});
|
|
689
810
|
|
|
690
811
|
subTimings['Full tracing'] = Date.now() - tracingStart;
|
|
691
812
|
if (debug && console)
|
|
@@ -693,123 +814,12 @@ async function collectTextsByPage(
|
|
|
693
814
|
`[subfont timing] Full tracing (${pagesNeedingFullTrace.length} pages): ${subTimings['Full tracing']}ms`
|
|
694
815
|
);
|
|
695
816
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
function getRepData(representativePd) {
|
|
703
|
-
if (repDataCache.has(representativePd)) {
|
|
704
|
-
return repDataCache.get(representativePd);
|
|
705
|
-
}
|
|
706
|
-
const repTextByProps = representativePd.textByProps;
|
|
707
|
-
|
|
708
|
-
const uniquePropsMap = new Map();
|
|
709
|
-
const textPerPropsKey = new Map();
|
|
710
|
-
const seenVariantKeys = new Set();
|
|
711
|
-
for (const entry of repTextByProps) {
|
|
712
|
-
const family = entry.props['font-family'] || '';
|
|
713
|
-
const propsKey = fontPropsKey(
|
|
714
|
-
family,
|
|
715
|
-
entry.props['font-weight'] || '',
|
|
716
|
-
entry.props['font-style'] || '',
|
|
717
|
-
entry.props['font-stretch'] || ''
|
|
718
|
-
);
|
|
719
|
-
if (!uniquePropsMap.has(propsKey)) {
|
|
720
|
-
uniquePropsMap.set(propsKey, entry.props);
|
|
721
|
-
textPerPropsKey.set(propsKey, []);
|
|
722
|
-
}
|
|
723
|
-
textPerPropsKey.get(propsKey).push(entry.text);
|
|
724
|
-
if (family) {
|
|
725
|
-
const weight = entry.props['font-weight'] || 'normal';
|
|
726
|
-
const style = entry.props['font-style'] || 'normal';
|
|
727
|
-
const stretch = entry.props['font-stretch'] || 'normal';
|
|
728
|
-
for (const fam of cssFontParser.parseFontFamily(family)) {
|
|
729
|
-
seenVariantKeys.add(
|
|
730
|
-
fontPropsKey(fam.toLowerCase(), weight, style, stretch)
|
|
731
|
-
);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
const data = { uniquePropsMap, textPerPropsKey, seenVariantKeys };
|
|
736
|
-
repDataCache.set(representativePd, data);
|
|
737
|
-
return data;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
let fastPathFallbacks = 0;
|
|
741
|
-
for (const pd of fastPathPages) {
|
|
742
|
-
if (hasInlineFontStyles(pd.htmlOrSvgAsset.text || '')) {
|
|
743
|
-
fastPathFallbacks++;
|
|
744
|
-
pd.textByProps = fontTracer(pd.htmlOrSvgAsset.parseTree, {
|
|
745
|
-
stylesheetsWithPredicates: pd.stylesheetsWithPredicates,
|
|
746
|
-
getCssRulesByProperty: memoizedGetCssRulesByProperty,
|
|
747
|
-
asset: pd.htmlOrSvgAsset,
|
|
748
|
-
});
|
|
749
|
-
continue;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
const { uniquePropsMap, textPerPropsKey, seenVariantKeys } = getRepData(
|
|
753
|
-
pd.representativePd
|
|
754
|
-
);
|
|
755
|
-
|
|
756
|
-
// Check if any @font-face variants are unseen by the representative.
|
|
757
|
-
// Only copy Maps when extensions are actually needed.
|
|
758
|
-
let effectivePropsMap = uniquePropsMap;
|
|
759
|
-
let effectiveTextPerPropsKey = textPerPropsKey;
|
|
760
|
-
for (const decl of pd.accumulatedFontFaceDeclarations) {
|
|
761
|
-
const family = decl['font-family'];
|
|
762
|
-
if (!family) continue;
|
|
763
|
-
const weight = decl['font-weight'] || 'normal';
|
|
764
|
-
const style = decl['font-style'] || 'normal';
|
|
765
|
-
const stretch = decl['font-stretch'] || 'normal';
|
|
766
|
-
const variantKey = fontPropsKey(
|
|
767
|
-
family.toLowerCase(),
|
|
768
|
-
weight,
|
|
769
|
-
style,
|
|
770
|
-
stretch
|
|
771
|
-
);
|
|
772
|
-
if (!seenVariantKeys.has(variantKey)) {
|
|
773
|
-
// Lazy-copy on first unseen variant
|
|
774
|
-
if (effectivePropsMap === uniquePropsMap) {
|
|
775
|
-
effectivePropsMap = new Map(uniquePropsMap);
|
|
776
|
-
effectiveTextPerPropsKey = new Map(textPerPropsKey);
|
|
777
|
-
}
|
|
778
|
-
const propsKey = fontPropsKey(
|
|
779
|
-
stringifyFontFamily(family),
|
|
780
|
-
weight,
|
|
781
|
-
style,
|
|
782
|
-
stretch
|
|
783
|
-
);
|
|
784
|
-
if (!effectivePropsMap.has(propsKey)) {
|
|
785
|
-
effectivePropsMap.set(propsKey, {
|
|
786
|
-
'font-family': stringifyFontFamily(family),
|
|
787
|
-
'font-weight': weight,
|
|
788
|
-
'font-style': style,
|
|
789
|
-
'font-stretch': stretch,
|
|
790
|
-
});
|
|
791
|
-
effectiveTextPerPropsKey.set(propsKey, []);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
const pageText = extractVisibleText(pd.htmlOrSvgAsset.text || '');
|
|
797
|
-
|
|
798
|
-
pd.textByProps = [];
|
|
799
|
-
for (const [propsKey, props] of effectivePropsMap) {
|
|
800
|
-
const repTexts = effectiveTextPerPropsKey.get(propsKey) || [];
|
|
801
|
-
pd.textByProps.push({
|
|
802
|
-
text: pageText + repTexts.join(''),
|
|
803
|
-
props: { ...props },
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
subTimings['Fast-path extraction'] = Date.now() - fastPathStart;
|
|
808
|
-
if (debug && console)
|
|
809
|
-
console.log(
|
|
810
|
-
`[subfont timing] Fast-path text extraction (${fastPathPages.length} pages, ${fastPathFallbacks} fell back to full trace): ${subTimings['Fast-path extraction']}ms`
|
|
811
|
-
);
|
|
812
|
-
}
|
|
817
|
+
processFastPathPages(fastPathPages, {
|
|
818
|
+
memoizedGetCssRulesByProperty,
|
|
819
|
+
console,
|
|
820
|
+
debug,
|
|
821
|
+
subTimings,
|
|
822
|
+
});
|
|
813
823
|
|
|
814
824
|
const assembleStart = Date.now();
|
|
815
825
|
for (const pd of pageData) {
|
|
@@ -24,15 +24,49 @@ const counterRendererNames = new Set([
|
|
|
24
24
|
]);
|
|
25
25
|
|
|
26
26
|
function unwrapNamespace(str) {
|
|
27
|
-
if (/^"/.test(str)) {
|
|
27
|
+
if (/^["']/.test(str)) {
|
|
28
28
|
return unquote(str);
|
|
29
29
|
} else if (/^url\(.*\)$/i.test(str)) {
|
|
30
|
-
return unquote(str.replace(/^url\((.*)\)
|
|
30
|
+
return unquote(str.replace(/^url\((.*)\)$/i, '$1'));
|
|
31
31
|
} else {
|
|
32
32
|
throw new Error(`Cannot parse CSS namespace: ${str}`);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Build a collision-free fingerprint for a CSS rule entry. Null bytes (\0)
|
|
37
|
+
// delimit fields because they cannot appear in CSS property values.
|
|
38
|
+
function ruleFingerprint(rule) {
|
|
39
|
+
const predicateEntries = Object.keys(rule.predicates)
|
|
40
|
+
.sort()
|
|
41
|
+
.map((k) => `${k}=${rule.predicates[k]}`);
|
|
42
|
+
return [
|
|
43
|
+
rule.selector,
|
|
44
|
+
rule.value,
|
|
45
|
+
rule.prop,
|
|
46
|
+
rule.important,
|
|
47
|
+
(rule.specificityArray || []).join(','),
|
|
48
|
+
rule.namespaceURI,
|
|
49
|
+
predicateEntries.join('&'),
|
|
50
|
+
].join('\0');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Remove fully-duplicate rule entries (same selector, value, specificity,
|
|
54
|
+
// predicates, namespace, and importance) within each property.
|
|
55
|
+
function deduplicateRules(rulesByProperty) {
|
|
56
|
+
for (const key of Object.keys(rulesByProperty)) {
|
|
57
|
+
if (key === 'counterStyles' || key === 'keyframes') continue;
|
|
58
|
+
const rules = rulesByProperty[key];
|
|
59
|
+
if (rules.length <= 1) continue;
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
rulesByProperty[key] = rules.filter((rule) => {
|
|
62
|
+
const fp = ruleFingerprint(rule);
|
|
63
|
+
if (seen.has(fp)) return false;
|
|
64
|
+
seen.add(fp);
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
36
70
|
function getCssRulesByProperty(properties, cssSource, existingPredicates) {
|
|
37
71
|
if (!Array.isArray(properties)) {
|
|
38
72
|
throw new Error('properties argument must be an array');
|
|
@@ -44,13 +78,21 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
|
|
|
44
78
|
|
|
45
79
|
const parseTree = postcss.parse(cssSource);
|
|
46
80
|
let defaultNamespaceURI;
|
|
81
|
+
const namespacePrefixes = new Map();
|
|
82
|
+
// Parse @namespace rules: either a default namespace or a prefixed one.
|
|
83
|
+
// Spec: https://developer.mozilla.org/en-US/docs/Web/CSS/@namespace
|
|
84
|
+
// Grammar: @namespace <prefix>? [<string> | url(<uri>)]
|
|
47
85
|
parseTree.walkAtRules('namespace', (rule) => {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
86
|
+
const match = rule.params.match(
|
|
87
|
+
/^(?<prefix>\w+)\s+(?<uri>.+)$|^(?<defaultUri>.+)$/
|
|
88
|
+
);
|
|
89
|
+
if (!match) return;
|
|
90
|
+
const { prefix, uri, defaultUri } = match.groups;
|
|
91
|
+
if (prefix) {
|
|
92
|
+
namespacePrefixes.set(prefix, unwrapNamespace(uri));
|
|
93
|
+
} else {
|
|
94
|
+
defaultNamespaceURI = unwrapNamespace(defaultUri);
|
|
51
95
|
}
|
|
52
|
-
// FIXME: Support registering namespace prefixes (fragments.length === 2):
|
|
53
|
-
// https://developer.mozilla.org/en-US/docs/Web/CSS/@namespace
|
|
54
96
|
});
|
|
55
97
|
const rulesByProperty = {
|
|
56
98
|
counterStyles: [],
|
|
@@ -61,6 +103,33 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
|
|
|
61
103
|
rulesByProperty[property] = [];
|
|
62
104
|
}
|
|
63
105
|
|
|
106
|
+
// Resolve the namespace URI for a selector by examining its subject
|
|
107
|
+
// (the rightmost compound selector) for a namespace prefix like svg|text.
|
|
108
|
+
function resolveNamespaceURI(selector) {
|
|
109
|
+
if (namespacePrefixes.size === 0) {
|
|
110
|
+
return defaultNamespaceURI;
|
|
111
|
+
}
|
|
112
|
+
// Find the subject (rightmost simple selector before pseudo-elements).
|
|
113
|
+
// Split on combinators: whitespace, >, +, ~
|
|
114
|
+
const compoundSelectors = selector.split(/\s*[>+~]\s*|\s+/);
|
|
115
|
+
const subject = compoundSelectors[compoundSelectors.length - 1];
|
|
116
|
+
// Check for namespace prefix: prefix|element, *|element, or |element
|
|
117
|
+
const nsMatch = subject.match(/^(?<nsPrefix>\*|\w*)\|/);
|
|
118
|
+
if (!nsMatch) {
|
|
119
|
+
return defaultNamespaceURI;
|
|
120
|
+
}
|
|
121
|
+
const prefix = nsMatch.groups.nsPrefix;
|
|
122
|
+
if (prefix === '*') {
|
|
123
|
+
// *|element matches any namespace — no namespace filter
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (prefix === '') {
|
|
127
|
+
// |element means no namespace (elements not in any namespace)
|
|
128
|
+
return '';
|
|
129
|
+
}
|
|
130
|
+
return namespacePrefixes.get(prefix) || defaultNamespaceURI;
|
|
131
|
+
}
|
|
132
|
+
|
|
64
133
|
const specificityCache = new Map();
|
|
65
134
|
function getSpecificity(selector) {
|
|
66
135
|
let cached = specificityCache.get(selector);
|
|
@@ -87,12 +156,15 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
|
|
|
87
156
|
function pushRulePerSelector(node, prop, value) {
|
|
88
157
|
getSpecificity(node.parent.selector).forEach((specificityObject) => {
|
|
89
158
|
const isStyleAttribute = specificityObject.selector === 'bogusselector';
|
|
159
|
+
const selectorStr = isStyleAttribute
|
|
160
|
+
? undefined
|
|
161
|
+
: specificityObject.selector.trim();
|
|
90
162
|
(rulesByProperty[prop] = rulesByProperty[prop] || []).push({
|
|
91
163
|
predicates: getCurrentPredicates(),
|
|
92
|
-
namespaceURI:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
164
|
+
namespaceURI: isStyleAttribute
|
|
165
|
+
? defaultNamespaceURI
|
|
166
|
+
: resolveNamespaceURI(selectorStr),
|
|
167
|
+
selector: selectorStr,
|
|
96
168
|
specificityArray: isStyleAttribute
|
|
97
169
|
? [1, 0, 0, 0]
|
|
98
170
|
: specificityObject.specificityArray,
|
|
@@ -198,12 +270,15 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
|
|
|
198
270
|
getSpecificity(node.parent.selector).forEach((specificityObject) => {
|
|
199
271
|
const isStyleAttribute =
|
|
200
272
|
specificityObject.selector === 'bogusselector';
|
|
273
|
+
const fontSelector = isStyleAttribute
|
|
274
|
+
? undefined
|
|
275
|
+
: specificityObject.selector.trim();
|
|
201
276
|
const entry = {
|
|
202
277
|
predicates: getCurrentPredicates(),
|
|
203
|
-
namespaceURI:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
278
|
+
namespaceURI: isStyleAttribute
|
|
279
|
+
? defaultNamespaceURI
|
|
280
|
+
: resolveNamespaceURI(fontSelector),
|
|
281
|
+
selector: fontSelector,
|
|
207
282
|
specificityArray: isStyleAttribute
|
|
208
283
|
? [1, 0, 0, 0]
|
|
209
284
|
: specificityObject.specificityArray,
|
|
@@ -261,7 +336,7 @@ function getCssRulesByProperty(properties, cssSource, existingPredicates) {
|
|
|
261
336
|
}
|
|
262
337
|
})(parseTree);
|
|
263
338
|
|
|
264
|
-
|
|
339
|
+
deduplicateRules(rulesByProperty);
|
|
265
340
|
|
|
266
341
|
return rulesByProperty;
|
|
267
342
|
}
|