@turntrout/subfont 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/README.md +19 -0
- package/lib/collectFeatureGlyphIds.js +35 -4
- package/lib/collectTextsByPage.js +219 -29
- package/lib/subsetFontWithGlyphs.js +41 -1
- package/lib/subsetFonts.js +5 -2
- package/lib/subsetGeneration.js +15 -13
- package/package.json +1 -2
package/CLAUDE.md
CHANGED
|
@@ -34,7 +34,7 @@ pnpm run check-coverage # Verify coverage thresholds
|
|
|
34
34
|
- Built on **assetgraph** for HTML/CSS asset graph traversal
|
|
35
35
|
- Uses **puppeteer-core** for headless browser font tracing
|
|
36
36
|
- **font-tracer** traces which fonts are used on each page
|
|
37
|
-
- **
|
|
37
|
+
- **harfbuzzjs** for WOFF2 subsetting (via direct WASM calls in `lib/subsetFontWithGlyphs.js`)
|
|
38
38
|
- `lib/subsetFonts.js` — Main orchestration logic
|
|
39
39
|
- `lib/FontTracerPool.js` — Manages puppeteer browser pool for parallel tracing
|
|
40
40
|
|
package/README.md
CHANGED
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
A faster fork of [subfont](https://github.com/Munter/subfont) that subsets web fonts to only the characters used on your pages. Adds parallel tracing, disk caching, woff2-only output, and always-on variable font instancing. On [`turntrout.com`](https://github.com/alexander-turner/TurnTrout.com) (382 pages, 20+ font variants), switching to this fork cut font subsetting from [111 minutes](https://github.com/alexander-turner/TurnTrout.com/actions/runs/23470135763) to [28 minutes](https://github.com/alexander-turner/TurnTrout.com/actions/runs/23518006824).
|
|
6
6
|
|
|
7
|
+
### Aggressive woff2 subsetting
|
|
8
|
+
|
|
9
|
+
subfont produces dramatically smaller font files by stripping data that browsers never use:
|
|
10
|
+
|
|
11
|
+
| Optimization | Technique |
|
|
12
|
+
| --------------------------- | ---------------------------------------------------------------------------------- |
|
|
13
|
+
| Hinting removal | Strips TrueType hinting instructions (browsers auto-hint) |
|
|
14
|
+
| Name table pruning | Keeps only the 4 IDs browsers read (family, subfamily, full name, PostScript name) |
|
|
15
|
+
| Table stripping | Drops DSIG, LTSH, VDMX, hdmx, gasp, PCLT |
|
|
16
|
+
| CSS-aware feature filtering | Only collects alternate glyphs for OpenType features actually used in your CSS |
|
|
17
|
+
|
|
18
|
+
On the [`turntrout.com/design`](https://turntrout.com/design) page, a typical font subset (OpenSans, woff2) is **48-68% smaller** than a naive subset of the same glyphs:
|
|
19
|
+
|
|
20
|
+
| Text sample | Naive subset | subfont | Savings |
|
|
21
|
+
| ----------------- | ------------ | ------- | ------- |
|
|
22
|
+
| Heading (short) | 2,604 B | 824 B | **68%** |
|
|
23
|
+
| Paragraph | 4,052 B | 1,840 B | **55%** |
|
|
24
|
+
| Full page charset | 5,268 B | 2,716 B | **48%** |
|
|
25
|
+
|
|
7
26
|
## Install
|
|
8
27
|
|
|
9
28
|
```
|
|
@@ -1,22 +1,40 @@
|
|
|
1
1
|
const { toSfnt } = require('./sfntCache');
|
|
2
2
|
|
|
3
|
+
// GSUB feature tags that can produce alternate glyphs. Used as the
|
|
4
|
+
// fallback set when CSS doesn't specify which features are in use.
|
|
5
|
+
// Keep in sync with fontVariantToOTTags in collectTextsByPage.js.
|
|
3
6
|
const GSUB_FEATURE_TAGS = new Set([
|
|
4
7
|
'aalt',
|
|
8
|
+
'afrc',
|
|
9
|
+
'c2pc',
|
|
5
10
|
'c2sc',
|
|
6
11
|
'calt',
|
|
7
12
|
'clig',
|
|
8
13
|
'dlig',
|
|
9
14
|
'dnom',
|
|
10
15
|
'frac',
|
|
16
|
+
'fwid',
|
|
17
|
+
'hist',
|
|
18
|
+
'hlig',
|
|
19
|
+
'jp04',
|
|
20
|
+
'jp78',
|
|
21
|
+
'jp83',
|
|
22
|
+
'jp90',
|
|
11
23
|
'liga',
|
|
12
24
|
'lnum',
|
|
25
|
+
'nalt',
|
|
13
26
|
'numr',
|
|
14
27
|
'onum',
|
|
15
28
|
'ordn',
|
|
29
|
+
'ornm',
|
|
30
|
+
'pcap',
|
|
16
31
|
'pnum',
|
|
32
|
+
'pwid',
|
|
33
|
+
'ruby',
|
|
17
34
|
'salt',
|
|
18
35
|
'sinf',
|
|
19
36
|
'smcp',
|
|
37
|
+
'smpl',
|
|
20
38
|
'ss01',
|
|
21
39
|
'ss02',
|
|
22
40
|
'ss03',
|
|
@@ -40,13 +58,16 @@ const GSUB_FEATURE_TAGS = new Set([
|
|
|
40
58
|
'subs',
|
|
41
59
|
'sups',
|
|
42
60
|
'swsh',
|
|
61
|
+
'titl',
|
|
43
62
|
'tnum',
|
|
63
|
+
'trad',
|
|
64
|
+
'unic',
|
|
44
65
|
'zero',
|
|
45
66
|
]);
|
|
46
67
|
|
|
47
68
|
const enqueueWasm = require('./wasmQueue');
|
|
48
69
|
|
|
49
|
-
async function collectFeatureGlyphIdsImpl(fontBuffer, text) {
|
|
70
|
+
async function collectFeatureGlyphIdsImpl(fontBuffer, text, cssFeatureTags) {
|
|
50
71
|
const harfbuzzJs = await require('harfbuzzjs');
|
|
51
72
|
const sfnt = await toSfnt(fontBuffer);
|
|
52
73
|
|
|
@@ -56,8 +77,16 @@ async function collectFeatureGlyphIdsImpl(fontBuffer, text) {
|
|
|
56
77
|
|
|
57
78
|
try {
|
|
58
79
|
const fontFeatures = new Set(face.getTableFeatureTags('GSUB'));
|
|
80
|
+
|
|
81
|
+
// When CSS specifies which features are used, only test those.
|
|
82
|
+
// Otherwise fall back to the full set of supported GSUB features.
|
|
83
|
+
const allowedTags =
|
|
84
|
+
cssFeatureTags && cssFeatureTags.length > 0
|
|
85
|
+
? new Set(cssFeatureTags)
|
|
86
|
+
: GSUB_FEATURE_TAGS;
|
|
87
|
+
|
|
59
88
|
const featuresToTest = [...fontFeatures].filter((tag) =>
|
|
60
|
-
|
|
89
|
+
allowedTags.has(tag)
|
|
61
90
|
);
|
|
62
91
|
|
|
63
92
|
if (featuresToTest.length === 0) return [];
|
|
@@ -108,8 +137,10 @@ async function collectFeatureGlyphIdsImpl(fontBuffer, text) {
|
|
|
108
137
|
}
|
|
109
138
|
}
|
|
110
139
|
|
|
111
|
-
function collectFeatureGlyphIds(fontBuffer, text) {
|
|
112
|
-
return enqueueWasm(() =>
|
|
140
|
+
function collectFeatureGlyphIds(fontBuffer, text, cssFeatureTags) {
|
|
141
|
+
return enqueueWasm(() =>
|
|
142
|
+
collectFeatureGlyphIdsImpl(fontBuffer, text, cssFeatureTags)
|
|
143
|
+
);
|
|
113
144
|
}
|
|
114
145
|
|
|
115
146
|
module.exports = collectFeatureGlyphIds;
|
|
@@ -45,11 +45,118 @@ const featureSettingsProps = new Set([
|
|
|
45
45
|
'font-variant-position',
|
|
46
46
|
]);
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
// Map font-variant-* CSS values to their corresponding OpenType feature tags.
|
|
49
|
+
const fontVariantToOTTags = {
|
|
50
|
+
'font-variant-ligatures': {
|
|
51
|
+
'common-ligatures': ['liga', 'clig'],
|
|
52
|
+
'no-common-ligatures': ['liga', 'clig'],
|
|
53
|
+
'discretionary-ligatures': ['dlig'],
|
|
54
|
+
'no-discretionary-ligatures': ['dlig'],
|
|
55
|
+
'historical-ligatures': ['hlig'],
|
|
56
|
+
'no-historical-ligatures': ['hlig'],
|
|
57
|
+
contextual: ['calt'],
|
|
58
|
+
'no-contextual': ['calt'],
|
|
59
|
+
},
|
|
60
|
+
'font-variant-caps': {
|
|
61
|
+
'small-caps': ['smcp'],
|
|
62
|
+
'all-small-caps': ['smcp', 'c2sc'],
|
|
63
|
+
'petite-caps': ['pcap'],
|
|
64
|
+
'all-petite-caps': ['pcap', 'c2pc'],
|
|
65
|
+
unicase: ['unic'],
|
|
66
|
+
'titling-caps': ['titl'],
|
|
67
|
+
},
|
|
68
|
+
'font-variant-numeric': {
|
|
69
|
+
'lining-nums': ['lnum'],
|
|
70
|
+
'oldstyle-nums': ['onum'],
|
|
71
|
+
'proportional-nums': ['pnum'],
|
|
72
|
+
'tabular-nums': ['tnum'],
|
|
73
|
+
'diagonal-fractions': ['frac'],
|
|
74
|
+
'stacked-fractions': ['afrc'],
|
|
75
|
+
ordinal: ['ordn'],
|
|
76
|
+
'slashed-zero': ['zero'],
|
|
77
|
+
},
|
|
78
|
+
'font-variant-position': {
|
|
79
|
+
sub: ['subs'],
|
|
80
|
+
super: ['sups'],
|
|
81
|
+
},
|
|
82
|
+
'font-variant-east-asian': {
|
|
83
|
+
jis78: ['jp78'],
|
|
84
|
+
jis83: ['jp83'],
|
|
85
|
+
jis90: ['jp90'],
|
|
86
|
+
jis04: ['jp04'],
|
|
87
|
+
simplified: ['smpl'],
|
|
88
|
+
traditional: ['trad'],
|
|
89
|
+
'proportional-width': ['pwid'],
|
|
90
|
+
'full-width': ['fwid'],
|
|
91
|
+
ruby: ['ruby'],
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Extract OpenType feature tags referenced by a CSS declaration.
|
|
96
|
+
function extractFeatureTagsFromDecl(prop, value) {
|
|
97
|
+
const tags = new Set();
|
|
98
|
+
const propLower = prop.toLowerCase();
|
|
99
|
+
|
|
100
|
+
if (propLower === 'font-feature-settings') {
|
|
101
|
+
// Parse quoted 4-letter tags: "liga" 1, 'dlig', etc.
|
|
102
|
+
const re = /["']([a-zA-Z0-9]{4})["']/g;
|
|
103
|
+
let m;
|
|
104
|
+
while ((m = re.exec(value)) !== null) {
|
|
105
|
+
tags.add(m[1]);
|
|
106
|
+
}
|
|
107
|
+
return tags;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (propLower === 'font-variant-alternates') {
|
|
111
|
+
const v = value.toLowerCase();
|
|
112
|
+
if (v.includes('historical-forms')) tags.add('hist');
|
|
113
|
+
if (/stylistic\s*\(/.test(v)) tags.add('salt');
|
|
114
|
+
if (/swash\s*\(/.test(v)) tags.add('swsh');
|
|
115
|
+
if (/ornaments\s*\(/.test(v)) tags.add('ornm');
|
|
116
|
+
if (/annotation\s*\(/.test(v)) tags.add('nalt');
|
|
117
|
+
if (/styleset\s*\(/.test(v)) {
|
|
118
|
+
for (let i = 1; i <= 20; i++) {
|
|
119
|
+
tags.add(`ss${String(i).padStart(2, '0')}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (/character-variant\s*\(/.test(v)) {
|
|
123
|
+
for (let i = 1; i <= 99; i++) {
|
|
124
|
+
tags.add(`cv${String(i).padStart(2, '0')}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return tags;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const mapping = fontVariantToOTTags[propLower];
|
|
131
|
+
if (mapping) {
|
|
132
|
+
// Split into tokens for exact keyword matching — substring matching
|
|
133
|
+
// would falsely trigger e.g. "sub" inside "super".
|
|
134
|
+
const tokens = new Set(value.toLowerCase().split(/\s+/));
|
|
135
|
+
for (const [keyword, otTags] of Object.entries(mapping)) {
|
|
136
|
+
if (tokens.has(keyword)) {
|
|
137
|
+
for (const t of otTags) tags.add(t);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return tags;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Collect feature tags from all feature-related declarations in a CSS rule.
|
|
145
|
+
function ruleFeatureTags(rule) {
|
|
146
|
+
const tags = new Set();
|
|
147
|
+
let hasFeatureDecl = false;
|
|
148
|
+
for (const node of rule.nodes) {
|
|
149
|
+
if (
|
|
150
|
+
node.type === 'decl' &&
|
|
151
|
+
featureSettingsProps.has(node.prop.toLowerCase())
|
|
152
|
+
) {
|
|
153
|
+
hasFeatureDecl = true;
|
|
154
|
+
for (const t of extractFeatureTagsFromDecl(node.prop, node.value)) {
|
|
155
|
+
tags.add(t);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return hasFeatureDecl ? tags : null;
|
|
53
160
|
}
|
|
54
161
|
|
|
55
162
|
function ruleFontFamily(rule) {
|
|
@@ -62,32 +169,108 @@ function ruleFontFamily(rule) {
|
|
|
62
169
|
return null;
|
|
63
170
|
}
|
|
64
171
|
|
|
172
|
+
// Add all items from `tags` into the Set stored at `key` in `map`,
|
|
173
|
+
// creating the Set if it doesn't exist yet.
|
|
174
|
+
function addTagsToMapEntry(map, key, tags) {
|
|
175
|
+
let s = map.get(key);
|
|
176
|
+
if (!s) {
|
|
177
|
+
s = new Set();
|
|
178
|
+
map.set(key, s);
|
|
179
|
+
}
|
|
180
|
+
for (const t of tags) s.add(t);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Record the OT tags from a single CSS rule into featureTagsByFamily,
|
|
184
|
+
// keyed by font-family (or '*' when no font-family is specified).
|
|
185
|
+
function recordRuleFeatureTags(rule, featureTagsByFamily) {
|
|
186
|
+
const tags = ruleFeatureTags(rule);
|
|
187
|
+
if (!tags) return null;
|
|
188
|
+
|
|
189
|
+
const fontFamily = ruleFontFamily(rule);
|
|
190
|
+
if (!fontFamily) {
|
|
191
|
+
if (featureTagsByFamily) addTagsToMapEntry(featureTagsByFamily, '*', tags);
|
|
192
|
+
return true; // signals "all families"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const families = cssFontParser.parseFontFamily(fontFamily);
|
|
196
|
+
if (featureTagsByFamily) {
|
|
197
|
+
for (const family of families) {
|
|
198
|
+
addTagsToMapEntry(featureTagsByFamily, family.toLowerCase(), tags);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return families;
|
|
202
|
+
}
|
|
203
|
+
|
|
65
204
|
// Determine which font-families use font-feature-settings or font-variant-*.
|
|
66
205
|
// Returns null (none detected), a Set of lowercase family names, or true (all).
|
|
67
|
-
|
|
206
|
+
// Also populates featureTagsByFamily with the OT tags per family (lowercase).
|
|
207
|
+
function findFontFamiliesWithFeatureSettings(
|
|
208
|
+
stylesheetsWithPredicates,
|
|
209
|
+
featureTagsByFamily
|
|
210
|
+
) {
|
|
68
211
|
let result = null;
|
|
69
212
|
for (const { asset } of stylesheetsWithPredicates) {
|
|
70
213
|
if (!asset || !asset.parseTree) continue;
|
|
71
214
|
asset.parseTree.walkRules((rule) => {
|
|
72
|
-
if (result === true) return;
|
|
73
|
-
if (!ruleUsesFeatureSettings(rule)) return;
|
|
215
|
+
if (result === true && !featureTagsByFamily) return;
|
|
74
216
|
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
77
|
-
|
|
217
|
+
const recorded = recordRuleFeatureTags(rule, featureTagsByFamily);
|
|
218
|
+
if (!recorded) return;
|
|
219
|
+
|
|
220
|
+
if (recorded === true) {
|
|
78
221
|
result = true;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
222
|
+
} else {
|
|
223
|
+
if (!result) result = new Set();
|
|
224
|
+
for (const family of recorded) {
|
|
225
|
+
result.add(family.toLowerCase());
|
|
226
|
+
}
|
|
84
227
|
}
|
|
85
228
|
});
|
|
86
|
-
if (result === true) break;
|
|
229
|
+
if (result === true && !featureTagsByFamily) break;
|
|
87
230
|
}
|
|
88
231
|
return result;
|
|
89
232
|
}
|
|
90
233
|
|
|
234
|
+
// Determine whether a template's font families use feature settings, and
|
|
235
|
+
// collect the corresponding OT feature tags from featureTagsByFamily.
|
|
236
|
+
function resolveFeatureSettings(
|
|
237
|
+
fontFamilies,
|
|
238
|
+
fontFamiliesWithFeatureSettings,
|
|
239
|
+
featureTagsByFamily
|
|
240
|
+
) {
|
|
241
|
+
let hasFontFeatureSettings = false;
|
|
242
|
+
if (fontFamiliesWithFeatureSettings === true) {
|
|
243
|
+
hasFontFeatureSettings = true;
|
|
244
|
+
} else if (fontFamiliesWithFeatureSettings instanceof Set) {
|
|
245
|
+
for (const f of fontFamilies) {
|
|
246
|
+
if (fontFamiliesWithFeatureSettings.has(f.toLowerCase())) {
|
|
247
|
+
hasFontFeatureSettings = true;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let fontFeatureTags;
|
|
254
|
+
if (hasFontFeatureSettings && featureTagsByFamily) {
|
|
255
|
+
const tags = new Set();
|
|
256
|
+
const globalTags = featureTagsByFamily.get('*');
|
|
257
|
+
if (globalTags) {
|
|
258
|
+
for (const t of globalTags) tags.add(t);
|
|
259
|
+
}
|
|
260
|
+
for (const f of fontFamilies) {
|
|
261
|
+
const familyTags = featureTagsByFamily.get(f.toLowerCase());
|
|
262
|
+
if (familyTags) {
|
|
263
|
+
for (const t of familyTags) tags.add(t);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (tags.size > 0) {
|
|
267
|
+
fontFeatureTags = [...tags];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { hasFontFeatureSettings, fontFeatureTags };
|
|
272
|
+
}
|
|
273
|
+
|
|
91
274
|
const allInitialValues = require('./initialValueByProp');
|
|
92
275
|
const initialValueByProp = {
|
|
93
276
|
'font-style': allInitialValues['font-style'],
|
|
@@ -708,14 +891,17 @@ async function collectTextsByPage(
|
|
|
708
891
|
}
|
|
709
892
|
}
|
|
710
893
|
|
|
894
|
+
const featureTagsByFamily = new Map();
|
|
711
895
|
const fontFamiliesWithFeatureSettings = findFontFamiliesWithFeatureSettings(
|
|
712
|
-
stylesheetsWithPredicates
|
|
896
|
+
stylesheetsWithPredicates,
|
|
897
|
+
featureTagsByFamily
|
|
713
898
|
);
|
|
714
899
|
|
|
715
900
|
const result = {
|
|
716
901
|
accumulatedFontFaceDeclarations,
|
|
717
902
|
stylesheetsWithPredicates,
|
|
718
903
|
fontFamiliesWithFeatureSettings,
|
|
904
|
+
featureTagsByFamily,
|
|
719
905
|
fastPathKey: buildStylesheetKey(htmlOrSvgAsset, true),
|
|
720
906
|
};
|
|
721
907
|
stylesheetResultCache.set(key, result);
|
|
@@ -739,6 +925,7 @@ async function collectTextsByPage(
|
|
|
739
925
|
accumulatedFontFaceDeclarations,
|
|
740
926
|
stylesheetsWithPredicates,
|
|
741
927
|
fontFamiliesWithFeatureSettings,
|
|
928
|
+
featureTagsByFamily,
|
|
742
929
|
fastPathKey,
|
|
743
930
|
} = getOrComputeStylesheetResults(htmlOrSvgAsset);
|
|
744
931
|
fontFaceDeclarationsByHtmlOrSvgAsset.set(
|
|
@@ -755,6 +942,7 @@ async function collectTextsByPage(
|
|
|
755
942
|
accumulatedFontFaceDeclarations,
|
|
756
943
|
stylesheetsWithPredicates,
|
|
757
944
|
fontFamiliesWithFeatureSettings,
|
|
945
|
+
featureTagsByFamily,
|
|
758
946
|
stylesheetCacheKey: fastPathKey,
|
|
759
947
|
});
|
|
760
948
|
}
|
|
@@ -833,6 +1021,7 @@ async function collectTextsByPage(
|
|
|
833
1021
|
textByProps: pd.textByProps,
|
|
834
1022
|
accumulatedFontFaceDeclarations: pd.accumulatedFontFaceDeclarations,
|
|
835
1023
|
fontFamiliesWithFeatureSettings: pd.fontFamiliesWithFeatureSettings,
|
|
1024
|
+
featureTagsByFamily: pd.featureTagsByFamily,
|
|
836
1025
|
});
|
|
837
1026
|
}
|
|
838
1027
|
|
|
@@ -872,6 +1061,7 @@ async function collectTextsByPage(
|
|
|
872
1061
|
textByProps,
|
|
873
1062
|
accumulatedFontFaceDeclarations,
|
|
874
1063
|
fontFamiliesWithFeatureSettings,
|
|
1064
|
+
featureTagsByFamily,
|
|
875
1065
|
} = htmlOrSvgAssetTextsWithPropsEntry;
|
|
876
1066
|
|
|
877
1067
|
// Get or compute the snapped global entries for this declarations set
|
|
@@ -932,17 +1122,12 @@ async function collectTextsByPage(
|
|
|
932
1122
|
uniqueCharsCache.set(pageTextStr, pageTextUnique);
|
|
933
1123
|
}
|
|
934
1124
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
hasFontFeatureSettings = true;
|
|
942
|
-
break;
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
}
|
|
1125
|
+
const { hasFontFeatureSettings, fontFeatureTags } =
|
|
1126
|
+
resolveFeatureSettings(
|
|
1127
|
+
template.fontFamilies,
|
|
1128
|
+
fontFamiliesWithFeatureSettings,
|
|
1129
|
+
featureTagsByFamily
|
|
1130
|
+
);
|
|
946
1131
|
|
|
947
1132
|
return {
|
|
948
1133
|
smallestOriginalSize: template.smallestOriginalSize,
|
|
@@ -959,6 +1144,7 @@ async function collectTextsByPage(
|
|
|
959
1144
|
fontVariationSettings: template.fontVariationSettings,
|
|
960
1145
|
preload: preloadFontUrls.has(template.fontUrl),
|
|
961
1146
|
hasFontFeatureSettings,
|
|
1147
|
+
fontFeatureTags,
|
|
962
1148
|
};
|
|
963
1149
|
}
|
|
964
1150
|
);
|
|
@@ -1003,3 +1189,7 @@ async function collectTextsByPage(
|
|
|
1003
1189
|
}
|
|
1004
1190
|
|
|
1005
1191
|
module.exports = collectTextsByPage;
|
|
1192
|
+
|
|
1193
|
+
// Exported for testing only
|
|
1194
|
+
module.exports._extractFeatureTagsFromDecl = extractFeatureTagsFromDecl;
|
|
1195
|
+
module.exports._resolveFeatureSettings = resolveFeatureSettings;
|
|
@@ -4,9 +4,16 @@ const { toSfnt } = require('./sfntCache');
|
|
|
4
4
|
|
|
5
5
|
// hb_subset_sets_t enum values — https://github.com/harfbuzz/harfbuzz/blob/main/src/hb-subset.h
|
|
6
6
|
const HB_SUBSET_SETS_GLYPH_INDEX = 0;
|
|
7
|
+
const HB_SUBSET_SETS_DROP_TABLE_TAG = 5;
|
|
7
8
|
const HB_SUBSET_SETS_LAYOUT_FEATURE_TAG = 6;
|
|
9
|
+
const HB_SUBSET_SETS_NAME_ID = 4;
|
|
8
10
|
|
|
9
|
-
//
|
|
11
|
+
// hb_subset_flags_t
|
|
12
|
+
const HB_SUBSET_FLAGS_NO_HINTING = 0x00000001;
|
|
13
|
+
|
|
14
|
+
// All font subsetting goes through harfbuzz directly so we can apply
|
|
15
|
+
// web-specific optimizations (no hinting, minimal name table, table
|
|
16
|
+
// stripping) and support explicit glyph-ID inclusion.
|
|
10
17
|
let _wasmExports;
|
|
11
18
|
let _loadPromise;
|
|
12
19
|
async function loadHarfbuzz() {
|
|
@@ -62,6 +69,15 @@ function setAxisRange(exports, input, face, axisName, value) {
|
|
|
62
69
|
}
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
// Tables unnecessary for web rendering — safe to drop unconditionally.
|
|
73
|
+
// gasp is only meaningful when hinting is present (which we strip above).
|
|
74
|
+
const DROP_TABLE_TAGS = ['DSIG', 'LTSH', 'VDMX', 'hdmx', 'gasp', 'PCLT'];
|
|
75
|
+
|
|
76
|
+
// Name IDs needed for web fonts: family (1), subfamily (2), full name (4),
|
|
77
|
+
// PostScript name (6). Copyright (0), unique ID (3), version (5), and
|
|
78
|
+
// everything above 6 are display/license metadata that browsers never read.
|
|
79
|
+
const KEEP_NAME_IDS = [1, 2, 4, 6];
|
|
80
|
+
|
|
65
81
|
function configureSubsetInput(
|
|
66
82
|
exports,
|
|
67
83
|
input,
|
|
@@ -70,6 +86,7 @@ function configureSubsetInput(
|
|
|
70
86
|
glyphIds,
|
|
71
87
|
variationAxes
|
|
72
88
|
) {
|
|
89
|
+
// --- Retain all layout features ---
|
|
73
90
|
const layoutFeatures = exports.hb_subset_input_set(
|
|
74
91
|
input,
|
|
75
92
|
HB_SUBSET_SETS_LAYOUT_FEATURE_TAG
|
|
@@ -77,11 +94,33 @@ function configureSubsetInput(
|
|
|
77
94
|
exports.hb_set_clear(layoutFeatures);
|
|
78
95
|
exports.hb_set_invert(layoutFeatures);
|
|
79
96
|
|
|
97
|
+
// --- Strip hinting instructions (ignored by modern browsers) ---
|
|
98
|
+
const flags = exports.hb_subset_input_get_flags(input);
|
|
99
|
+
exports.hb_subset_input_set_flags(input, flags | HB_SUBSET_FLAGS_NO_HINTING);
|
|
100
|
+
|
|
101
|
+
// --- Keep only essential name table entries ---
|
|
102
|
+
const nameIdSet = exports.hb_subset_input_set(input, HB_SUBSET_SETS_NAME_ID);
|
|
103
|
+
exports.hb_set_clear(nameIdSet);
|
|
104
|
+
for (const id of KEEP_NAME_IDS) {
|
|
105
|
+
exports.hb_set_add(nameIdSet, id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Drop tables not needed for web rendering ---
|
|
109
|
+
const dropTableSet = exports.hb_subset_input_set(
|
|
110
|
+
input,
|
|
111
|
+
HB_SUBSET_SETS_DROP_TABLE_TAG
|
|
112
|
+
);
|
|
113
|
+
for (const tag of DROP_TABLE_TAGS) {
|
|
114
|
+
exports.hb_set_add(dropTableSet, HB_TAG(tag));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Add unicode codepoints ---
|
|
80
118
|
const inputUnicodes = exports.hb_subset_input_unicode_set(input);
|
|
81
119
|
for (const c of text) {
|
|
82
120
|
exports.hb_set_add(inputUnicodes, c.codePointAt(0));
|
|
83
121
|
}
|
|
84
122
|
|
|
123
|
+
// --- Add explicit glyph IDs (from feature glyph collection) ---
|
|
85
124
|
if (glyphIds && glyphIds.length > 0) {
|
|
86
125
|
const glyphSet = exports.hb_subset_input_set(
|
|
87
126
|
input,
|
|
@@ -92,6 +131,7 @@ function configureSubsetInput(
|
|
|
92
131
|
}
|
|
93
132
|
}
|
|
94
133
|
|
|
134
|
+
// --- Pin/reduce variation axes ---
|
|
95
135
|
if (variationAxes) {
|
|
96
136
|
for (const [axisName, value] of Object.entries(variationAxes)) {
|
|
97
137
|
if (typeof value === 'number') {
|
package/lib/subsetFonts.js
CHANGED
|
@@ -607,7 +607,9 @@ async function subsetFonts(
|
|
|
607
607
|
({ fontUsages, htmlOrSvgAsset }) => ({
|
|
608
608
|
assetFileName: htmlOrSvgAsset.nonInlineAncestor.urlOrDescription,
|
|
609
609
|
fontUsages: fontUsages.map((fontUsage) =>
|
|
610
|
-
(({ hasFontFeatureSettings, ...rest }) => rest)(
|
|
610
|
+
(({ hasFontFeatureSettings, fontFeatureTags, ...rest }) => rest)(
|
|
611
|
+
fontUsage
|
|
612
|
+
)
|
|
611
613
|
),
|
|
612
614
|
})
|
|
613
615
|
),
|
|
@@ -1256,7 +1258,8 @@ async function subsetFonts(
|
|
|
1256
1258
|
({ fontUsages, htmlOrSvgAsset }) => ({
|
|
1257
1259
|
assetFileName: htmlOrSvgAsset.nonInlineAncestor.urlOrDescription,
|
|
1258
1260
|
fontUsages: fontUsages.map((fontUsage) =>
|
|
1259
|
-
(({ subsets, hasFontFeatureSettings, ...rest }) =>
|
|
1261
|
+
(({ subsets, hasFontFeatureSettings, fontFeatureTags, ...rest }) =>
|
|
1262
|
+
rest)(fontUsage)
|
|
1260
1263
|
),
|
|
1261
1264
|
})
|
|
1262
1265
|
),
|
package/lib/subsetGeneration.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
const fs = require('fs/promises');
|
|
2
2
|
const pathModule = require('path');
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
-
const subsetFont = require('subset-font');
|
|
5
4
|
const { getVariationAxisBounds } = require('./variationAxes');
|
|
6
5
|
const collectFeatureGlyphIds = require('./collectFeatureGlyphIds');
|
|
7
6
|
const subsetFontWithGlyphs = require('./subsetFontWithGlyphs');
|
|
8
7
|
|
|
8
|
+
// Bump when subsetting behaviour changes to invalidate stale disk-cache
|
|
9
|
+
// entries (e.g. after adding hinting removal or table stripping).
|
|
10
|
+
const SUBSET_CACHE_VERSION = '2';
|
|
11
|
+
|
|
9
12
|
function subsetCacheKey(
|
|
10
13
|
fontBuffer,
|
|
11
14
|
text,
|
|
@@ -14,6 +17,7 @@ function subsetCacheKey(
|
|
|
14
17
|
featureGlyphIds
|
|
15
18
|
) {
|
|
16
19
|
const hash = crypto.createHash('sha256');
|
|
20
|
+
hash.update(SUBSET_CACHE_VERSION);
|
|
17
21
|
hash.update(fontBuffer);
|
|
18
22
|
hash.update(text);
|
|
19
23
|
hash.update(targetFormat);
|
|
@@ -182,7 +186,11 @@ async function getSubsetsForFontUsage(
|
|
|
182
186
|
|
|
183
187
|
let featureGlyphIds;
|
|
184
188
|
if (fontUsage.hasFontFeatureSettings && fontBuffer) {
|
|
185
|
-
featureGlyphIds = await collectFeatureGlyphIds(
|
|
189
|
+
featureGlyphIds = await collectFeatureGlyphIds(
|
|
190
|
+
fontBuffer,
|
|
191
|
+
text,
|
|
192
|
+
fontUsage.fontFeatureTags
|
|
193
|
+
);
|
|
186
194
|
}
|
|
187
195
|
|
|
188
196
|
for (const targetFormat of formats) {
|
|
@@ -207,17 +215,11 @@ async function getSubsetsForFontUsage(
|
|
|
207
215
|
if (cachedResult) {
|
|
208
216
|
subsetPromiseMap.set(promiseId, Promise.resolve(cachedResult));
|
|
209
217
|
} else {
|
|
210
|
-
const subsetCall =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
variationAxes: subsetInfo.variationAxes,
|
|
216
|
-
})
|
|
217
|
-
: subsetFont(fontBuffer, text, {
|
|
218
|
-
targetFormat,
|
|
219
|
-
variationAxes: subsetInfo.variationAxes,
|
|
220
|
-
});
|
|
218
|
+
const subsetCall = subsetFontWithGlyphs(fontBuffer, text, {
|
|
219
|
+
targetFormat,
|
|
220
|
+
glyphIds: featureGlyphIds,
|
|
221
|
+
variationAxes: subsetInfo.variationAxes,
|
|
222
|
+
});
|
|
221
223
|
|
|
222
224
|
subsetPromiseMap.set(
|
|
223
225
|
promiseId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@turntrout/subfont",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Automatically subset web fonts to only the characters used on your pages. Fork of Munter/subfont with modern defaults.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=18.0.0"
|
|
@@ -64,7 +64,6 @@
|
|
|
64
64
|
"pretty-bytes": "^5.1.0",
|
|
65
65
|
"puppeteer-core": "^24.39.1",
|
|
66
66
|
"specificity": "^0.4.1",
|
|
67
|
-
"subset-font": "^2.3.0",
|
|
68
67
|
"urltools": "^0.4.1",
|
|
69
68
|
"yargs": "^17.7.2"
|
|
70
69
|
},
|