Skip to content
15 changes: 14 additions & 1 deletion src/core/core_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../shared/util.js";
import { Dict, isName, isRefsEqual, Name, Ref, RefSet } from "./primitives.js";
import { BaseStream } from "./base_stream.js";
import { CONTROL_CHAR_REGEXP } from "../shared/css_utils.js";
import { stringToPDFString } from "./string_utils.js";

const PDF_VERSION_REGEXP = /^[1-9]\.\d$/;
Expand Down Expand Up @@ -341,7 +342,8 @@ function lookupNormalRect(arr, fallback) {
* each part of the path.
*/
function parseXFAPath(path) {
const positionPattern = /(.+)\[(\d+)\]$/;
// Anchoring prevents retrying the match at every character.
const positionPattern = /^(.+)\[(\d+)\]$/;
return path.split(".").map(component => {
const m = component.match(positionPattern);
if (m) {
Expand Down Expand Up @@ -560,6 +562,17 @@ function validateFontName(fontFamily, mustWarn = false) {
}
return false;
}
// A <string> is terminated by a newline, which for CSS also includes the
// form feed character; see https://drafts.csswg.org/css-syntax/#newline.
// The font family is escaped before being used, see `serializeFontFamily`,
// hence this only prevents values that cannot sensibly name a font from
// being used at all (the unquoted case below is already this strict).
if (CONTROL_CHAR_REGEXP.test(fontFamily)) {
if (mustWarn) {
warn(`FontFamily contains control characters: ${fontFamily}.`);
}
return false;
}
} else {
// See https://developer.mozilla.org/en-US/docs/Web/CSS/custom-ident.
for (const ident of fontFamily.split(/[ \t]+/)) {
Expand Down
29 changes: 26 additions & 3 deletions src/core/writer.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,31 @@ async function writeArray(array, buffer, transform) {
buffer.push("]");
}

// The exponential notation isn't valid in a PDF, hence a number is always
// written with all its digits.
function numberToPDFString(value) {
// `toFixed` uses the exponential notation from 1e21 on, so such a number is
// written thanks to BigInt: it's necessarily an integer, and `isInteger` also
// rules out NaN and 卤Infinity for which BigInt would throw.
if (Number.isInteger(value) && Math.abs(value) >= 1e21) {
return BigInt(value).toString();
}

// Below that limit `toFixed(10)` never uses the exponential notation (unlike
// `toString` which uses it under 1e-6) and it rounds the value: it always
// adds 10 decimals, hence scan them backwards to remove the trailing zeros,
// and then the dot itself when none of the decimals is left.
const str = value.toFixed(10);
let end = str.length;
while (str[end - 1] === "0") {
end--;
}
if (str[end - 1] === ".") {
end--;
}
return str.slice(0, end);
}

async function writeValue(value, buffer, transform) {
if (value instanceof Name) {
buffer.push(`/${escapePDFName(value.name)}`);
Expand All @@ -165,9 +190,7 @@ async function writeValue(value, buffer, transform) {
// matrices (e.g. [0.000008 0 0 0.000008 0 0]).
// The numbers must be "rounded" only when pdf.js is producing them and the
// current transformation matrix is well known.
// toFixed(10) avoids scientific notation and rounds; the replace removes
// trailing zeros (and a trailing dot for integers).
buffer.push(value.toFixed(10).replace(/\.?0+$/, ""));
buffer.push(numberToPDFString(value));
} else if (typeof value === "boolean") {
buffer.push(value.toString());
} else if (value instanceof Dict) {
Expand Down
8 changes: 6 additions & 2 deletions src/core/xfa/html_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { createValidAbsoluteUrl, warn } from "../../shared/util.js";
import { getMeasurement, stripQuotes } from "./utils.js";
import { selectFont } from "./fonts.js";
import { serializeFontFamily } from "../../shared/css_utils.js";
import { TextMeasure } from "./text.js";
import { XFAObject } from "./xfa_object.js";

Expand Down Expand Up @@ -597,13 +598,16 @@ function setFontFamily(xfaFont, node, fontFinder, style) {
}

const name = stripQuotes(xfaFont.typeface);
style.fontFamily = `"${name}"`;
// Use the same serialization as the `@font-face` rule, resp. the `FontFace`
// instance, that the font is registered with; see `createFontFaceRule` and
// `createNativeFontFace` in `src/display/font_loader.js`.
style.fontFamily = serializeFontFamily(name);

const typeface = fontFinder.find(name);
if (typeface) {
const { fontFamily } = typeface.regular.cssFontInfo;
if (fontFamily !== name) {
style.fontFamily = `"${fontFamily}"`;
style.fontFamily = serializeFontFamily(fontFamily);
}

const para = getCurrentPara(node);
Expand Down
8 changes: 6 additions & 2 deletions src/display/font_loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
warn,
} from "../shared/util.js";
import { makePathFromDrawOPS } from "./display_utils.js";
import { serializeFontFamily } from "../shared/css_utils.js";

class FontLoader {
#systemFonts = new Set();
Expand Down Expand Up @@ -439,7 +440,7 @@ class FontFaceObject {
css.style = `oblique ${this.cssFontInfo.italicAngle}deg`;
}
nativeFontFace = new FontFace(
this.cssFontInfo.fontFamily,
serializeFontFamily(this.cssFontInfo.fontFamily),
this.data,
css
);
Expand All @@ -463,7 +464,10 @@ class FontFaceObject {
if (this.cssFontInfo.italicAngle) {
css += `font-style: oblique ${this.cssFontInfo.italicAngle}deg;`;
}
rule = `@font-face {font-family:"${this.cssFontInfo.fontFamily}";${css}src:${url}}`;
// The font family originates from the PDF document, hence it must be
// serialized as a <string> to prevent arbitrary rule injection.
const fontFamily = serializeFontFamily(this.cssFontInfo.fontFamily);
rule = `@font-face {font-family:${fontFamily};${css}src:${url}}`;
}

this._inspectFont?.(this, url);
Expand Down
76 changes: 76 additions & 0 deletions src/shared/css_utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

const CONTROL_CHAR_REGEXP = /\p{Cc}/u;

/**
* Checks if the given value already is a well-formed CSS <string>, i.e. a
* value which can be used verbatim since it cannot introduce delimiters.
* See https://drafts.csswg.org/css-syntax/#string-token-diagram.
* @param {string} str
* @returns {boolean}
*/
function isCSSString(str) {
const quote = str[0];
if (
str.length < 2 ||
(quote !== `"` && quote !== `'`) ||
str.at(-1) !== quote
) {
return false;
}
const end = str.length - 1;
for (let i = 1; i < end; i++) {
const char = str[i];
if (char === quote || CONTROL_CHAR_REGEXP.test(char)) {
return false;
}
if (char === "\\") {
// Skip the escaped character. A trailing backslash would instead escape
// the closing quote, and control characters must not occur in a CSS
// <string> even when escaped this way.
if (++i >= end || CONTROL_CHAR_REGEXP.test(str[i])) {
return false;
}
}
}
return true;
}

/**
* Serializes a font family, originating from the PDF document, such that it
* can safely be interpolated into CSS.
* @param {string} fontFamily
* @returns {string}
*/
function serializeFontFamily(fontFamily) {
if (isCSSString(fontFamily)) {
return fontFamily;
}
// Always emit a <string>, rather than a <custom-ident> sequence, since both
// denote the same family name but only the former cannot be mistaken for a
// generic family (e.g. `serif`) or a CSS-wide keyword (e.g. `inherit`);
// those are not valid font family names and would be ignored.
// Control characters use hexadecimal escapes, since CSS line terminators
// cannot be escaped by simply prefixing them with a backslash.
const escaped = fontFamily.replaceAll(/["\\\p{Cc}]/gu, char =>
char === `"` || char === "\\"
? `\\${char}`
: `\\${char.codePointAt(0).toString(16)} `
);
return `"${escaped}"`;
}

export { CONTROL_CHAR_REGEXP, serializeFontFamily };
20 changes: 20 additions & 0 deletions test/unit/autolinker_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,24 @@ describe("autolinker", function () {
["john.doe@uni-cityname.tld", "mailto:john.doe@uni-cityname.tld"],
]);
});

it("should find emails with the longest parts allowed by the RFCs", function () {
const local = "a".repeat(64);
const label = "b".repeat(63);
testLinks([[`${local}@${label}.com`, `mailto:${local}@${label}.com`]]);
});

it("shouldn't find emails with parts longer than allowed by the RFCs", function () {
expect(
Autolinker.findLinks(`${"a".repeat(107)}@${"a".repeat(80)}.com`)
).toEqual([]);
});

it("should handle a long run of characters before an @ efficiently", function () {
const text = `${"a".repeat(50000)}@`;

const startTime = performance.now();
expect(Autolinker.findLinks(text)).toEqual([]);
expect(performance.now() - startTime).toBeLessThan(1000);
});
});
1 change: 1 addition & 0 deletions test/unit/clitests.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"evaluator_spec.js",
"event_utils_spec.js",
"fetch_stream_spec.js",
"font_loader_spec.js",
"font_substitutions_spec.js",
"fonts_spec.js",
"image_utils_spec.js",
Expand Down
50 changes: 50 additions & 0 deletions test/unit/core_utils_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,32 @@ describe("core_utils", function () {
{ name: "BAR", pos: 456 },
]);
});

it("should ignore a malformed position", function () {
expect(parseXFAPath("foo[].bar[1x].oof[].[3]")).toEqual([
{ name: "foo[]", pos: 0 },
{ name: "bar[1x]", pos: 0 },
{ name: "oof[]", pos: 0 },
{ name: "[3]", pos: 0 },
]);
});

it("should keep the longest name when a component has several brackets", function () {
expect(parseXFAPath("foo[1][2]")).toEqual([{ name: "foo[1]", pos: 2 }]);
});

it("should handle a long component efficiently", function () {
// Looking for the position with a leading `.+` is quadratic in the
// length of a component which doesn't end with one.
const name = "a".repeat(200000);

const startTime = performance.now();
const parsedPath = parseXFAPath(name);
const duration = performance.now() - startTime;

expect(parsedPath).toEqual([{ name, pos: 0 }]);
expect(duration).toBeLessThan(1000);
});
});

describe("recoverJsURL", function () {
Expand Down Expand Up @@ -414,6 +440,30 @@ describe("core_utils", function () {
expect(validateCSSFont(cssFontInfo)).toBeFalse();
});

it("Check font family containing control characters", function () {
const cssFontInfo = {
fontFamily: "",
fontWeight: 0,
italicAngle: 0,
};

// A form feed is a newline in CSS, hence it terminates the <string>.
cssFontInfo.fontFamily = `"blah\fblah"`;
expect(validateCSSFont(cssFontInfo)).toBeFalse();

cssFontInfo.fontFamily = `"blah\x00blah"`;
expect(validateCSSFont(cssFontInfo)).toBeFalse();

cssFontInfo.fontFamily = `"blah\tblah"`;
expect(validateCSSFont(cssFontInfo)).toBeFalse();

cssFontInfo.fontFamily = `"blah\nblah"`;
expect(validateCSSFont(cssFontInfo)).toBeFalse();

cssFontInfo.fontFamily = `"blah blah"`;
expect(validateCSSFont(cssFontInfo)).toBeTrue();
});

it("Check font weight", function () {
const cssFontInfo = {
fontFamily: "blah",
Expand Down
Loading
Loading