@turntrout/subfont 1.0.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,347 @@
1
+ const fs = require('fs');
2
+ const pathModule = require('path');
3
+ const crypto = require('crypto');
4
+ const subsetFont = require('subset-font');
5
+ const { getVariationAxisBounds } = require('./variationAxes');
6
+ const collectFeatureGlyphIds = require('./collectFeatureGlyphIds');
7
+ const subsetFontWithGlyphs = require('./subsetFontWithGlyphs');
8
+
9
+ // Simple disk cache for subset results.
10
+ // Cache key: hash(fontBuffer + text + format + variationAxes)
11
+ // Cache value: the subset font buffer
12
+
13
+ function subsetCacheKey(
14
+ fontBuffer,
15
+ text,
16
+ targetFormat,
17
+ variationAxes,
18
+ featureGlyphIds
19
+ ) {
20
+ const hash = crypto.createHash('sha256');
21
+ hash.update(fontBuffer);
22
+ hash.update(text);
23
+ hash.update(targetFormat);
24
+ if (variationAxes) hash.update(JSON.stringify(variationAxes));
25
+ if (featureGlyphIds) hash.update(JSON.stringify(featureGlyphIds));
26
+ return hash.digest('hex');
27
+ }
28
+
29
+ class SubsetDiskCache {
30
+ constructor(cacheDir) {
31
+ this._cacheDir = cacheDir;
32
+ this._ensured = false;
33
+ }
34
+
35
+ _ensureDir() {
36
+ if (!this._ensured) {
37
+ try {
38
+ fs.mkdirSync(this._cacheDir, { recursive: true });
39
+ } catch {
40
+ // Ignore errors (path is a file, read-only FS, etc.)
41
+ }
42
+ this._ensured = true;
43
+ }
44
+ }
45
+
46
+ get(key) {
47
+ const filePath = pathModule.join(this._cacheDir, key);
48
+ try {
49
+ return fs.readFileSync(filePath);
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ set(key, buffer) {
56
+ this._ensureDir();
57
+ const filePath = pathModule.join(this._cacheDir, key);
58
+ try {
59
+ fs.writeFileSync(filePath, buffer);
60
+ } catch {
61
+ // Ignore write errors (read-only FS, etc.)
62
+ }
63
+ }
64
+ }
65
+
66
+ function getSubsetPromiseId(fontUsage, format, variationAxes = null) {
67
+ return [
68
+ fontUsage.text,
69
+ fontUsage.fontUrl,
70
+ format,
71
+ JSON.stringify(variationAxes),
72
+ ].join('\x1d');
73
+ }
74
+
75
+ function collectCanonicalFontUsages(htmlOrSvgAssetTextsWithProps) {
76
+ const canonicalFontUsageByUrl = new Map();
77
+
78
+ for (const item of htmlOrSvgAssetTextsWithProps) {
79
+ for (const fontUsage of item.fontUsages) {
80
+ if (!fontUsage.fontUrl) continue;
81
+ if (!canonicalFontUsageByUrl.has(fontUsage.fontUrl)) {
82
+ canonicalFontUsageByUrl.set(fontUsage.fontUrl, fontUsage);
83
+ }
84
+ }
85
+ }
86
+
87
+ return canonicalFontUsageByUrl;
88
+ }
89
+
90
+ async function loadFontAssets(assetGraph, allFonts) {
91
+ await assetGraph.populate({
92
+ followRelations: {
93
+ to: { url: { $or: allFonts } },
94
+ },
95
+ });
96
+
97
+ const fontAssetsByUrl = new Map();
98
+ const originalFontBuffers = new Map();
99
+ for (const fontUrl of allFonts) {
100
+ const fontAsset = assetGraph.findAssets({
101
+ url: fontUrl,
102
+ isLoaded: true,
103
+ })[0];
104
+ if (fontAsset) {
105
+ fontAssetsByUrl.set(fontUrl, fontAsset);
106
+ originalFontBuffers.set(fontUrl, fontAsset.rawSrc);
107
+ }
108
+ }
109
+
110
+ return { fontAssetsByUrl, originalFontBuffers };
111
+ }
112
+
113
+ async function computeVariationAxisBounds(
114
+ canonicalFontUsageByUrl,
115
+ fontAssetsByUrl,
116
+ seenAxisValuesByFontUrlAndAxisName
117
+ ) {
118
+ const cache = new Map();
119
+ const fontUrls = [...canonicalFontUsageByUrl.keys()].filter((url) =>
120
+ fontAssetsByUrl.has(url)
121
+ );
122
+ const boundsResults = await Promise.all(
123
+ fontUrls.map((fontUrl) =>
124
+ getVariationAxisBounds(
125
+ fontAssetsByUrl,
126
+ fontUrl,
127
+ seenAxisValuesByFontUrlAndAxisName
128
+ )
129
+ )
130
+ );
131
+ for (let i = 0; i < fontUrls.length; i++) {
132
+ cache.set(fontUrls[i], boundsResults[i]);
133
+ }
134
+ return cache;
135
+ }
136
+
137
+ function getSubsetInfoForFont(fontUrl, variationAxisBoundsCache) {
138
+ const res = variationAxisBoundsCache.get(fontUrl);
139
+ if (res) {
140
+ return {
141
+ variationAxes: res.variationAxes,
142
+ fullyInstanced: res.fullyInstanced,
143
+ numAxesPinned: res.numAxesPinned,
144
+ numAxesReduced: res.numAxesReduced,
145
+ };
146
+ }
147
+ return {
148
+ variationAxes: undefined,
149
+ fullyInstanced: false,
150
+ numAxesPinned: 0,
151
+ numAxesReduced: 0,
152
+ };
153
+ }
154
+
155
+ function applySubsetInfo(fontUsage, info) {
156
+ fontUsage.variationAxes = info.variationAxes;
157
+ fontUsage.fullyInstanced = info.fullyInstanced;
158
+ fontUsage.numAxesPinned = info.numAxesPinned;
159
+ fontUsage.numAxesReduced = info.numAxesReduced;
160
+ }
161
+
162
+ function assignSubsetResults(
163
+ canonicalFontUsageByUrl,
164
+ subsetResultsByFontUrl,
165
+ resolvedSubsets,
166
+ formats
167
+ ) {
168
+ for (const [, fontUsage] of canonicalFontUsageByUrl) {
169
+ const info = subsetResultsByFontUrl.get(fontUsage.fontUrl);
170
+ for (const targetFormat of formats) {
171
+ const promiseId = getSubsetPromiseId(
172
+ fontUsage,
173
+ targetFormat,
174
+ info.variationAxes
175
+ );
176
+ const subsetBuffer = resolvedSubsets.get(promiseId);
177
+ if (subsetBuffer) {
178
+ if (!fontUsage.subsets) {
179
+ fontUsage.subsets = {};
180
+ }
181
+ fontUsage.subsets[targetFormat] = subsetBuffer;
182
+ const size = subsetBuffer.length;
183
+ if (
184
+ !fontUsage.smallestSubsetSize ||
185
+ size < fontUsage.smallestSubsetSize
186
+ ) {
187
+ fontUsage.smallestSubsetSize = size;
188
+ fontUsage.smallestSubsetFormat = targetFormat;
189
+ applySubsetInfo(fontUsage, info);
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ function propagateSubsets(
197
+ htmlOrSvgAssetTextsWithProps,
198
+ canonicalFontUsageByUrl,
199
+ subsetResultsByFontUrl
200
+ ) {
201
+ for (const item of htmlOrSvgAssetTextsWithProps) {
202
+ for (const fontUsage of item.fontUsages) {
203
+ if (!fontUsage.fontUrl) continue;
204
+ const canonical = canonicalFontUsageByUrl.get(fontUsage.fontUrl);
205
+ if (canonical && canonical !== fontUsage && canonical.subsets) {
206
+ fontUsage.subsets = canonical.subsets;
207
+ fontUsage.smallestSubsetSize = canonical.smallestSubsetSize;
208
+ fontUsage.smallestSubsetFormat = canonical.smallestSubsetFormat;
209
+ applySubsetInfo(
210
+ fontUsage,
211
+ subsetResultsByFontUrl.get(fontUsage.fontUrl)
212
+ );
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ async function getSubsetsForFontUsage(
219
+ assetGraph,
220
+ htmlOrSvgAssetTextsWithProps,
221
+ formats,
222
+ seenAxisValuesByFontUrlAndAxisName,
223
+ cacheDir = null
224
+ ) {
225
+ const diskCache = cacheDir ? new SubsetDiskCache(cacheDir) : null;
226
+ const canonicalFontUsageByUrl = collectCanonicalFontUsages(
227
+ htmlOrSvgAssetTextsWithProps
228
+ );
229
+ const allFontUrls = [...canonicalFontUsageByUrl.keys()];
230
+
231
+ const { fontAssetsByUrl, originalFontBuffers } = await loadFontAssets(
232
+ assetGraph,
233
+ allFontUrls
234
+ );
235
+
236
+ const subsetPromiseMap = new Map();
237
+ const subsetResultsByFontUrl = new Map();
238
+
239
+ const variationAxisBoundsCache = await computeVariationAxisBounds(
240
+ canonicalFontUsageByUrl,
241
+ fontAssetsByUrl,
242
+ seenAxisValuesByFontUrlAndAxisName
243
+ );
244
+
245
+ for (const [fontUrl, fontUsage] of canonicalFontUsageByUrl) {
246
+ const fontBuffer = originalFontBuffers.get(fontUrl);
247
+ if (!fontBuffer) continue;
248
+ const text = fontUsage.text;
249
+ const subsetInfo = getSubsetInfoForFont(fontUrl, variationAxisBoundsCache);
250
+ subsetResultsByFontUrl.set(fontUrl, subsetInfo);
251
+
252
+ // When font-feature-settings or font-variant-* CSS properties are used,
253
+ // collect the alternate glyph IDs that GSUB features produce for the
254
+ // page text. These are passed directly to HarfBuzz's subset glyph set,
255
+ // preserving the alternate glyphs without including all codepoints.
256
+ let featureGlyphIds;
257
+ if (fontUsage.hasFontFeatureSettings && fontBuffer) {
258
+ featureGlyphIds = await collectFeatureGlyphIds(fontBuffer, text);
259
+ }
260
+
261
+ for (const targetFormat of formats) {
262
+ const promiseId = getSubsetPromiseId(
263
+ fontUsage,
264
+ targetFormat,
265
+ subsetInfo.variationAxes
266
+ );
267
+
268
+ if (!subsetPromiseMap.has(promiseId)) {
269
+ // Check disk cache first if available
270
+ const cacheKey = diskCache
271
+ ? subsetCacheKey(
272
+ fontBuffer,
273
+ text,
274
+ targetFormat,
275
+ subsetInfo.variationAxes,
276
+ featureGlyphIds
277
+ )
278
+ : null;
279
+ const cachedResult = diskCache && diskCache.get(cacheKey);
280
+
281
+ if (cachedResult) {
282
+ subsetPromiseMap.set(promiseId, Promise.resolve(cachedResult));
283
+ } else {
284
+ const subsetCall =
285
+ featureGlyphIds && featureGlyphIds.length > 0
286
+ ? subsetFontWithGlyphs(fontBuffer, text, {
287
+ targetFormat,
288
+ glyphIds: featureGlyphIds,
289
+ variationAxes: subsetInfo.variationAxes,
290
+ })
291
+ : subsetFont(fontBuffer, text, {
292
+ targetFormat,
293
+ variationAxes: subsetInfo.variationAxes,
294
+ });
295
+
296
+ subsetPromiseMap.set(
297
+ promiseId,
298
+ subsetCall
299
+ .then((result) => {
300
+ if (diskCache && result) {
301
+ diskCache.set(cacheKey, result);
302
+ }
303
+ return result;
304
+ })
305
+ .catch((err) => {
306
+ const error = new Error(err.message);
307
+ error.asset = fontAssetsByUrl.get(fontUrl);
308
+ assetGraph.warn(error);
309
+ })
310
+ );
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ // Await all subset promises, then assign results synchronously to avoid
317
+ // race conditions when multiple formats resolve concurrently.
318
+ const promiseKeys = [...subsetPromiseMap.keys()];
319
+ const promiseResults = await Promise.all(subsetPromiseMap.values());
320
+ const resolvedSubsets = new Map();
321
+ for (let i = 0; i < promiseKeys.length; i++) {
322
+ resolvedSubsets.set(promiseKeys[i], promiseResults[i]);
323
+ }
324
+
325
+ assignSubsetResults(
326
+ canonicalFontUsageByUrl,
327
+ subsetResultsByFontUrl,
328
+ resolvedSubsets,
329
+ formats
330
+ );
331
+
332
+ propagateSubsets(
333
+ htmlOrSvgAssetTextsWithProps,
334
+ canonicalFontUsageByUrl,
335
+ subsetResultsByFontUrl
336
+ );
337
+
338
+ return fontAssetsByUrl;
339
+ }
340
+
341
+ module.exports = {
342
+ getSubsetPromiseId,
343
+ getSubsetsForFontUsage,
344
+ // Exported for testing
345
+ _subsetCacheKey: subsetCacheKey,
346
+ _SubsetDiskCache: SubsetDiskCache,
347
+ };
@@ -0,0 +1,38 @@
1
+ // A much, much smarter person than me solved this problem, and their code represents the bulk of the work here:
2
+ // http://stackoverflow.com/questions/2270910/how-to-convert-sequence-of-numbers-in-an-array-to-range-of-numbers
3
+
4
+ function getHexValue(num) {
5
+ return num.toString(16).toUpperCase();
6
+ }
7
+
8
+ /**
9
+ * Generates a unicode-range string from an array of unicode codepoints
10
+ * @param {Number[]} codePoints The code points
11
+ * @return {String} The resulting [unicode-range](https://developer.mozilla.org/en-US/docs/Web/CSS/%40font-face/unicode-range)
12
+ */
13
+ const getUnicodeRanges = (codePoints) => {
14
+ const ranges = [];
15
+ let start, end;
16
+
17
+ codePoints.sort((a, b) => a - b);
18
+
19
+ for (let i = 0; i < codePoints.length; i++) {
20
+ start = codePoints[i];
21
+ end = start;
22
+
23
+ while (codePoints[i + 1] - codePoints[i] === 1) {
24
+ end = codePoints[i + 1];
25
+ i++;
26
+ }
27
+
28
+ ranges.push(
29
+ start === end
30
+ ? `U+${getHexValue(start)}`
31
+ : `U+${getHexValue(start)}-${getHexValue(end)}`
32
+ );
33
+ }
34
+
35
+ return ranges.toString();
36
+ };
37
+
38
+ module.exports = getUnicodeRanges;
package/lib/unquote.js ADDED
@@ -0,0 +1,23 @@
1
+ function unescapeCssString(str) {
2
+ return str.replace(
3
+ /\\([0-9a-f]{1,6})(\s?)/gi,
4
+ ($0, hexChars, followingWhitespace) =>
5
+ `${String.fromCodePoint(parseInt(hexChars, 16))}${
6
+ hexChars.length === 6 ? followingWhitespace : ''
7
+ }`
8
+ );
9
+ }
10
+
11
+ module.exports = function unquote(str) {
12
+ if (typeof str !== 'string') {
13
+ return str;
14
+ }
15
+
16
+ return str.replace(
17
+ /^'([^']*)'$|^"([^"]*)"$/,
18
+ ($0, singleQuoted, doubleQuoted) =>
19
+ typeof singleQuoted === 'string'
20
+ ? unescapeCssString(singleQuoted.replace(/\\'/g, "'"))
21
+ : unescapeCssString(doubleQuoted.replace(/\\"/g, '"'))
22
+ );
23
+ };
@@ -0,0 +1,162 @@
1
+ const getFontInfo = require('./getFontInfo');
2
+ const parseFontVariationSettings = require('./parseFontVariationSettings');
3
+
4
+ const standardVariationAxes = new Set(['wght', 'wdth', 'ital', 'slnt', 'opsz']);
5
+ // It would be very hard to trace statically which values of opsz (font-optical-sizing)
6
+ // are going to be used, so we ignore that one:
7
+ const ignoredVariationAxes = new Set(['opsz']);
8
+
9
+ function clamp(value, min, max) {
10
+ return Math.min(Math.max(value, min), max);
11
+ }
12
+
13
+ function renderNumberRange(min, max) {
14
+ if (min === max) {
15
+ return String(min);
16
+ } else {
17
+ return `${min}-${max}`;
18
+ }
19
+ }
20
+
21
+ function getVariationAxisUsage(
22
+ htmlOrSvgAssetTextsWithProps,
23
+ parseFontWeightRange,
24
+ parseFontStretchRange
25
+ ) {
26
+ const seenAxisValuesByFontUrlAndAxisName = new Map();
27
+
28
+ function noteUsedValue(fontUrl, axisName, axisValue) {
29
+ let seenAxes = seenAxisValuesByFontUrlAndAxisName.get(fontUrl);
30
+ if (!seenAxes) {
31
+ seenAxes = new Map();
32
+ seenAxisValuesByFontUrlAndAxisName.set(fontUrl, seenAxes);
33
+ }
34
+ if (seenAxes.has(axisName)) {
35
+ seenAxes.get(axisName).add(axisValue);
36
+ } else {
37
+ seenAxes.set(axisName, new Set([axisValue]));
38
+ }
39
+ }
40
+
41
+ // Since fontUsages are built from shared templates, all pages produce
42
+ // the same fontStyles/fontWeights/etc. for a given fontUrl. Process
43
+ // each unique fontUrl only once to avoid num_pages × redundant iterations.
44
+ const seenFontUrls = new Set();
45
+ for (const { fontUsages } of htmlOrSvgAssetTextsWithProps) {
46
+ for (const {
47
+ fontUrl,
48
+ fontStyles,
49
+ fontWeights,
50
+ fontStretches,
51
+ fontVariationSettings,
52
+ props,
53
+ } of fontUsages) {
54
+ if (seenFontUrls.has(fontUrl)) continue;
55
+ seenFontUrls.add(fontUrl);
56
+ if (fontStyles.has('italic')) {
57
+ noteUsedValue(fontUrl, 'ital', 1);
58
+ }
59
+ // If any font-style value except italic is seen (including normal or oblique)
60
+ // we're also utilizing value 0:
61
+ if (fontStyles.size > (fontStyles.has('italic') ? 1 : 0)) {
62
+ noteUsedValue(fontUrl, 'ital', 0);
63
+ }
64
+ if (fontStyles.has('oblique')) {
65
+ // https://www.w3.org/TR/css-fonts-4/#font-style-prop
66
+ // oblique <angle>?
67
+ // [...] The lack of an <angle> represents 14deg.
68
+ // And also:
69
+ // Note: the OpenType slnt axis is defined with a positive angle meaning a counter-clockwise slant, the opposite direction to CSS.
70
+ // The CSS implementation will take this into account when using variations to produce oblique faces.
71
+ noteUsedValue(fontUrl, 'slnt', -14);
72
+ }
73
+ // If any font-style value except oblique is seen (including normal or italic)
74
+ // we're also utilizing value 0:
75
+ if (fontStyles.size > (fontStyles.has('oblique') ? 1 : 0)) {
76
+ noteUsedValue(fontUrl, 'slnt', 0);
77
+ }
78
+
79
+ const minMaxFontWeight = parseFontWeightRange(props['font-weight']);
80
+ for (const fontWeight of fontWeights) {
81
+ noteUsedValue(fontUrl, 'wght', clamp(fontWeight, ...minMaxFontWeight));
82
+ }
83
+
84
+ const minMaxFontStretch = parseFontStretchRange(props['font-stretch']);
85
+ for (const fontStrech of fontStretches) {
86
+ noteUsedValue(fontUrl, 'wdth', clamp(fontStrech, ...minMaxFontStretch));
87
+ }
88
+
89
+ for (const fontVariationSettingsValue of fontVariationSettings) {
90
+ for (const [axisName, axisValue] of parseFontVariationSettings(
91
+ fontVariationSettingsValue
92
+ )) {
93
+ noteUsedValue(fontUrl, axisName, axisValue);
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ return { seenAxisValuesByFontUrlAndAxisName };
100
+ }
101
+
102
+ async function getVariationAxisBounds(
103
+ fontAssetsByUrl,
104
+ fontUrl,
105
+ seenAxisValuesByFontUrlAndAxisName
106
+ ) {
107
+ let fontInfo;
108
+ try {
109
+ fontInfo = await getFontInfo(fontAssetsByUrl.get(fontUrl).rawSrc);
110
+ } catch {
111
+ // Invalid font -- skip instancing, return safe defaults
112
+ return {
113
+ fullyInstanced: false,
114
+ numAxesPinned: 0,
115
+ numAxesReduced: 0,
116
+ variationAxes: {},
117
+ };
118
+ }
119
+
120
+ const variationAxes = {};
121
+ let fullyInstanced = true;
122
+ let numAxesPinned = 0;
123
+ let numAxesReduced = 0;
124
+ const fontVariationEntries = Object.entries(fontInfo.variationAxes);
125
+ const seenAxisValuesByAxisName =
126
+ seenAxisValuesByFontUrlAndAxisName.get(fontUrl);
127
+ if (fontVariationEntries.length > 0 && seenAxisValuesByAxisName) {
128
+ for (const [
129
+ axisName,
130
+ { min, max, default: defaultValue },
131
+ ] of fontVariationEntries) {
132
+ let seenAxisValues = seenAxisValuesByAxisName.get(axisName);
133
+ if (!seenAxisValues && !ignoredVariationAxes.has(axisName)) {
134
+ seenAxisValues = new Set([defaultValue]);
135
+ }
136
+ if (seenAxisValues && seenAxisValues.size === 1) {
137
+ variationAxes[axisName] = clamp([...seenAxisValues][0], min, max);
138
+ numAxesPinned += 1;
139
+ } else if (seenAxisValues) {
140
+ const minSeenValue = Math.min(...seenAxisValues);
141
+ const maxSeenValue = Math.max(...seenAxisValues);
142
+ variationAxes[axisName] = {
143
+ min: Math.max(minSeenValue, min),
144
+ max: Math.min(maxSeenValue, max),
145
+ };
146
+ fullyInstanced = false;
147
+ if (minSeenValue > min || maxSeenValue < max) {
148
+ numAxesReduced += 1;
149
+ }
150
+ }
151
+ }
152
+ }
153
+ return { fullyInstanced, numAxesPinned, numAxesReduced, variationAxes };
154
+ }
155
+
156
+ module.exports = {
157
+ standardVariationAxes,
158
+ ignoredVariationAxes,
159
+ renderNumberRange,
160
+ getVariationAxisUsage,
161
+ getVariationAxisBounds,
162
+ };