Skip to content

Commit 6579099

Browse files
Resolve fonts using CSS Level 3 algorithm when using html() (#3040)
Co-authored-by: Lukas Hollaender <[email protected]>
1 parent 75e6ed7 commit 6579099

25 files changed

+1392
-13
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.vscode
12
.idea
23
.DS_Store
34
node_modules/

.prettierrc

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

src/libs/fontFace.js

+391
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
function toLookup(arr) {
2+
return arr.reduce(function(lookup, name, index) {
3+
lookup[name] = index;
4+
5+
return lookup;
6+
}, {});
7+
}
8+
9+
var fontStyleOrder = {
10+
italic: ["italic", "oblique", "normal"],
11+
oblique: ["oblique", "italic", "normal"],
12+
normal: ["normal", "oblique", "italic"]
13+
};
14+
15+
var fontStretchOrder = [
16+
"ultra-condensed",
17+
"extra-condensed",
18+
"condensed",
19+
"semi-condensed",
20+
"normal",
21+
"semi-expanded",
22+
"expanded",
23+
"extra-expanded",
24+
"ultra-expanded"
25+
];
26+
27+
// For a given font-stretch value, we need to know where to start our search
28+
// from in the fontStretchOrder list.
29+
var fontStretchLookup = toLookup(fontStretchOrder);
30+
31+
var fontWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
32+
var fontWeightsLookup = toLookup(fontWeights);
33+
34+
function normalizeFontStretch(stretch) {
35+
stretch = stretch || "normal";
36+
37+
return typeof fontStretchLookup[stretch] === "number" ? stretch : "normal";
38+
}
39+
40+
function normalizeFontStyle(style) {
41+
style = style || "normal";
42+
43+
return fontStyleOrder[style] ? style : "normal";
44+
}
45+
46+
function normalizeFontWeight(weight) {
47+
if (!weight) {
48+
return 400;
49+
}
50+
51+
if (typeof weight === "number") {
52+
// Ignore values which aren't valid font-weights.
53+
return weight >= 100 && weight <= 900 && weight % 100 === 0 ? weight : 400;
54+
}
55+
56+
if (/^\d00$/.test(weight)) {
57+
return parseInt(weight);
58+
}
59+
60+
switch (weight) {
61+
case "bold":
62+
return 700;
63+
64+
case "normal":
65+
default:
66+
return 400;
67+
}
68+
}
69+
70+
export function normalizeFontFace(fontFace) {
71+
var family = fontFace.family.replace(/"|'/g, "").toLowerCase();
72+
73+
var style = normalizeFontStyle(fontFace.style);
74+
var weight = normalizeFontWeight(fontFace.weight);
75+
var stretch = normalizeFontStretch(fontFace.stretch);
76+
77+
return {
78+
family: family,
79+
style: style,
80+
weight: weight,
81+
stretch: stretch,
82+
src: fontFace.src || [],
83+
84+
// The ref property maps this font-face to the font
85+
// added by the .addFont() method.
86+
ref: fontFace.ref || {
87+
name: family,
88+
style: [stretch, style, weight].join(" ")
89+
}
90+
};
91+
}
92+
93+
/**
94+
* Turns a list of font-faces into a map, for easier lookup when resolving
95+
* fonts.
96+
* */
97+
export function buildFontFaceMap(fontFaces) {
98+
var map = {};
99+
100+
for (var i = 0; i < fontFaces.length; ++i) {
101+
var normalized = normalizeFontFace(fontFaces[i]);
102+
103+
var name = normalized.family;
104+
var stretch = normalized.stretch;
105+
var style = normalized.style;
106+
var weight = normalized.weight;
107+
108+
map[name] = map[name] || {};
109+
110+
map[name][stretch] = map[name][stretch] || {};
111+
map[name][stretch][style] = map[name][stretch][style] || {};
112+
map[name][stretch][style][weight] = normalized;
113+
}
114+
115+
return map;
116+
}
117+
118+
/**
119+
* Searches a map of stretches, weights, etc. in the given direction and
120+
* then, if no match has been found, in the opposite directions.
121+
*
122+
* @param {Object.<string, any>} matchingSet A map of the various font variations.
123+
* @param {any[]} order The order of the different variations
124+
* @param {number} pivot The starting point of the search in the order list.
125+
* @param {-1 | 1} dir The initial direction of the search (desc = -1, asc = 1)
126+
*/
127+
128+
function searchFromPivot(matchingSet, order, pivot, dir) {
129+
var i;
130+
131+
for (i = pivot; i >= 0 && i < order.length; i += dir) {
132+
if (matchingSet[order[i]]) {
133+
return matchingSet[order[i]];
134+
}
135+
}
136+
137+
for (i = pivot; i >= 0 && i < order.length; i -= dir) {
138+
if (matchingSet[order[i]]) {
139+
return matchingSet[order[i]];
140+
}
141+
}
142+
}
143+
144+
function resolveFontStretch(stretch, matchingSet) {
145+
if (matchingSet[stretch]) {
146+
return matchingSet[stretch];
147+
}
148+
149+
var pivot = fontStretchLookup[stretch];
150+
151+
// If the font-stretch value is normal or more condensed, we want to
152+
// start with a descending search, otherwise we should do ascending.
153+
var dir = pivot <= fontStretchLookup["normal"] ? -1 : 1;
154+
var match = searchFromPivot(matchingSet, fontStretchOrder, pivot, dir);
155+
156+
if (!match) {
157+
// Since a font-family cannot exist without having at least one stretch value
158+
// we should never reach this point.
159+
throw new Error(
160+
"Could not find a matching font-stretch value for " + stretch
161+
);
162+
}
163+
164+
return match;
165+
}
166+
167+
function resolveFontStyle(fontStyle, matchingSet) {
168+
if (matchingSet[fontStyle]) {
169+
return matchingSet[fontStyle];
170+
}
171+
172+
var ordering = fontStyleOrder[fontStyle];
173+
174+
for (var i = 0; i < ordering.length; ++i) {
175+
if (matchingSet[ordering[i]]) {
176+
return matchingSet[ordering[i]];
177+
}
178+
}
179+
180+
// Since a font-family cannot exist without having at least one style value
181+
// we should never reach this point.
182+
throw new Error("Could not find a matching font-style for " + fontStyle);
183+
}
184+
185+
function resolveFontWeight(weight, matchingSet) {
186+
if (matchingSet[weight]) {
187+
return matchingSet[weight];
188+
}
189+
190+
if (weight === 400 && matchingSet[500]) {
191+
return matchingSet[500];
192+
}
193+
194+
if (weight === 500 && matchingSet[400]) {
195+
return matchingSet[400];
196+
}
197+
198+
var pivot = fontWeightsLookup[weight];
199+
200+
// If the font-stretch value is normal or more condensed, we want to
201+
// start with a descending search, otherwise we should do ascending.
202+
var dir = weight < 400 ? -1 : 1;
203+
var match = searchFromPivot(matchingSet, fontWeights, pivot, dir);
204+
205+
if (!match) {
206+
// Since a font-family cannot exist without having at least one stretch value
207+
// we should never reach this point.
208+
throw new Error(
209+
"Could not find a matching font-weight for value " + weight
210+
);
211+
}
212+
213+
return match;
214+
}
215+
216+
var defaultGenericFontFamilies = {
217+
"sans-serif": "helvetica",
218+
fixed: "courier",
219+
monospace: "courier",
220+
terminal: "courier",
221+
cursive: "times",
222+
fantasy: "times",
223+
serif: "times"
224+
};
225+
226+
var systemFonts = {
227+
caption: "times",
228+
icon: "times",
229+
menu: "times",
230+
"message-box": "times",
231+
"small-caption": "times",
232+
"status-bar": "times"
233+
};
234+
235+
function ruleToString(rule) {
236+
return [rule.stretch, rule.style, rule.weight, rule.family].join(" ");
237+
}
238+
239+
export function resolveFontFace(fontFaceMap, rules, opts) {
240+
opts = opts || {};
241+
242+
var defaultFontFamily = opts.defaultFontFamily || "times";
243+
var genericFontFamilies = Object.assign(
244+
{},
245+
defaultGenericFontFamilies,
246+
opts.genericFontFamilies || {}
247+
);
248+
249+
var rule = null;
250+
var matches = null;
251+
252+
for (var i = 0; i < rules.length; ++i) {
253+
rule = normalizeFontFace(rules[i]);
254+
255+
if (genericFontFamilies[rule.family]) {
256+
rule.family = genericFontFamilies[rule.family];
257+
}
258+
259+
if (fontFaceMap.hasOwnProperty(rule.family)) {
260+
matches = fontFaceMap[rule.family];
261+
262+
break;
263+
}
264+
}
265+
266+
// Always fallback to a known font family.
267+
matches = matches || fontFaceMap[defaultFontFamily];
268+
269+
if (!matches) {
270+
// At this point we should definitiely have a font family, but if we
271+
// don't there is something wrong with our configuration
272+
throw new Error(
273+
"Could not find a font-family for the rule '" +
274+
ruleToString(rule) +
275+
"' and default family '" +
276+
defaultFontFamily +
277+
"'."
278+
);
279+
}
280+
281+
matches = resolveFontStretch(rule.stretch, matches);
282+
matches = resolveFontStyle(rule.style, matches);
283+
matches = resolveFontWeight(rule.weight, matches);
284+
285+
if (!matches) {
286+
// We should've fount
287+
throw new Error(
288+
"Failed to resolve a font for the rule '" + ruleToString(rule) + "'."
289+
);
290+
}
291+
292+
return matches;
293+
}
294+
295+
/**
296+
* Builds a style id for use with the addFont() method.
297+
* @param {FontFace} font
298+
*/
299+
export function toStyleName(font) {
300+
return [font.weight, font.style, font.stretch].join(" ");
301+
}
302+
303+
function eatWhiteSpace(input) {
304+
return input.trimLeft();
305+
}
306+
307+
function parseQuotedFontFamily(input, quote) {
308+
var index = 0;
309+
310+
while (index < input.length) {
311+
var current = input.charAt(index);
312+
313+
if (current === quote) {
314+
return [input.substring(0, index), input.substring(index + 1)];
315+
}
316+
317+
index += 1;
318+
}
319+
320+
// Unexpected end of input
321+
return null;
322+
}
323+
324+
function parseNonQuotedFontFamily(input) {
325+
// It implements part of the identifier parser here: https://www.w3.org/TR/CSS21/syndata.html#value-def-identifier
326+
//
327+
// NOTE: This parser pretty much ignores escaped identifiers and that there is a thing called unicode.
328+
//
329+
// Breakdown of regexp:
330+
// -[a-z_] - when identifier starts with a hyphen, you're not allowed to have another hyphen or a digit
331+
// [a-z_] - allow a-z and underscore at beginning of input
332+
// [a-z0-9_-]* - after that, anything goes
333+
var match = input.match(/^(-[a-z_]|[a-z_])[a-z0-9_-]*/i);
334+
335+
// non quoted value contains illegal characters
336+
if (match === null) {
337+
return null;
338+
}
339+
340+
return [match[0], input.substring(match[0].length)];
341+
}
342+
343+
var defaultFont = ["times"];
344+
345+
export function parseFontFamily(input) {
346+
var result = [];
347+
var ch, parsed;
348+
var remaining = input.trim();
349+
350+
if (remaining === "") {
351+
return defaultFont;
352+
}
353+
354+
if (remaining in systemFonts) {
355+
return [systemFonts[remaining]];
356+
}
357+
358+
while (remaining !== "") {
359+
parsed = null;
360+
remaining = eatWhiteSpace(remaining);
361+
ch = remaining.charAt(0);
362+
363+
switch (ch) {
364+
case '"':
365+
case "'":
366+
parsed = parseQuotedFontFamily(remaining.substring(1), ch);
367+
break;
368+
369+
default:
370+
parsed = parseNonQuotedFontFamily(remaining);
371+
break;
372+
}
373+
374+
if (parsed === null) {
375+
return defaultFont;
376+
}
377+
378+
result.push(parsed[0]);
379+
380+
remaining = eatWhiteSpace(parsed[1]);
381+
382+
// We expect end of input or a comma separator here
383+
if (remaining !== "" && remaining.charAt(0) !== ",") {
384+
return defaultFont;
385+
}
386+
387+
remaining = remaining.replace(/^,/, "");
388+
}
389+
390+
return result;
391+
}

0 commit comments

Comments
 (0)