Skip to content

Commit 5e5cf96

Browse files
committed
improve logic for parsing font family strings
1 parent 8204a5c commit 5e5cf96

File tree

3 files changed

+182
-4
lines changed

3 files changed

+182
-4
lines changed

src/libs/fontFace.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,15 @@ var defaultGenericFontFamilies = {
223223
serif: "times"
224224
};
225225

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+
226235
function ruleToString(rule) {
227236
return [rule.stretch, rule.style, rule.weight, rule.family].join(" ");
228237
}
@@ -290,3 +299,100 @@ export function resolveFontFace(fontFaceMap, rules, opts) {
290299
export function toStyleName(font) {
291300
return [font.weight, font.style, font.stretch].join(" ");
292301
}
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+
switch (current) {
314+
case quote:
315+
return [input.substring(0, index), input.substring(index + 1)];
316+
317+
// Mismatching quote
318+
case ",":
319+
return null;
320+
}
321+
322+
index += 1;
323+
}
324+
325+
// Unexpected end of input
326+
return null;
327+
}
328+
329+
function parseNonQuotedFontFamily(input) {
330+
// It implements part of the identifier parser here: https://www.w3.org/TR/CSS21/syndata.html#value-def-identifier
331+
//
332+
// NOTE: This parser pretty much ignores escaped identifiers and that there is a thing called unicode.
333+
//
334+
// Breakdown of regexp:
335+
// -[a-z_] - when identifier starts with a hyphen, you're not allowed to have another hyphen or a digit
336+
// [a-z_] - allow a-z and underscore at beginning of input
337+
// [a-z0-9_-]* - after that, anything goes
338+
var match = input.match(/^(-[a-z_]|[a-z_])[a-z0-9_-]*/i);
339+
340+
// non quoted value contains illegal characters
341+
if (match === null) {
342+
return null;
343+
}
344+
345+
return [match[0], input.substring(match[0].length)];
346+
}
347+
348+
var defaultFont = ["times"];
349+
350+
export function parseFontFamily(input) {
351+
var result = [];
352+
var ch, parsed;
353+
var remaining = input.trim();
354+
355+
if (remaining === "") {
356+
return defaultFont;
357+
}
358+
359+
if (remaining in systemFonts) {
360+
return [systemFonts[remaining]];
361+
}
362+
363+
while (remaining !== "") {
364+
parsed = null;
365+
remaining = eatWhiteSpace(remaining);
366+
ch = remaining.charAt(0);
367+
368+
switch (ch) {
369+
case '"':
370+
case "'":
371+
parsed = parseQuotedFontFamily(remaining.substring(1), ch);
372+
break;
373+
374+
default:
375+
parsed = parseNonQuotedFontFamily(remaining);
376+
break;
377+
}
378+
379+
if (parsed === null) {
380+
return defaultFont;
381+
}
382+
383+
result.push(parsed[0]);
384+
385+
remaining = eatWhiteSpace(parsed[1]);
386+
387+
// We expect end of input or a comma separator here
388+
if (remaining !== "" && remaining.charAt(0) !== ",") {
389+
return defaultFont;
390+
}
391+
392+
remaining = remaining.replace(/^,/, "");
393+
}
394+
395+
return result.map(function(f) {
396+
return f.toLowerCase();
397+
});
398+
}

src/modules/context2d.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
import { jsPDF } from "../jspdf.js";
1111
import { RGBColor } from "../libs/rgbcolor.js";
1212
import { console } from "../libs/console.js";
13-
import { buildFontFaceMap, resolveFontFace } from "../libs/fontFace.js";
13+
import {
14+
buildFontFaceMap,
15+
parseFontFamily,
16+
resolveFontFace
17+
} from "../libs/fontFace.js";
1418

1519
/**
1620
* This plugin mimics the HTML5 CanvasRenderingContext2D.
@@ -524,8 +528,7 @@ import { buildFontFaceMap, resolveFontFace } from "../libs/fontFace.js";
524528
}
525529

526530
this.pdf.setFontSize(fontSize);
527-
528-
var parts = fontFamily.replace(/"|'/g, "").split(/\s*,\s*/);
531+
var parts = parseFontFamily(fontFamily);
529532

530533
if (this.fontFaces) {
531534
var fontFaceMap = getFontFaceMap(this.pdf, this.fontFaces);

test/specs/fontfaces.spec.mjs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
resolveFontFace,
33
buildFontFaceMap,
4-
normalizeFontFace
4+
normalizeFontFace,
5+
parseFontFamily
56
} from "../../src/libs/fontFace.js";
67

78
function fontFace(opts) {
@@ -373,4 +374,72 @@ describe("font-face", () => {
373374
});
374375
});
375376
});
377+
378+
describe("font family parser", () => {
379+
const defaultFont = ["times"];
380+
381+
it("should return default font family on empty input", () => {
382+
const result = parseFontFamily(" ");
383+
384+
expect(result).toEqual(defaultFont);
385+
});
386+
387+
it("should return default font family on non-sensical input", () => {
388+
const result = parseFontFamily("@£$∞$§∞|$§∞©£@•$");
389+
390+
expect(result).toEqual(defaultFont);
391+
});
392+
393+
it("should return default font family when font family contains illegal characters", () => {
394+
const invalidStrs = [
395+
"--no-double-hyphen",
396+
"-3no-digit-after-hypen",
397+
"0digits",
398+
"#£no-illegal-characters"
399+
];
400+
401+
const result = invalidStrs.map(parseFontFamily);
402+
403+
expect(result).toEqual(invalidStrs.map(() => defaultFont));
404+
});
405+
406+
// If the user has specified a system font, then it's up to the user-agent to pick one.
407+
it("should return default font if it is a system font", () => {
408+
const systemFonts = [
409+
"caption",
410+
"icon",
411+
"menu",
412+
"message-box",
413+
"small-caption",
414+
"status-bar"
415+
];
416+
417+
const result = systemFonts.map(parseFontFamily);
418+
419+
expect(result).toEqual(systemFonts.map(() => defaultFont));
420+
});
421+
422+
it("should return all font families", () => {
423+
var result = parseFontFamily(
424+
" 'roboto sans' , \"SourceCode Pro\", Co-mP_l3x , arial, sans-serif "
425+
);
426+
427+
expect(result).toEqual([
428+
"roboto sans",
429+
"sourcecode pro",
430+
"co-mp_l3x",
431+
"arial",
432+
"sans-serif"
433+
]);
434+
});
435+
436+
it("should return default on mismatching quotes", () => {
437+
var result = [
438+
parseFontFamily("'I am not closed"),
439+
parseFontFamily('"I am not closed either')
440+
];
441+
442+
expect(result).toEqual([defaultFont, defaultFont]);
443+
});
444+
});
376445
});

0 commit comments

Comments
 (0)