pdf.js.mirror/src/display/display_utils.js
Jonas Jenwald e3a7c0779d Fix the broken regular expression in the decode helper in the getPdfFilenameFromUrl function (issue 20664)
This will ignore filenames that become effectively empty, i.e. ones that are only ".pdf" and nothing more.

*Please note:* While this passes all existing unit-tests, I don't know if this is necessarily the "correct" solution here.
2026-02-27 15:19:43 +01:00

1482 lines
41 KiB
JavaScript

/* Copyright 2015 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.
*/
import {
BaseException,
DrawOPS,
FeatureTest,
MathClamp,
shadow,
stripPath,
Util,
warn,
} from "../shared/util.js";
import { XfaLayer } from "./xfa_layer.js";
const SVG_NS = "http://www.w3.org/2000/svg";
class PixelsPerInch {
static CSS = 96.0;
static PDF = 72.0;
static PDF_TO_CSS_UNITS = this.CSS / this.PDF;
}
async function fetchData(url, type = "text") {
if (
(typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) ||
isValidFetchUrl(url, document.baseURI)
) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
switch (type) {
case "blob":
return response.blob();
case "bytes":
return response.bytes();
case "json":
return response.json();
}
return response.text();
}
// The Fetch API is not supported.
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("GET", url, /* async = */ true);
request.responseType = type === "bytes" ? "arraybuffer" : type;
request.onreadystatechange = () => {
if (request.readyState !== XMLHttpRequest.DONE) {
return;
}
if (request.status === 200 || request.status === 0) {
switch (type) {
case "bytes":
resolve(new Uint8Array(request.response));
return;
case "blob":
case "json":
resolve(request.response);
return;
}
resolve(request.responseText);
return;
}
reject(new Error(request.statusText));
};
request.send(null);
});
}
/**
* @typedef {Object} PageViewportParameters
* @property {Array<number>} viewBox - The xMin, yMin, xMax and
* yMax coordinates.
* @property {number} userUnit - The size of units.
* @property {number} scale - The scale of the viewport.
* @property {number} rotation - The rotation, in degrees, of the viewport.
* @property {number} [offsetX] - The horizontal, i.e. x-axis, offset. The
* default value is `0`.
* @property {number} [offsetY] - The vertical, i.e. y-axis, offset. The
* default value is `0`.
* @property {boolean} [dontFlip] - If true, the y-axis will not be flipped.
* The default value is `false`.
*/
/**
* @typedef {Object} PageViewportCloneParameters
* @property {number} [scale] - The scale, overriding the one in the cloned
* viewport. The default value is `this.scale`.
* @property {number} [rotation] - The rotation, in degrees, overriding the one
* in the cloned viewport. The default value is `this.rotation`.
* @property {number} [offsetX] - The horizontal, i.e. x-axis, offset.
* The default value is `this.offsetX`.
* @property {number} [offsetY] - The vertical, i.e. y-axis, offset.
* The default value is `this.offsetY`.
* @property {boolean} [dontFlip] - If true, the x-axis will not be flipped.
* The default value is `false`.
*/
/**
* PDF page viewport created based on scale, rotation and offset.
*/
class PageViewport {
/**
* @param {PageViewportParameters}
*/
constructor({
viewBox,
userUnit,
scale,
rotation,
offsetX = 0,
offsetY = 0,
dontFlip = false,
}) {
this.viewBox = viewBox;
this.userUnit = userUnit;
this.scale = scale;
this.rotation = rotation;
this.offsetX = offsetX;
this.offsetY = offsetY;
scale *= userUnit; // Take the userUnit into account.
// creating transform to convert pdf coordinate system to the normal
// canvas like coordinates taking in account scale and rotation
const centerX = (viewBox[2] + viewBox[0]) / 2;
const centerY = (viewBox[3] + viewBox[1]) / 2;
let rotateA, rotateB, rotateC, rotateD;
// Normalize the rotation, by clamping it to the [0, 360) range.
rotation %= 360;
if (rotation < 0) {
rotation += 360;
}
switch (rotation) {
case 180:
rotateA = -1;
rotateB = 0;
rotateC = 0;
rotateD = 1;
break;
case 90:
rotateA = 0;
rotateB = 1;
rotateC = 1;
rotateD = 0;
break;
case 270:
rotateA = 0;
rotateB = -1;
rotateC = -1;
rotateD = 0;
break;
case 0:
rotateA = 1;
rotateB = 0;
rotateC = 0;
rotateD = -1;
break;
default:
throw new Error(
"PageViewport: Invalid rotation, must be a multiple of 90 degrees."
);
}
if (dontFlip) {
rotateC = -rotateC;
rotateD = -rotateD;
}
let offsetCanvasX, offsetCanvasY;
let width, height;
if (rotateA === 0) {
offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX;
offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY;
width = (viewBox[3] - viewBox[1]) * scale;
height = (viewBox[2] - viewBox[0]) * scale;
} else {
offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX;
offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY;
width = (viewBox[2] - viewBox[0]) * scale;
height = (viewBox[3] - viewBox[1]) * scale;
}
// creating transform for the following operations:
// translate(-centerX, -centerY), rotate and flip vertically,
// scale, and translate(offsetCanvasX, offsetCanvasY)
this.transform = [
rotateA * scale,
rotateB * scale,
rotateC * scale,
rotateD * scale,
offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY,
offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY,
];
this.width = width;
this.height = height;
}
/**
* The original, un-scaled, viewport dimensions.
* @type {Object}
*/
get rawDims() {
const dims = this.viewBox;
return shadow(this, "rawDims", {
pageWidth: dims[2] - dims[0],
pageHeight: dims[3] - dims[1],
pageX: dims[0],
pageY: dims[1],
});
}
/**
* Clones viewport, with optional additional properties.
* @param {PageViewportCloneParameters} [params]
* @returns {PageViewport} Cloned viewport.
*/
clone({
scale = this.scale,
rotation = this.rotation,
offsetX = this.offsetX,
offsetY = this.offsetY,
dontFlip = false,
} = {}) {
return new PageViewport({
viewBox: this.viewBox.slice(),
userUnit: this.userUnit,
scale,
rotation,
offsetX,
offsetY,
dontFlip,
});
}
/**
* Converts PDF point to the viewport coordinates. For examples, useful for
* converting PDF location into canvas pixel coordinates.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @returns {Array} Array containing `x`- and `y`-coordinates of the
* point in the viewport coordinate space.
* @see {@link convertToPdfPoint}
* @see {@link convertToViewportRectangle}
*/
convertToViewportPoint(x, y) {
const p = [x, y];
Util.applyTransform(p, this.transform);
return p;
}
/**
* Converts PDF rectangle to the viewport coordinates.
* @param {Array} rect - The xMin, yMin, xMax and yMax coordinates.
* @returns {Array} Array containing corresponding coordinates of the
* rectangle in the viewport coordinate space.
* @see {@link convertToViewportPoint}
*/
convertToViewportRectangle(rect) {
const topLeft = [rect[0], rect[1]];
Util.applyTransform(topLeft, this.transform);
const bottomRight = [rect[2], rect[3]];
Util.applyTransform(bottomRight, this.transform);
return [topLeft[0], topLeft[1], bottomRight[0], bottomRight[1]];
}
/**
* Converts viewport coordinates to the PDF location. For examples, useful
* for converting canvas pixel location into PDF one.
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @returns {Array} Array containing `x`- and `y`-coordinates of the
* point in the PDF coordinate space.
* @see {@link convertToViewportPoint}
*/
convertToPdfPoint(x, y) {
const p = [x, y];
Util.applyInverseTransform(p, this.transform);
return p;
}
}
class RenderingCancelledException extends BaseException {
constructor(msg, extraDelay = 0) {
super(msg, "RenderingCancelledException");
this.extraDelay = extraDelay;
}
}
function isDataScheme(url) {
const ii = url.length;
let i = 0;
while (i < ii && url[i].trim() === "") {
i++;
}
return url.substring(i, i + 5).toLowerCase() === "data:";
}
function isPdfFile(filename) {
return typeof filename === "string" && /\.pdf$/i.test(filename);
}
/**
* Gets the filename from a given URL.
* @param {string} url
* @returns {string}
*/
function getFilenameFromUrl(url) {
[url] = url.split(/[#?]/, 1);
return stripPath(url);
}
/**
* Returns the filename or guessed filename from the url (see issue 3455).
* @param {string} url - The original PDF location.
* @param {string} defaultFilename - The value returned if the filename is
* unknown, or the protocol is unsupported.
* @returns {string} Guessed PDF filename.
*/
function getPdfFilenameFromUrl(url, defaultFilename = "document.pdf") {
if (typeof url !== "string") {
return defaultFilename;
}
if (isDataScheme(url)) {
warn('getPdfFilenameFromUrl: ignore "data:"-URL for performance reasons.');
return defaultFilename;
}
const getURL = urlString => {
try {
return new URL(urlString);
} catch {
try {
return new URL(decodeURIComponent(urlString));
} catch {
try {
// Attempt to parse the URL using the document's base URI.
return new URL(urlString, "https://foo.bar");
} catch {
try {
return new URL(decodeURIComponent(urlString), "https://foo.bar");
} catch {
return null;
}
}
}
}
};
const newURL = getURL(url);
if (!newURL) {
// If the URL is invalid, return the default filename.
return defaultFilename;
}
const decode = name => {
try {
let decoded = decodeURIComponent(name);
if (decoded.includes("/")) {
decoded = stripPath(decoded);
if (/^\.pdf$/i.test(decoded)) {
return name;
}
}
return decoded;
} catch {
return name;
}
};
const pdfRegex = /\.pdf$/i;
const filename = stripPath(newURL.pathname);
if (pdfRegex.test(filename)) {
return decode(filename);
}
if (newURL.searchParams.size > 0) {
const getLast = iterator => [...iterator].findLast(v => pdfRegex.test(v));
// If any of the search parameters ends with ".pdf", return it.
const name =
getLast(newURL.searchParams.values()) ??
getLast(newURL.searchParams.keys());
if (name) {
return decode(name);
}
}
if (newURL.hash) {
const reFilename = /[^/?#=]+\.pdf\b(?!.*\.pdf\b)/i;
const hashFilename = reFilename.exec(newURL.hash);
if (hashFilename) {
return decode(hashFilename[0]);
}
}
return defaultFilename;
}
class StatTimer {
started = Object.create(null);
times = [];
time(name) {
if (name in this.started) {
warn(`Timer is already running for ${name}`);
}
this.started[name] = Date.now();
}
timeEnd(name) {
if (!(name in this.started)) {
warn(`Timer has not been started for ${name}`);
}
this.times.push({
name,
start: this.started[name],
end: Date.now(),
});
// Remove timer from started so it can be called again.
delete this.started[name];
}
toString() {
// Find the longest name for padding purposes.
const outBuf = [];
let longest = 0;
for (const { name } of this.times) {
longest = Math.max(name.length, longest);
}
for (const { name, start, end } of this.times) {
outBuf.push(`${name.padEnd(longest)} ${end - start}ms\n`);
}
return outBuf.join("");
}
}
function isValidFetchUrl(url, baseUrl) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
throw new Error("Not implemented: isValidFetchUrl");
}
const res = baseUrl ? URL.parse(url, baseUrl) : URL.parse(url);
// The Fetch API only supports the http/https protocols, and not file/ftp.
return /https?:/.test(res?.protocol ?? "");
}
/**
* Event handler to suppress context menu.
*/
function noContextMenu(e) {
e.preventDefault();
}
function stopEvent(e) {
e.preventDefault();
e.stopPropagation();
}
// Deprecated API function -- display regardless of the `verbosity` setting.
function deprecated(details) {
// eslint-disable-next-line no-console
console.log("Deprecated API usage: " + details);
}
class PDFDateString {
static #regex;
/**
* Convert a PDF date string to a JavaScript `Date` object.
*
* The PDF date string format is described in section 7.9.4 of the official
* PDF 32000-1:2008 specification. However, in the PDF 1.7 reference (sixth
* edition) Adobe describes the same format including a trailing apostrophe.
* This syntax in incorrect, but Adobe Acrobat creates PDF files that contain
* them. We ignore all apostrophes as they are not necessary for date parsing.
*
* Moreover, Adobe Acrobat doesn't handle changing the date to universal time
* and doesn't use the user's time zone (effectively ignoring the HH' and mm'
* parts of the date string).
*
* @param {string} input
* @returns {Date|null}
*/
static toDateObject(input) {
if (input instanceof Date) {
return input;
}
if (!input || typeof input !== "string") {
return null;
}
// Lazily initialize the regular expression.
this.#regex ||= new RegExp(
"^D:" + // Prefix (required)
"(\\d{4})" + // Year (required)
"(\\d{2})?" + // Month (optional)
"(\\d{2})?" + // Day (optional)
"(\\d{2})?" + // Hour (optional)
"(\\d{2})?" + // Minute (optional)
"(\\d{2})?" + // Second (optional)
"([Z|+|-])?" + // Universal time relation (optional)
"(\\d{2})?" + // Offset hour (optional)
"'?" + // Splitting apostrophe (optional)
"(\\d{2})?" + // Offset minute (optional)
"'?" // Trailing apostrophe (optional)
);
// Optional fields that don't satisfy the requirements from the regular
// expression (such as incorrect digit counts or numbers that are out of
// range) will fall back the defaults from the specification.
const matches = this.#regex.exec(input);
if (!matches) {
return null;
}
// JavaScript's `Date` object expects the month to be between 0 and 11
// instead of 1 and 12, so we have to correct for that.
const year = parseInt(matches[1], 10);
let month = parseInt(matches[2], 10);
month = month >= 1 && month <= 12 ? month - 1 : 0;
let day = parseInt(matches[3], 10);
day = day >= 1 && day <= 31 ? day : 1;
let hour = parseInt(matches[4], 10);
hour = hour >= 0 && hour <= 23 ? hour : 0;
let minute = parseInt(matches[5], 10);
minute = minute >= 0 && minute <= 59 ? minute : 0;
let second = parseInt(matches[6], 10);
second = second >= 0 && second <= 59 ? second : 0;
const universalTimeRelation = matches[7] || "Z";
let offsetHour = parseInt(matches[8], 10);
offsetHour = offsetHour >= 0 && offsetHour <= 23 ? offsetHour : 0;
let offsetMinute = parseInt(matches[9], 10) || 0;
offsetMinute = offsetMinute >= 0 && offsetMinute <= 59 ? offsetMinute : 0;
// Universal time relation 'Z' means that the local time is equal to the
// universal time, whereas the relations '+'/'-' indicate that the local
// time is later respectively earlier than the universal time. Every date
// is normalized to universal time.
if (universalTimeRelation === "-") {
hour += offsetHour;
minute += offsetMinute;
} else if (universalTimeRelation === "+") {
hour -= offsetHour;
minute -= offsetMinute;
}
return new Date(Date.UTC(year, month, day, hour, minute, second));
}
}
/**
* NOTE: This is (mostly) intended to support printing of XFA forms.
*/
function getXfaPageViewport(xfaPage, { scale = 1, rotation = 0 }) {
const { width, height } = xfaPage.attributes.style;
const viewBox = [0, 0, parseInt(width), parseInt(height)];
return new PageViewport({
viewBox,
userUnit: 1,
scale,
rotation,
});
}
function getRGB(color) {
if (color.startsWith("#")) {
const colorRGB = parseInt(color.slice(1), 16);
return [
(colorRGB & 0xff0000) >> 16,
(colorRGB & 0x00ff00) >> 8,
colorRGB & 0x0000ff,
];
}
if (color.startsWith("rgb(")) {
// getComputedStyle(...).color returns a `rgb(R, G, B)` color.
return color
.slice(/* "rgb(".length */ 4, -1) // Strip out "rgb(" and ")".
.split(",")
.map(x => parseInt(x));
}
if (color.startsWith("rgba(")) {
return color
.slice(/* "rgba(".length */ 5, -1) // Strip out "rgba(" and ")".
.split(",")
.map(x => parseInt(x))
.slice(0, 3);
}
warn(`Not a valid color format: "${color}"`);
return [0, 0, 0];
}
function getColorValues(colors) {
const span = document.createElement("span");
span.style.visibility = "hidden";
// NOTE: The following does *not* affect `forced-colors: active` mode.
span.style.colorScheme = "only light";
document.body.append(span);
for (const name of colors.keys()) {
span.style.color = name;
const computedColor = window.getComputedStyle(span).color;
colors.set(name, getRGB(computedColor));
}
span.remove();
}
function getCurrentTransform(ctx) {
const { a, b, c, d, e, f } = ctx.getTransform();
return [a, b, c, d, e, f];
}
function getCurrentTransformInverse(ctx) {
const { a, b, c, d, e, f } = ctx.getTransform().invertSelf();
return [a, b, c, d, e, f];
}
/**
* @param {HTMLDivElement} div
* @param {PageViewport} viewport
* @param {boolean} mustFlip
* @param {boolean} mustRotate
*/
function setLayerDimensions(
div,
viewport,
mustFlip = false,
mustRotate = true
) {
if (viewport instanceof PageViewport) {
const { pageWidth, pageHeight } = viewport.rawDims;
const { style } = div;
const useRound = FeatureTest.isCSSRoundSupported;
const w = `var(--total-scale-factor) * ${pageWidth}px`,
h = `var(--total-scale-factor) * ${pageHeight}px`;
const widthStr = useRound
? `round(down, ${w}, var(--scale-round-x))`
: `calc(${w})`,
heightStr = useRound
? `round(down, ${h}, var(--scale-round-y))`
: `calc(${h})`;
if (!mustFlip || viewport.rotation % 180 === 0) {
style.width = widthStr;
style.height = heightStr;
} else {
style.width = heightStr;
style.height = widthStr;
}
}
if (mustRotate) {
div.setAttribute("data-main-rotation", viewport.rotation);
}
}
/**
* Scale factors for the canvas, necessary with HiDPI displays.
*/
class OutputScale {
constructor() {
const { pixelRatio } = OutputScale;
/**
* @type {number} Horizontal scale.
*/
this.sx = pixelRatio;
/**
* @type {number} Vertical scale.
*/
this.sy = pixelRatio;
}
/**
* @type {boolean} Returns `true` when scaling is required, `false` otherwise.
*/
get scaled() {
return this.sx !== 1 || this.sy !== 1;
}
/**
* @type {boolean} Returns `true` when scaling is symmetric,
* `false` otherwise.
*/
get symmetric() {
return this.sx === this.sy;
}
/**
* @returns {boolean} Returns `true` if scaling was limited,
* `false` otherwise.
*/
limitCanvas(width, height, maxPixels, maxDim, capAreaFactor = -1) {
let maxAreaScale = Infinity,
maxWidthScale = Infinity,
maxHeightScale = Infinity;
maxPixels = OutputScale.capPixels(maxPixels, capAreaFactor);
if (maxPixels > 0) {
maxAreaScale = Math.sqrt(maxPixels / (width * height));
}
if (maxDim !== -1) {
maxWidthScale = maxDim / width;
maxHeightScale = maxDim / height;
}
const maxScale = Math.min(maxAreaScale, maxWidthScale, maxHeightScale);
if (this.sx > maxScale || this.sy > maxScale) {
this.sx = maxScale;
this.sy = maxScale;
return true;
}
return false;
}
static get pixelRatio() {
return globalThis.devicePixelRatio || 1;
}
static capPixels(maxPixels, capAreaFactor) {
if (capAreaFactor >= 0) {
const winPixels = Math.ceil(
(typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")
? window.innerWidth * window.innerHeight
: window.screen.availWidth * window.screen.availHeight) *
this.pixelRatio ** 2 *
(1 + capAreaFactor / 100)
);
return maxPixels > 0 ? Math.min(maxPixels, winPixels) : winPixels;
}
return maxPixels;
}
}
// See https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
// to know which types are supported by the browser.
const SupportedImageMimeTypes = [
"image/apng",
"image/avif",
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/svg+xml",
"image/webp",
"image/x-icon",
];
class ColorScheme {
static get isDarkMode() {
return shadow(
this,
"isDarkMode",
!!window?.matchMedia?.("(prefers-color-scheme: dark)").matches
);
}
}
class CSSConstants {
static get commentForegroundColor() {
const element = document.createElement("span");
element.classList.add("comment", "sidebar");
const { style } = element;
style.width = style.height = "0";
style.display = "none";
style.color = "var(--comment-fg-color)";
document.body.append(element);
const { color } = window.getComputedStyle(element);
element.remove();
return shadow(this, "commentForegroundColor", getRGB(color));
}
}
function applyOpacity(r, g, b, opacity) {
opacity = MathClamp(opacity ?? 1, 0, 1);
const white = 255 * (1 - opacity);
r = Math.round(r * opacity + white);
g = Math.round(g * opacity + white);
b = Math.round(b * opacity + white);
return [r, g, b];
}
function RGBToHSL(rgb, output) {
const r = rgb[0] / 255;
const g = rgb[1] / 255;
const b = rgb[2] / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) {
// achromatic
output[0] = output[1] = 0; // hue and saturation are 0
} else {
const d = max - min;
output[1] = l < 0.5 ? d / (max + min) : d / (2 - max - min);
// hue
switch (max) {
case r:
output[0] = ((g - b) / d + (g < b ? 6 : 0)) * 60;
break;
case g:
output[0] = ((b - r) / d + 2) * 60;
break;
case b:
output[0] = ((r - g) / d + 4) * 60;
break;
}
}
output[2] = l;
}
function HSLToRGB(hsl, output) {
const h = hsl[0];
const s = hsl[1];
const l = hsl[2];
const c = (1 - Math.abs(2 * l - 1)) * s; // chroma
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
switch (Math.floor(h / 60)) {
case 0:
output[0] = c + m;
output[1] = x + m;
output[2] = m;
break;
case 1:
output[0] = x + m;
output[1] = c + m;
output[2] = m;
break;
case 2:
output[0] = m;
output[1] = c + m;
output[2] = x + m;
break;
case 3:
output[0] = m;
output[1] = x + m;
output[2] = c + m;
break;
case 4:
output[0] = x + m;
output[1] = m;
output[2] = c + m;
break;
case 5:
case 6:
output[0] = c + m;
output[1] = m;
output[2] = x + m;
break;
}
}
function computeLuminance(x) {
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
}
function contrastRatio(hsl1, hsl2, output) {
HSLToRGB(hsl1, output);
output.map(computeLuminance);
const lum1 = 0.2126 * output[0] + 0.7152 * output[1] + 0.0722 * output[2];
HSLToRGB(hsl2, output);
output.map(computeLuminance);
const lum2 = 0.2126 * output[0] + 0.7152 * output[1] + 0.0722 * output[2];
return lum1 > lum2
? (lum1 + 0.05) / (lum2 + 0.05)
: (lum2 + 0.05) / (lum1 + 0.05);
}
// Cache for the findContrastColor function, to improve performance.
const contrastCache = new Map();
/**
* Find a color that has sufficient contrast against a fixed color.
* The luminance (in HSL color space) of the base color is adjusted
* until the contrast ratio between the base color and the fixed color
* is at least the minimum contrast ratio required by WCAG 2.1.
* @param {Array<number>} baseColor
* @param {Array<number>} fixedColor
* @returns {string}
*/
function findContrastColor(baseColor, fixedColor) {
const key =
baseColor[0] +
baseColor[1] * 0x100 +
baseColor[2] * 0x10000 +
fixedColor[0] * 0x1000000 +
fixedColor[1] * 0x100000000 +
fixedColor[2] * 0x10000000000;
let cachedValue = contrastCache.get(key);
if (cachedValue) {
return cachedValue;
}
const array = new Float32Array(9);
const output = array.subarray(0, 3);
const baseHSL = array.subarray(3, 6);
RGBToHSL(baseColor, baseHSL);
const fixedHSL = array.subarray(6, 9);
RGBToHSL(fixedColor, fixedHSL);
const isFixedColorDark = fixedHSL[2] < 0.5;
// Use the contrast ratio requirements from WCAG 2.1.
// https://www.w3.org/TR/WCAG21/#contrast-minimum
// https://www.w3.org/TR/WCAG21/#contrast-enhanced
const minContrast = isFixedColorDark ? 12 : 4.5;
baseHSL[2] = isFixedColorDark
? Math.sqrt(baseHSL[2])
: 1 - Math.sqrt(1 - baseHSL[2]);
if (contrastRatio(baseHSL, fixedHSL, output) < minContrast) {
let start, end;
if (isFixedColorDark) {
start = baseHSL[2];
end = 1;
} else {
start = 0;
end = baseHSL[2];
}
const PRECISION = 0.005;
while (end - start > PRECISION) {
const mid = (baseHSL[2] = (start + end) / 2);
if (
isFixedColorDark ===
contrastRatio(baseHSL, fixedHSL, output) < minContrast
) {
start = mid;
} else {
end = mid;
}
}
baseHSL[2] = isFixedColorDark ? end : start;
}
HSLToRGB(baseHSL, output);
cachedValue = Util.makeHexColor(
Math.round(output[0] * 255),
Math.round(output[1] * 255),
Math.round(output[2] * 255)
);
contrastCache.set(key, cachedValue);
return cachedValue;
}
function renderRichText({ html, dir, className }, container) {
const fragment = document.createDocumentFragment();
if (typeof html === "string") {
const p = document.createElement("p");
p.dir = dir || "auto";
const lines = html.split(/(?:\r\n?|\n)/);
for (let i = 0, ii = lines.length; i < ii; ++i) {
const line = lines[i];
p.append(document.createTextNode(line));
if (i < ii - 1) {
p.append(document.createElement("br"));
}
}
fragment.append(p);
} else {
XfaLayer.render({
xfaHtml: html,
div: fragment,
intent: "richText",
});
}
fragment.firstElementChild.classList.add("richText", className);
container.append(fragment);
}
function makePathFromDrawOPS(data) {
// Using a SVG string is slightly slower than using the following loop.
const path = new Path2D();
if (!data) {
return path;
}
for (let i = 0, ii = data.length; i < ii; ) {
switch (data[i++]) {
case DrawOPS.moveTo:
path.moveTo(data[i++], data[i++]);
break;
case DrawOPS.lineTo:
path.lineTo(data[i++], data[i++]);
break;
case DrawOPS.curveTo:
path.bezierCurveTo(
data[i++],
data[i++],
data[i++],
data[i++],
data[i++],
data[i++]
);
break;
case DrawOPS.quadraticCurveTo:
path.quadraticCurveTo(data[i++], data[i++], data[i++], data[i++]);
break;
case DrawOPS.closePath:
path.closePath();
break;
default:
warn(`Unrecognized drawing path operator: ${data[i - 1]}`);
break;
}
}
return path;
}
/**
* Maps between page IDs and page numbers, allowing bidirectional conversion
* between the two representations. This is useful when the page numbering
* in the PDF document doesn't match the default sequential ordering.
*/
class PagesMapper {
/**
* Maps page IDs to their corresponding page numbers.
* @type {Map<number, Array<number>>|null}
*/
#idToPageNumber = null;
/**
* Maps page numbers to their corresponding page IDs.
* @type {Uint32Array|null}
*/
#pageNumberToId = null;
/**
* Previous mapping of page IDs to page numbers.
* @type {Int32Array|null}
*/
#prevPageNumbers = null;
/**
* The total number of pages.
* @type {number}
*/
#pagesNumber = 0;
/**
* Listeners for page changes.
* @type {Array<function>}
*/
#listeners = [];
/**
* Maps page numbers to their corresponding page IDs (used in copy
* operations).
* @type {Uint32Array|null}
*/
#copiedPageIds = null;
/**
* Maps page IDs to their corresponding page numbers, used in copy operations.
* @type {Uint32Array|null}
*/
#copiedPageNumbers = null;
/**
* Gets the total number of pages.
* @returns {number} The number of pages.
*/
get pagesNumber() {
return this.#pagesNumber;
}
/**
* Sets the total number of pages and initializes default mappings
* where page IDs equal page numbers (1-indexed).
* @param {number} n - The total number of pages.
*/
set pagesNumber(n) {
if (this.#pagesNumber === n) {
return;
}
this.#pagesNumber = n;
this.#reset();
}
/**
* Resets the page mappings to their default state, where page IDs equal page
* numbers (1-indexed). This is called when the number of pages changes, or
* when the current mapping matches the default mapping after a move
* operation.
*/
#reset() {
this.#pageNumberToId = null;
this.#idToPageNumber = null;
}
/**
* Adds a listener function that will be called whenever the page mappings
* are updated.
* @param {function} listener
*/
addListener(listener) {
this.#listeners.push(listener);
}
/**
* Removes a previously added listener function.
* @param {function} listener
*/
removeListener(listener) {
const index = this.#listeners.indexOf(listener);
if (index >= 0) {
this.#listeners.splice(index, 1);
}
}
/**
* Calls all registered listener functions to notify them of changes to the
* page mappings.
* @param {Object} data - An object containing information about the update.
*/
#updateListeners(data) {
for (const listener of this.#listeners) {
listener(data);
}
}
/**
* Initializes the page mappings if they haven't been initialized yet.
* @param {boolean} mustInit
*/
#init(mustInit) {
if (this.#pageNumberToId) {
return;
}
const n = this.#pagesNumber;
const pageNumberToId = (this.#pageNumberToId = new Uint32Array(n));
this.#prevPageNumbers = new Int32Array(pageNumberToId);
const idToPageNumber = (this.#idToPageNumber = new Map());
if (mustInit) {
for (let i = 1; i <= n; i++) {
pageNumberToId[i - 1] = i;
idToPageNumber.set(i, [i]);
}
}
}
/**
* Updates the mapping from page IDs to page numbers based on the current
* mapping from page numbers to page IDs. This should be called after any
* changes to the page-number-to-ID mapping to keep the two mappings in sync.
*/
#updateIdToPageNumber() {
const idToPageNumber = this.#idToPageNumber;
const pageNumberToId = this.#pageNumberToId;
idToPageNumber.clear();
for (let i = 0, ii = this.#pagesNumber; i < ii; i++) {
const id = pageNumberToId[i];
const pageNumbers = idToPageNumber.get(id);
if (pageNumbers) {
pageNumbers.push(i + 1);
} else {
idToPageNumber.set(id, [i + 1]);
}
}
}
/**
* Move a set of pages to a new position while keeping ID→number mappings in
* sync.
*
* @param {Set<number>} selectedPages - Page numbers being moved (1-indexed).
* @param {number[]} pagesToMove - Ordered list of page numbers to move.
* @param {number} index - Zero-based insertion index in the page-number list.
*/
movePages(selectedPages, pagesToMove, index) {
this.#init(true);
const pageNumberToId = this.#pageNumberToId;
const idToPageNumber = this.#idToPageNumber;
const movedCount = pagesToMove.length;
const mappedPagesToMove = new Uint32Array(movedCount);
let removedBeforeTarget = 0;
for (let i = 0; i < movedCount; i++) {
const pageIndex = pagesToMove[i] - 1;
mappedPagesToMove[i] = pageNumberToId[pageIndex];
if (pageIndex < index) {
removedBeforeTarget += 1;
}
}
const pagesNumber = this.#pagesNumber;
// target index after removing elements that were before it
let adjustedTarget = index - removedBeforeTarget;
const remainingLen = pagesNumber - movedCount;
adjustedTarget = MathClamp(adjustedTarget, 0, remainingLen);
// Create the new mapping.
// First copy over the pages that are not being moved.
// Then insert the moved pages at the target position.
for (let i = 0, r = 0; i < pagesNumber; i++) {
if (!selectedPages.has(i + 1)) {
pageNumberToId[r++] = pageNumberToId[i];
}
}
// Shift the pages after the target position.
pageNumberToId.copyWithin(
adjustedTarget + movedCount,
adjustedTarget,
remainingLen
);
// Finally insert the moved pages.
pageNumberToId.set(mappedPagesToMove, adjustedTarget);
this.#setPrevPageNumbers(idToPageNumber, null);
this.#updateIdToPageNumber();
this.#updateListeners({ type: "move" });
if (pageNumberToId.every((id, i) => id === i + 1)) {
this.#reset();
}
}
/**
* Deletes a set of pages while keeping ID→number mappings in sync.
* @param {Array<number>} pagesToDelete - Page numbers to delete (1-indexed).
* These must be unique and sorted in ascending order.
*/
deletePages(pagesToDelete) {
this.#init(true);
const pageNumberToId = this.#pageNumberToId;
const prevIdToPageNumber = this.#idToPageNumber;
this.pagesNumber -= pagesToDelete.length;
this.#init(false);
const newPageNumberToId = this.#pageNumberToId;
let sourceIndex = 0;
let destIndex = 0;
for (const pageNumber of pagesToDelete) {
const pageIndex = pageNumber - 1;
if (pageIndex !== sourceIndex) {
newPageNumberToId.set(
pageNumberToId.subarray(sourceIndex, pageIndex),
destIndex
);
destIndex += pageIndex - sourceIndex;
}
sourceIndex = pageIndex + 1;
}
if (sourceIndex < pageNumberToId.length) {
newPageNumberToId.set(pageNumberToId.subarray(sourceIndex), destIndex);
}
this.#setPrevPageNumbers(prevIdToPageNumber, null);
this.#updateIdToPageNumber();
this.#updateListeners({ type: "delete", pageNumbers: pagesToDelete });
}
/**
* Copies a set of pages while keeping ID→number mappings in sync.
* @param {Uint32Array} pagesToCopy - Page numbers to copy (1-indexed).
*/
copyPages(pagesToCopy) {
this.#init(true);
this.#copiedPageNumbers = pagesToCopy;
this.#copiedPageIds = pagesToCopy.map(
pageNumber => this.#pageNumberToId[pageNumber - 1]
);
this.#updateListeners({ type: "copy", pageNumbers: pagesToCopy });
}
/**
* Pastes a set of pages while keeping ID→number mappings in sync.
* @param {number} index - Zero-based insertion index in the page-number list.
*/
pastePages(index) {
this.#init(true);
const pageNumberToId = this.#pageNumberToId;
const prevIdToPageNumber = this.#idToPageNumber;
const copiedPageNumbers = this.#copiedPageNumbers;
const copiedPageMapping = new Map();
let base = index;
for (const pageNumber of copiedPageNumbers) {
copiedPageMapping.set(++base, pageNumber);
}
this.pagesNumber += copiedPageNumbers.length;
this.#init(false);
const newPageNumberToId = this.#pageNumberToId;
newPageNumberToId.set(pageNumberToId.subarray(0, index), 0);
newPageNumberToId.set(this.#copiedPageIds, index);
newPageNumberToId.set(
pageNumberToId.subarray(index),
index + copiedPageNumbers.length
);
this.#setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping);
this.#updateIdToPageNumber();
this.#updateListeners({ type: "paste" });
this.#copiedPageIds = null;
}
/**
* Updates the previous page numbers based on the current page-number-to-ID
* mapping and the provided previous ID-to-page-number mapping.
* This is used to keep track of the original page numbers for each page ID.
* @param {Map<number, Array<number>} prevIdToPageNumber
* @param {Map<number, number>|null} copiedPageMapping
*/
#setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping) {
const prevPageNumbers = this.#prevPageNumbers;
const newPageNumberToId = this.#pageNumberToId;
const idsIndices = new Map();
for (let i = 0, ii = this.#pagesNumber; i < ii; i++) {
const oldPageNumber = copiedPageMapping?.get(i + 1);
if (oldPageNumber) {
prevPageNumbers[i] = -oldPageNumber;
continue;
}
const id = newPageNumberToId[i];
const j = idsIndices.get(id) || 0;
prevPageNumbers[i] = prevIdToPageNumber.get(id)?.[j];
idsIndices.set(id, j + 1);
}
}
/**
* Checks if the page mappings have been altered from their initial state.
* @returns {boolean} True if the mappings have been altered, false otherwise.
*/
hasBeenAltered() {
return this.#pageNumberToId !== null;
}
/**
* Gets the current page mapping suitable for saving.
* @returns {Object} An object containing the page indices.
*/
getPageMappingForSaving() {
const idToPageNumber = this.#idToPageNumber;
// idToPageNumber maps used 1-based IDs to 1-based page numbers.
// For example if the final pdf contains page 3 twice and they are moved at
// page 1 and 4, then it contains:
// pageNumberToId = [3, ., ., 3, ...,]
// idToPageNumber = {3: [1, 4], ...}
// In such a case we need to take a page 3 from the original pdf and take
// page 3 from a "copy".
// So we need to pass to the api something like:
// [ {
// document: null // this pdf
// includePages: [ 2, ... ], // page 3 is at index 2
// pageIndices: [0, ...], // page 3 will be at index 0 in the new pdf
// }, {
// document: null // this pdf
// includePages: [ 2, ... ], // page 3 is at index 2
// pageIndices: [3, ...], // page 3 will be at index 3 in the new pdf
// }
// ]
let nCopy = 0;
for (const pageNumbers of idToPageNumber.values()) {
nCopy = Math.max(nCopy, pageNumbers.length);
}
const extractParams = new Array(nCopy);
for (let i = 0; i < nCopy; i++) {
extractParams[i] = {
document: null,
pageIndices: [],
includePages: [],
};
}
for (const [id, pageNumbers] of idToPageNumber) {
for (let i = 0, ii = pageNumbers.length; i < ii; i++) {
extractParams[i].includePages.push([id - 1, pageNumbers[i] - 1]);
}
}
for (const { includePages, pageIndices } of extractParams) {
includePages.sort((a, b) => a[0] - b[0]);
for (let i = 0, ii = includePages.length; i < ii; i++) {
pageIndices.push(includePages[i][1]);
includePages[i] = includePages[i][0];
}
}
return extractParams;
}
/**
* Gets the previous page number for a given page number.
* @param {number} pageNumber
* @returns {number} The previous page number for the given page number, or 0
* if no mapping exists.
*/
getPrevPageNumber(pageNumber) {
return this.#prevPageNumbers[pageNumber - 1] ?? 0;
}
/**
* Gets the page number for a given page ID.
* @param {number} id - The page ID (1-indexed).
* @returns {number} The page number, or 0 if no mapping exists.
*/
getPageNumber(id) {
return this.#idToPageNumber ? (this.#idToPageNumber.get(id)?.[0] ?? 0) : id;
}
/**
* Gets the page ID for a given page number.
* @param {number} pageNumber - The page number (1-indexed).
* @returns {number} The page ID, or the page number itself if no mapping
* exists.
*/
getPageId(pageNumber) {
return this.#pageNumberToId?.[pageNumber - 1] ?? pageNumber;
}
getMapping() {
return this.#pageNumberToId.subarray(0, this.pagesNumber);
}
}
export {
applyOpacity,
ColorScheme,
CSSConstants,
deprecated,
fetchData,
findContrastColor,
getColorValues,
getCurrentTransform,
getCurrentTransformInverse,
getFilenameFromUrl,
getPdfFilenameFromUrl,
getRGB,
getXfaPageViewport,
isDataScheme,
isPdfFile,
isValidFetchUrl,
makePathFromDrawOPS,
noContextMenu,
OutputScale,
PagesMapper,
PageViewport,
PDFDateString,
PixelsPerInch,
RenderingCancelledException,
renderRichText,
setLayerDimensions,
StatTimer,
stopEvent,
SupportedImageMimeTypes,
SVG_NS,
};