@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.
@@ -5,7 +5,9 @@ const WORKER_MEMORY_BYTES = 50 * 1024 * 1024; // 50 MB
5
5
 
6
6
  function getMaxConcurrency() {
7
7
  const byMemory = Math.floor(os.freemem() / WORKER_MEMORY_BYTES);
8
- const byCpu = os.cpus().length * 4;
8
+ // Font tracing is CPU-bound (not I/O), so match the pool size to the
9
+ // core count directly — no multiplier.
10
+ const byCpu = os.cpus().length;
9
11
  return Math.max(1, Math.min(byMemory, byCpu));
10
12
  }
11
13
 
@@ -0,0 +1,13 @@
1
+ // Escape a value for safe inclusion in any JS string context (single-quoted,
2
+ // double-quoted, or template literal). Uses JSON.stringify for robust escaping
3
+ // of backslashes, quotes, newlines, U+2028, U+2029, etc.
4
+ // The < escape prevents </script> from closing an inline script tag.
5
+ function escapeJsStringLiteral(str) {
6
+ return JSON.stringify(str)
7
+ .slice(1, -1)
8
+ .replace(/'/g, "\\'")
9
+ .replace(/`/g, '\\x60')
10
+ .replace(/</g, '\\x3c');
11
+ }
12
+
13
+ module.exports = escapeJsStringLiteral;
@@ -81,8 +81,12 @@ const namedEntities = {
81
81
  const entityRe = /&(?:#x([0-9a-fA-F]+)|#(\d+)|([a-zA-Z]+));/g;
82
82
  function decodeEntities(str) {
83
83
  return str.replace(entityRe, (match, hex, dec, name) => {
84
- if (hex) return String.fromCodePoint(parseInt(hex, 16));
85
- if (dec) return String.fromCodePoint(parseInt(dec, 10));
84
+ try {
85
+ if (hex) return String.fromCodePoint(parseInt(hex, 16));
86
+ if (dec) return String.fromCodePoint(parseInt(dec, 10));
87
+ } catch {
88
+ return match;
89
+ }
86
90
  if (name && namedEntities[name.toLowerCase()] !== undefined) {
87
91
  return namedEntities[name.toLowerCase()];
88
92
  }
@@ -0,0 +1,25 @@
1
+ const pathModule = require('path');
2
+ const { Worker } = require('worker_threads');
3
+
4
+ const workerPath = pathModule.join(__dirname, 'fontConverterWorker.js');
5
+
6
+ function convert(buffer, targetFormat, sourceFormat) {
7
+ return new Promise((resolve, reject) => {
8
+ const worker = new Worker(workerPath);
9
+ worker.on('message', (msg) => {
10
+ worker.terminate();
11
+ if (msg.type === 'result') {
12
+ resolve(Buffer.from(msg.buffer));
13
+ } else {
14
+ reject(new Error(msg.error));
15
+ }
16
+ });
17
+ worker.on('error', (err) => {
18
+ worker.terminate();
19
+ reject(err);
20
+ });
21
+ worker.postMessage({ buffer, targetFormat, sourceFormat });
22
+ });
23
+ }
24
+
25
+ module.exports = { convert };
@@ -0,0 +1,16 @@
1
+ const { parentPort } = require('worker_threads');
2
+ const fontverter = require('fontverter');
3
+
4
+ parentPort.on('message', async (msg) => {
5
+ try {
6
+ const buffer = Buffer.from(msg.buffer);
7
+ const result = await fontverter.convert(
8
+ buffer,
9
+ msg.targetFormat,
10
+ msg.sourceFormat
11
+ );
12
+ parentPort.postMessage({ type: 'result', buffer: result });
13
+ } catch (err) {
14
+ parentPort.postMessage({ type: 'error', error: err.message });
15
+ }
16
+ });
@@ -75,15 +75,27 @@ function getFontFaceDeclarationText(node, relations) {
75
75
 
76
76
  const fontOrder = ['woff2', 'woff', 'truetype'];
77
77
 
78
- function getFontFaceForFontUsage(fontUsage) {
79
- const subsets = fontOrder
80
- .filter((format) => fontUsage.subsets[format])
78
+ // Cache base64-encoded data URIs keyed by the underlying Buffer. Subset
79
+ // buffers are shared across pages (propagated from the canonical fontUsage),
80
+ // so without this every page re-encodes the same multi-hundred-KB buffer.
81
+ const subsetDataUrlCache = new WeakMap();
82
+ function getSubsetDataUrls(subsetsObj) {
83
+ let cached = subsetDataUrlCache.get(subsetsObj);
84
+ if (cached) return cached;
85
+ cached = fontOrder
86
+ .filter((format) => subsetsObj[format])
81
87
  .map((format) => ({
82
88
  format,
83
- url: `data:${contentTypeByFontFormat[format]};base64,${fontUsage.subsets[
89
+ url: `data:${contentTypeByFontFormat[format]};base64,${subsetsObj[
84
90
  format
85
91
  ].toString('base64')}`,
86
92
  }));
93
+ subsetDataUrlCache.set(subsetsObj, cached);
94
+ return cached;
95
+ }
96
+
97
+ function getFontFaceForFontUsage(fontUsage) {
98
+ const subsets = getSubsetDataUrls(fontUsage.subsets);
87
99
 
88
100
  const resultString = ['@font-face {'];
89
101
 
@@ -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
@@ -3,18 +3,17 @@ module.exports = function gatherStylesheetsWithPredicates(
3
3
  htmlAsset,
4
4
  relationIndex
5
5
  ) {
6
- const assetStack = [];
6
+ const visiting = new Set();
7
7
  const incomingMedia = [];
8
8
  const conditionalCommentConditionStack = [];
9
9
  const result = [];
10
10
  (function traverse(asset, isWithinNotIeConditionalComment, isWithinNoscript) {
11
- if (assetStack.includes(asset)) {
12
- // Cycle detected
11
+ if (visiting.has(asset)) {
13
12
  return;
14
13
  } else if (!asset.isLoaded) {
15
14
  return;
16
15
  }
17
- assetStack.push(asset);
16
+ visiting.add(asset);
18
17
  // Use pre-built index if available, otherwise fall back to findRelations
19
18
  const relations = relationIndex
20
19
  ? relationIndex.get(asset) || []
@@ -60,7 +59,7 @@ module.exports = function gatherStylesheetsWithPredicates(
60
59
  }
61
60
  }
62
61
  }
63
- assetStack.pop();
62
+ visiting.delete(asset);
64
63
  if (asset.type === 'Css') {
65
64
  const predicates = {};
66
65
  for (const incomingMedium of incomingMedia) {
@@ -34,7 +34,7 @@ function isValidWeight(weight) {
34
34
  function normalizeFontPropertyValue(propName, value) {
35
35
  const propNameLowerCase = propName.toLowerCase();
36
36
  if (value === undefined) {
37
- return initialValueByProp[propName];
37
+ return initialValueByProp[propNameLowerCase];
38
38
  }
39
39
  if (propNameLowerCase === 'font-family') {
40
40
  return unquote(value);
@@ -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/sfntCache.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fontverter = require('fontverter');
2
+ const { convert } = require('./fontConverter');
2
3
 
3
4
  const sfntPromiseByBuffer = new WeakMap();
4
5
 
@@ -9,13 +10,15 @@ function toSfnt(buffer) {
9
10
  let promise;
10
11
  try {
11
12
  const format = fontverter.detectFormat(buffer);
12
- promise =
13
- format === 'sfnt'
14
- ? Promise.resolve(buffer)
15
- : fontverter.convert(buffer, 'sfnt');
16
- } catch (err) {
17
- // Unrecognized format — don't cache so retries work
18
- return fontverter.convert(buffer, 'sfnt');
13
+ if (format === 'sfnt') {
14
+ promise = Promise.resolve(buffer);
15
+ } else if (format === 'woff2') {
16
+ promise = convert(buffer, 'sfnt');
17
+ } else {
18
+ promise = fontverter.convert(buffer, 'sfnt');
19
+ }
20
+ } catch {
21
+ promise = convert(buffer, 'sfnt');
19
22
  }
20
23
  // Evict on rejection so retries with the same buffer aren't stuck
21
24
  promise.catch(() => sfntPromiseByBuffer.delete(buffer));