@turntrout/subfont 1.0.2 → 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/getCssRulesByProperty.js +91 -16
- package/lib/normalizeFontPropertyValue.js +49 -8
- package/lib/subsetFonts.js +36 -9
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -3,6 +3,34 @@ const initialValueByProp = require('./initialValueByProp');
|
|
|
3
3
|
const unquote = require('./unquote');
|
|
4
4
|
const normalizeFontStretch = require('font-snapper/lib/normalizeFontStretch');
|
|
5
5
|
|
|
6
|
+
// Resolve the CSS relative font-weight keyword `bolder` according to CSS Fonts Level 4.
|
|
7
|
+
// https://www.w3.org/TR/css-fonts-4/#relative-weights
|
|
8
|
+
function applyBolder(weight) {
|
|
9
|
+
if (weight < 350) return 400;
|
|
10
|
+
if (weight < 550) return 700;
|
|
11
|
+
return 900;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Resolve the CSS relative font-weight keyword `lighter` according to CSS Fonts Level 4.
|
|
15
|
+
function applyLighter(weight) {
|
|
16
|
+
if (weight < 100) return weight;
|
|
17
|
+
if (weight < 550) return 100;
|
|
18
|
+
if (weight < 750) return 400;
|
|
19
|
+
return 700;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolve a font-weight name (e.g. "bold") or numeric string to a number.
|
|
23
|
+
// Returns NaN if the value is not a recognized weight.
|
|
24
|
+
function resolveWeightToken(token) {
|
|
25
|
+
const named = cssFontWeightNames[token];
|
|
26
|
+
return parseFloat(named !== undefined ? named : token);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Return true when the numeric weight is within the CSS spec range [1, 1000].
|
|
30
|
+
function isValidWeight(weight) {
|
|
31
|
+
return weight >= 1 && weight <= 1000;
|
|
32
|
+
}
|
|
33
|
+
|
|
6
34
|
function normalizeFontPropertyValue(propName, value) {
|
|
7
35
|
const propNameLowerCase = propName.toLowerCase();
|
|
8
36
|
if (value === undefined) {
|
|
@@ -13,16 +41,29 @@ function normalizeFontPropertyValue(propName, value) {
|
|
|
13
41
|
} else if (propNameLowerCase === 'font-weight') {
|
|
14
42
|
let parsedValue = value;
|
|
15
43
|
if (typeof parsedValue === 'string') {
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
44
|
+
// font-tracer encodes relative weight modifiers as "baseWeight+bolder+lighter+..."
|
|
45
|
+
// Apply each modifier sequentially per the CSS Fonts Level 4 spec.
|
|
46
|
+
const segments = parsedValue.split('+');
|
|
47
|
+
const baseToken = segments[0].toLowerCase();
|
|
48
|
+
let baseWeight = resolveWeightToken(baseToken);
|
|
49
|
+
if (isValidWeight(baseWeight) && segments.length > 1) {
|
|
50
|
+
for (let i = 1; i < segments.length; i++) {
|
|
51
|
+
const modifier = segments[i].toLowerCase();
|
|
52
|
+
if (modifier === 'bolder') {
|
|
53
|
+
baseWeight = applyBolder(baseWeight);
|
|
54
|
+
} else if (modifier === 'lighter') {
|
|
55
|
+
baseWeight = applyLighter(baseWeight);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return baseWeight;
|
|
59
|
+
}
|
|
60
|
+
parsedValue = baseToken;
|
|
19
61
|
}
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
return
|
|
23
|
-
} else {
|
|
24
|
-
return value;
|
|
62
|
+
const numericWeight = resolveWeightToken(parsedValue);
|
|
63
|
+
if (isValidWeight(numericWeight)) {
|
|
64
|
+
return numericWeight;
|
|
25
65
|
}
|
|
66
|
+
return value;
|
|
26
67
|
} else if (propNameLowerCase === 'font-stretch') {
|
|
27
68
|
return normalizeFontStretch(value);
|
|
28
69
|
} else if (typeof value === 'string' && propNameLowerCase !== 'src') {
|
package/lib/subsetFonts.js
CHANGED
|
@@ -854,7 +854,6 @@ async function subsetFonts(
|
|
|
854
854
|
for (const htmlOrSvgAsset of htmlOrSvgAssets) {
|
|
855
855
|
const accumulatedFontFaceDeclarations =
|
|
856
856
|
fontFaceDeclarationsByHtmlOrSvgAsset.get(htmlOrSvgAsset);
|
|
857
|
-
// TODO: Maybe group by media?
|
|
858
857
|
const containedRelationsByFontFaceRule = new Map();
|
|
859
858
|
for (const { relations } of accumulatedFontFaceDeclarations) {
|
|
860
859
|
for (const relation of relations) {
|
|
@@ -882,14 +881,42 @@ async function subsetFonts(
|
|
|
882
881
|
continue;
|
|
883
882
|
}
|
|
884
883
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
.
|
|
884
|
+
// Group @font-face rules by their enclosing @media context so the
|
|
885
|
+
// fallback CSS preserves the original media-conditional loading.
|
|
886
|
+
// Walk up the ancestor chain in case the rule is nested (e.g.
|
|
887
|
+
// inside @supports inside @media).
|
|
888
|
+
const rulesByMedia = new Map(); // media params (or '') → [cssText, ...]
|
|
889
|
+
for (const rule of containedRelationsByFontFaceRule.keys()) {
|
|
890
|
+
let mediaKey = '';
|
|
891
|
+
let ancestor = rule.parent;
|
|
892
|
+
while (ancestor) {
|
|
893
|
+
if (
|
|
894
|
+
ancestor.type === 'atrule' &&
|
|
895
|
+
ancestor.name.toLowerCase() === 'media'
|
|
896
|
+
) {
|
|
897
|
+
mediaKey = ancestor.params;
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
ancestor = ancestor.parent;
|
|
901
|
+
}
|
|
902
|
+
if (!rulesByMedia.has(mediaKey)) rulesByMedia.set(mediaKey, []);
|
|
903
|
+
rulesByMedia
|
|
904
|
+
.get(mediaKey)
|
|
905
|
+
.push(
|
|
906
|
+
getFontFaceDeclarationText(
|
|
907
|
+
rule,
|
|
908
|
+
containedRelationsByFontFaceRule.get(rule)
|
|
909
|
+
)
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
let fallbackCssText = '';
|
|
913
|
+
for (const [media, texts] of rulesByMedia) {
|
|
914
|
+
if (media) {
|
|
915
|
+
fallbackCssText += `@media ${media}{${texts.join('')}}`;
|
|
916
|
+
} else {
|
|
917
|
+
fallbackCssText += texts.join('');
|
|
918
|
+
}
|
|
919
|
+
}
|
|
893
920
|
|
|
894
921
|
let cssAsset = fallbackCssAssetCache.get(fallbackCssText);
|
|
895
922
|
if (!cssAsset) {
|
package/package.json
CHANGED