@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.
@@ -0,0 +1,249 @@
1
+ const cssFontParser = require('css-font-parser');
2
+
3
+ const featureSettingsProps = new Set([
4
+ 'font-feature-settings',
5
+ 'font-variant-alternates',
6
+ 'font-variant-caps',
7
+ 'font-variant-east-asian',
8
+ 'font-variant-ligatures',
9
+ 'font-variant-numeric',
10
+ 'font-variant-position',
11
+ ]);
12
+
13
+ // Map font-variant-* CSS values to their corresponding OpenType feature tags.
14
+ const fontVariantToOTTags = {
15
+ 'font-variant-ligatures': {
16
+ 'common-ligatures': ['liga', 'clig'],
17
+ 'no-common-ligatures': ['liga', 'clig'],
18
+ 'discretionary-ligatures': ['dlig'],
19
+ 'no-discretionary-ligatures': ['dlig'],
20
+ 'historical-ligatures': ['hlig'],
21
+ 'no-historical-ligatures': ['hlig'],
22
+ contextual: ['calt'],
23
+ 'no-contextual': ['calt'],
24
+ },
25
+ 'font-variant-caps': {
26
+ 'small-caps': ['smcp'],
27
+ 'all-small-caps': ['smcp', 'c2sc'],
28
+ 'petite-caps': ['pcap'],
29
+ 'all-petite-caps': ['pcap', 'c2pc'],
30
+ unicase: ['unic'],
31
+ 'titling-caps': ['titl'],
32
+ },
33
+ 'font-variant-numeric': {
34
+ 'lining-nums': ['lnum'],
35
+ 'oldstyle-nums': ['onum'],
36
+ 'proportional-nums': ['pnum'],
37
+ 'tabular-nums': ['tnum'],
38
+ 'diagonal-fractions': ['frac'],
39
+ 'stacked-fractions': ['afrc'],
40
+ ordinal: ['ordn'],
41
+ 'slashed-zero': ['zero'],
42
+ },
43
+ 'font-variant-position': {
44
+ sub: ['subs'],
45
+ super: ['sups'],
46
+ },
47
+ 'font-variant-east-asian': {
48
+ jis78: ['jp78'],
49
+ jis83: ['jp83'],
50
+ jis90: ['jp90'],
51
+ jis04: ['jp04'],
52
+ simplified: ['smpl'],
53
+ traditional: ['trad'],
54
+ 'proportional-width': ['pwid'],
55
+ 'full-width': ['fwid'],
56
+ ruby: ['ruby'],
57
+ },
58
+ };
59
+
60
+ // Extract OpenType feature tags referenced by a CSS declaration.
61
+ function extractFeatureTagsFromDecl(prop, value) {
62
+ const tags = new Set();
63
+ const propLower = prop.toLowerCase();
64
+
65
+ if (propLower === 'font-feature-settings') {
66
+ // Parse quoted 4-letter tags: "liga" 1, 'dlig', etc.
67
+ const re = /["']([a-zA-Z0-9]{4})["']/g;
68
+ let m;
69
+ while ((m = re.exec(value)) !== null) {
70
+ tags.add(m[1]);
71
+ }
72
+ return tags;
73
+ }
74
+
75
+ if (propLower === 'font-variant-alternates') {
76
+ const v = value.toLowerCase();
77
+ if (v.includes('historical-forms')) tags.add('hist');
78
+ if (/stylistic\s*\(/.test(v)) tags.add('salt');
79
+ if (/swash\s*\(/.test(v)) tags.add('swsh');
80
+ if (/ornaments\s*\(/.test(v)) tags.add('ornm');
81
+ if (/annotation\s*\(/.test(v)) tags.add('nalt');
82
+ if (/styleset\s*\(/.test(v)) {
83
+ for (let i = 1; i <= 20; i++) {
84
+ tags.add(`ss${String(i).padStart(2, '0')}`);
85
+ }
86
+ }
87
+ if (/character-variant\s*\(/.test(v)) {
88
+ for (let i = 1; i <= 99; i++) {
89
+ tags.add(`cv${String(i).padStart(2, '0')}`);
90
+ }
91
+ }
92
+ return tags;
93
+ }
94
+
95
+ const mapping = fontVariantToOTTags[propLower];
96
+ if (mapping) {
97
+ // Split into tokens for exact keyword matching — substring matching
98
+ // would falsely trigger e.g. "sub" inside "super".
99
+ const tokens = new Set(value.toLowerCase().split(/\s+/));
100
+ for (const [keyword, otTags] of Object.entries(mapping)) {
101
+ if (tokens.has(keyword)) {
102
+ for (const t of otTags) tags.add(t);
103
+ }
104
+ }
105
+ }
106
+ return tags;
107
+ }
108
+
109
+ // Collect feature tags from all feature-related declarations in a CSS rule.
110
+ function ruleFeatureTags(rule) {
111
+ const tags = new Set();
112
+ let hasFeatureDecl = false;
113
+ for (const node of rule.nodes) {
114
+ if (
115
+ node.type === 'decl' &&
116
+ featureSettingsProps.has(node.prop.toLowerCase())
117
+ ) {
118
+ hasFeatureDecl = true;
119
+ for (const t of extractFeatureTagsFromDecl(node.prop, node.value)) {
120
+ tags.add(t);
121
+ }
122
+ }
123
+ }
124
+ return hasFeatureDecl ? tags : null;
125
+ }
126
+
127
+ function ruleFontFamily(rule) {
128
+ for (let i = rule.nodes.length - 1; i >= 0; i--) {
129
+ const node = rule.nodes[i];
130
+ if (node.type === 'decl' && node.prop.toLowerCase() === 'font-family') {
131
+ return node.value;
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ // Add all items from `tags` into the Set stored at `key` in `map`,
138
+ // creating the Set if it doesn't exist yet.
139
+ function addTagsToMapEntry(map, key, tags) {
140
+ let s = map.get(key);
141
+ if (!s) {
142
+ s = new Set();
143
+ map.set(key, s);
144
+ }
145
+ for (const t of tags) s.add(t);
146
+ }
147
+
148
+ // Record the OT tags from a single CSS rule into featureTagsByFamily,
149
+ // keyed by font-family (or '*' when no font-family is specified).
150
+ function recordRuleFeatureTags(rule, featureTagsByFamily) {
151
+ const tags = ruleFeatureTags(rule);
152
+ if (!tags) return null;
153
+
154
+ const fontFamily = ruleFontFamily(rule);
155
+ if (!fontFamily) {
156
+ if (featureTagsByFamily) addTagsToMapEntry(featureTagsByFamily, '*', tags);
157
+ return true; // signals "all families"
158
+ }
159
+
160
+ const families = cssFontParser.parseFontFamily(fontFamily);
161
+ if (featureTagsByFamily) {
162
+ for (const family of families) {
163
+ addTagsToMapEntry(featureTagsByFamily, family.toLowerCase(), tags);
164
+ }
165
+ }
166
+ return families;
167
+ }
168
+
169
+ // Determine which font-families use font-feature-settings or font-variant-*.
170
+ // Returns null (none detected), a Set of lowercase family names, or true (all).
171
+ // Also populates featureTagsByFamily with the OT tags per family (lowercase).
172
+ function findFontFamiliesWithFeatureSettings(
173
+ stylesheetsWithPredicates,
174
+ featureTagsByFamily
175
+ ) {
176
+ let result = null;
177
+ for (const { asset } of stylesheetsWithPredicates) {
178
+ if (!asset || !asset.parseTree) continue;
179
+ asset.parseTree.walkRules((rule) => {
180
+ if (result === true && !featureTagsByFamily) return;
181
+
182
+ const recorded = recordRuleFeatureTags(rule, featureTagsByFamily);
183
+ if (!recorded) return;
184
+
185
+ if (recorded === true) {
186
+ result = true;
187
+ } else if (result !== true) {
188
+ if (!result) result = new Set();
189
+ for (const family of recorded) {
190
+ result.add(family.toLowerCase());
191
+ }
192
+ }
193
+ });
194
+ if (result === true && !featureTagsByFamily) break;
195
+ }
196
+ return result;
197
+ }
198
+
199
+ // Determine whether a template's font families use feature settings, and
200
+ // collect the corresponding OT feature tags from featureTagsByFamily.
201
+ function resolveFeatureSettings(
202
+ fontFamilies,
203
+ fontFamiliesWithFeatureSettings,
204
+ featureTagsByFamily
205
+ ) {
206
+ let hasFontFeatureSettings = false;
207
+ if (fontFamiliesWithFeatureSettings === true) {
208
+ hasFontFeatureSettings = true;
209
+ } else if (fontFamiliesWithFeatureSettings instanceof Set) {
210
+ for (const f of fontFamilies) {
211
+ if (fontFamiliesWithFeatureSettings.has(f.toLowerCase())) {
212
+ hasFontFeatureSettings = true;
213
+ break;
214
+ }
215
+ }
216
+ }
217
+
218
+ let fontFeatureTags;
219
+ if (hasFontFeatureSettings && featureTagsByFamily) {
220
+ const tags = new Set();
221
+ const globalTags = featureTagsByFamily.get('*');
222
+ if (globalTags) {
223
+ for (const t of globalTags) tags.add(t);
224
+ }
225
+ for (const f of fontFamilies) {
226
+ const familyTags = featureTagsByFamily.get(f.toLowerCase());
227
+ if (familyTags) {
228
+ for (const t of familyTags) tags.add(t);
229
+ }
230
+ }
231
+ if (tags.size > 0) {
232
+ fontFeatureTags = [...tags];
233
+ }
234
+ }
235
+
236
+ return { hasFontFeatureSettings, fontFeatureTags };
237
+ }
238
+
239
+ module.exports = {
240
+ featureSettingsProps,
241
+ fontVariantToOTTags,
242
+ extractFeatureTagsFromDecl,
243
+ ruleFeatureTags,
244
+ ruleFontFamily,
245
+ addTagsToMapEntry,
246
+ recordRuleFeatureTags,
247
+ findFontFamiliesWithFeatureSettings,
248
+ resolveFeatureSettings,
249
+ };
@@ -48,16 +48,6 @@ parentPort.on('message', (msg) => {
48
48
  const textByProps = fontTracer(document, {
49
49
  stylesheetsWithPredicates,
50
50
  getCssRulesByProperty: memoizedGetCssRulesByProperty,
51
- propsToReturn: [
52
- 'font-family',
53
- 'font-style',
54
- 'font-weight',
55
- 'font-variant',
56
- 'font-stretch',
57
- 'font-variation-settings',
58
- 'animation-timing-function',
59
- 'font-size',
60
- ],
61
51
  });
62
52
 
63
53
  // Strip any non-serializable data from results
@@ -0,0 +1,101 @@
1
+ // Helpers for surfacing phase progress to the console.
2
+ //
3
+ // Two audiences:
4
+ // 1. Non-debug users want brief, always-visible signal that the tool
5
+ // isn't hung on large runs (banner + periodic page counts + done).
6
+ // 2. Debug users want to pinpoint *which* phase is stuck when output
7
+ // goes silent. Paired "→ starting" / "← finished in Nms" markers
8
+ // around each slow phase mean the last line printed is always the
9
+ // currently-running phase.
10
+
11
+ // Pick a progress step that gives roughly 10 updates across the run,
12
+ // clamped to [1, 10]. For a 500-page run this prints every 50; for 30,
13
+ // every 3. Avoids long silent stretches on large runs and per-page spam
14
+ // on small ones.
15
+ function progressStep(total) {
16
+ return Math.max(1, Math.min(10, Math.ceil(total / 10)));
17
+ }
18
+
19
+ // Reporter for a phase that iterates `total` items.
20
+ // banner(msg): optional one-line header printed immediately.
21
+ // tick(): call once per completed item; returns the running count.
22
+ // done(): prints "<label>: <total>/<total> pages done." footer.
23
+ // All methods no-op when disabled (missing console or below minTotal).
24
+ function createPageProgress({ total, console, label, minTotal = 5 }) {
25
+ const enabled = Boolean(console) && total >= minTotal;
26
+ const step = progressStep(total);
27
+ let count = 0;
28
+ return {
29
+ enabled,
30
+ banner(msg) {
31
+ if (enabled) console.log(msg);
32
+ },
33
+ tick() {
34
+ count++;
35
+ if (enabled && count % step === 0 && count < total) {
36
+ console.log(` ${label}: ${count}/${total} pages...`);
37
+ }
38
+ return count;
39
+ },
40
+ done() {
41
+ if (enabled) {
42
+ console.log(` ${label}: ${total}/${total} pages done.`);
43
+ }
44
+ },
45
+ };
46
+ }
47
+
48
+ // Per-page debug line emitted right after a page finishes tracing.
49
+ // When a run hangs, the last-printed page identifies which page stalled.
50
+ function logTracedPage(console, debug, index, total, asset, startMs) {
51
+ if (!debug || !console) return;
52
+ console.log(
53
+ `[subfont timing] traced [${index}/${total}] ${asset.urlOrDescription} in ${Date.now() - startMs}ms`
54
+ );
55
+ }
56
+
57
+ // Start/end markers for a debug phase. The "→ label..." line is printed
58
+ // *before* the work begins, so if the work hangs the user sees exactly
59
+ // which phase is in flight. The returned end() logs elapsed ms and
60
+ // returns the duration so callers can still populate a timings map.
61
+ //
62
+ // const phase = logPhaseStart(console, debug, 'getSubsetsForFontUsage');
63
+ // await getSubsetsForFontUsage(...);
64
+ // timings.x = phase.end();
65
+ function logPhaseStart(console, debug, label) {
66
+ if (!debug || !console) {
67
+ const start = Date.now();
68
+ return {
69
+ end() {
70
+ return Date.now() - start;
71
+ },
72
+ };
73
+ }
74
+ const start = Date.now();
75
+ console.log(`[subfont timing] → ${label}...`);
76
+ return {
77
+ end(extraInfo) {
78
+ const ms = Date.now() - start;
79
+ const suffix = extraInfo ? ` (${extraInfo})` : '';
80
+ console.log(`[subfont timing] ← ${label}: ${ms}ms${suffix}`);
81
+ return ms;
82
+ },
83
+ };
84
+ }
85
+
86
+ // Bind a (console, debug) pair once at the top of a function, then use
87
+ // the returned tracker to open phases with a single short call:
88
+ //
89
+ // const trackPhase = makePhaseTracker(console, debug);
90
+ // const p = trackPhase('codepoint generation');
91
+ // ...work...
92
+ // timings.x = p.end();
93
+ function makePhaseTracker(console, debug) {
94
+ return (label) => logPhaseStart(console, debug, label);
95
+ }
96
+
97
+ module.exports = {
98
+ createPageProgress,
99
+ logTracedPage,
100
+ makePhaseTracker,
101
+ };
package/lib/subfont.js CHANGED
@@ -1,12 +1,14 @@
1
1
  const fsPromises = require('fs/promises');
2
2
  const os = require('os');
3
3
  const pathModule = require('path');
4
+ const sanitizeFilename = require('sanitize-filename');
4
5
  const { getMaxConcurrency } = require('./concurrencyLimit');
5
6
  const AssetGraph = require('assetgraph');
6
7
  const prettyBytes = require('pretty-bytes');
7
8
  const urlTools = require('urltools');
8
9
  const util = require('util');
9
10
  const subsetFonts = require('./subsetFonts');
11
+ const { makePhaseTracker } = require('./progress');
10
12
 
11
13
  class UsageError extends Error {
12
14
  constructor(message) {
@@ -191,21 +193,20 @@ module.exports = async function subfont(
191
193
  }
192
194
 
193
195
  const outerTimings = {};
196
+ // The tracker writes with console.log (duck-typed). Route it through
197
+ // the silent-aware log wrapper so --silent suppresses phase markers
198
+ // the same way it suppresses other subfont output.
199
+ const trackPhase = makePhaseTracker({ log }, debug);
194
200
 
195
- let phaseStart = Date.now();
201
+ const loadAssetsPhase = trackPhase('loadAssets');
196
202
  await assetGraph.loadAssets(inputUrls);
197
- outerTimings.loadAssets = Date.now() - phaseStart;
198
- if (debug) log(`[subfont timing] loadAssets: ${outerTimings.loadAssets}ms`);
203
+ outerTimings.loadAssets = loadAssetsPhase.end();
199
204
 
200
- phaseStart = Date.now();
205
+ const populatePhase = trackPhase('populate (initial)');
201
206
  await assetGraph.populate({
202
207
  followRelations: followRelationsQuery,
203
208
  });
204
- outerTimings['populate (initial)'] = Date.now() - phaseStart;
205
- if (debug)
206
- log(
207
- `[subfont timing] populate (initial): ${outerTimings['populate (initial)']}ms`
208
- );
209
+ outerTimings['populate (initial)'] = populatePhase.end();
209
210
 
210
211
  const entrypointAssets = assetGraph.findAssets({ isInitial: true });
211
212
  const redirectOrigins = new Set();
@@ -269,7 +270,7 @@ module.exports = async function subfont(
269
270
  );
270
271
  }
271
272
 
272
- phaseStart = Date.now();
273
+ const subsetPhase = trackPhase('subsetFonts total');
273
274
  const { fontInfo, timings: subsetTimings } = await subsetFonts(assetGraph, {
274
275
  inlineCss,
275
276
  fontDisplay,
@@ -285,11 +286,9 @@ module.exports = async function subfont(
285
286
  chromeArgs: chromeFlags,
286
287
  cacheDir,
287
288
  });
289
+ const subsetFontsTotal = subsetPhase.end();
288
290
 
289
- const subsetFontsTotal = Date.now() - phaseStart;
290
- if (debug) log(`[subfont timing] subsetFonts total: ${subsetFontsTotal}ms`);
291
-
292
- phaseStart = Date.now();
291
+ const postProcessingPhase = trackPhase('post-subsetFonts processing');
293
292
  let sumSizesAfter = 0;
294
293
  for (const asset of assetGraph.findAssets(sizeableAssetQuery)) {
295
294
  sumSizesAfter += asset.rawSrc.length;
@@ -313,11 +312,12 @@ module.exports = async function subfont(
313
312
  assetGraph.info(
314
313
  new Error(`Pulling down modified stylesheet ${asset.url}`)
315
314
  );
316
- asset.url = `${assetGraph.root}subfont/${
317
- asset.baseName || 'index'
318
- }-${asset.md5Hex.slice(0, 10)}${
319
- asset.extension || asset.defaultExtension
320
- }`;
315
+ const safeName =
316
+ sanitizeFilename(asset.baseName || '', { replacement: '_' }) || 'index';
317
+ asset.url = `${assetGraph.root}subfont/${safeName}-${asset.md5Hex.slice(
318
+ 0,
319
+ 10
320
+ )}${asset.extension || asset.defaultExtension}`;
321
321
  }
322
322
  }
323
323
 
@@ -343,11 +343,7 @@ module.exports = async function subfont(
343
343
  );
344
344
  }
345
345
 
346
- outerTimings['post-subsetFonts processing'] = Date.now() - phaseStart;
347
- if (debug)
348
- log(
349
- `[subfont timing] post-subsetFonts processing: ${outerTimings['post-subsetFonts processing']}ms`
350
- );
346
+ outerTimings['post-subsetFonts processing'] = postProcessingPhase.end();
351
347
 
352
348
  if (strict && sawWarning) {
353
349
  // In non-silent mode, assetgraph's logEvents normally exits earlier via
@@ -358,7 +354,7 @@ module.exports = async function subfont(
358
354
  );
359
355
  }
360
356
 
361
- phaseStart = Date.now();
357
+ const writePhase = trackPhase('writeAssetsToDisc');
362
358
  if (!dryRun) {
363
359
  await assetGraph.writeAssetsToDisc(
364
360
  {
@@ -370,14 +366,9 @@ module.exports = async function subfont(
370
366
  assetGraph.root
371
367
  );
372
368
  }
369
+ outerTimings.writeAssetsToDisc = writePhase.end();
373
370
 
374
- outerTimings.writeAssetsToDisc = Date.now() - phaseStart;
375
- if (debug)
376
- log(
377
- `[subfont timing] writeAssetsToDisc: ${outerTimings.writeAssetsToDisc}ms`
378
- );
379
-
380
- phaseStart = Date.now();
371
+ const reportingPhase = trackPhase('output reporting');
381
372
  if (debug) {
382
373
  const compactFontInfo = fontInfo.map(({ fontUsages, ...rest }) => ({
383
374
  ...rest,
@@ -494,11 +485,7 @@ module.exports = async function subfont(
494
485
  )}`
495
486
  );
496
487
  log(`Total savings: ${prettyBytes(totalSavings)}`);
497
- outerTimings['output reporting'] = Date.now() - phaseStart;
498
- if (debug)
499
- log(
500
- `[subfont timing] output reporting: ${outerTimings['output reporting']}ms`
501
- );
488
+ outerTimings['output reporting'] = reportingPhase.end();
502
489
 
503
490
  const st = subsetTimings || {};
504
491
  const details = st.collectTextsByPageDetails || {};
@@ -216,6 +216,14 @@ function extractSubsetFont(exports, subset) {
216
216
  // Fresh view AFTER the WASM calls above — memory.buffer may have been
217
217
  // detached by a grow during hb_face_reference_blob / hb_blob_get_data.
218
218
  const heapu8 = getHeapu8(exports);
219
+
220
+ if (offset + subsetByteLength > heapu8.byteLength) {
221
+ exports.hb_blob_destroy(result);
222
+ throw new Error(
223
+ `WASM returned out-of-bounds offset ${offset} + length ${subsetByteLength} (heap size ${heapu8.byteLength})`
224
+ );
225
+ }
226
+
219
227
  const subsetFont = Buffer.from(
220
228
  heapu8.subarray(offset, offset + subsetByteLength)
221
229
  );