} [annotationCanvasMap]
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {AnnotationEditorUIManager} [annotationEditorUIManager]
+ * @property {StructTreeLayerBuilder} [structTreeLayer]
*/
/**
@@ -3074,6 +3086,8 @@ class AnnotationLayer {
#editableAnnotations = new Map();
+ #structTreeLayer = null;
+
constructor({
div,
accessibilityManager,
@@ -3081,10 +3095,12 @@ class AnnotationLayer {
annotationEditorUIManager,
page,
viewport,
+ structTreeLayer,
}) {
this.div = div;
this.#accessibilityManager = accessibilityManager;
this.#annotationCanvasMap = annotationCanvasMap;
+ this.#structTreeLayer = structTreeLayer || null;
this.page = page;
this.viewport = viewport;
this.zIndex = 0;
@@ -3107,9 +3123,16 @@ class AnnotationLayer {
return this.#editableAnnotations.size > 0;
}
- #appendElement(element, id) {
+ async #appendElement(element, id) {
const contentElement = element.firstChild || element;
- contentElement.id = `${AnnotationPrefix}${id}`;
+ const annotationId = (contentElement.id = `${AnnotationPrefix}${id}`);
+ const ariaAttributes =
+ await this.#structTreeLayer?.getAriaAttributes(annotationId);
+ if (ariaAttributes) {
+ for (const [key, value] of ariaAttributes) {
+ contentElement.setAttribute(key, value);
+ }
+ }
this.div.append(element);
this.#accessibilityManager?.moveElementInDOM(
@@ -3186,7 +3209,7 @@ class AnnotationLayer {
if (data.hidden) {
rendered.style.visibility = "hidden";
}
- this.#appendElement(rendered, data.id);
+ await this.#appendElement(rendered, data.id);
if (element._isEditable) {
this.#editableAnnotations.set(element.data.id, element);
@@ -3250,6 +3273,7 @@ class AnnotationLayer {
export {
AnnotationLayer,
FreeTextAnnotationElement,
+ HighlightAnnotationElement,
InkAnnotationElement,
StampAnnotationElement,
};
diff --git a/src/display/api.js b/src/display/api.js
index 571f1e677..8e7a3c4b7 100644
--- a/src/display/api.js
+++ b/src/display/api.js
@@ -43,6 +43,7 @@ import {
SerializableEmpty,
} from "./annotation_storage.js";
import {
+ deprecated,
DOMCanvasFactory,
DOMCMapReaderFactory,
DOMFilterFactory,
@@ -209,10 +210,11 @@ const DefaultStandardFontDataFactory =
* disabling of pre-fetching to work correctly.
* @property {boolean} [pdfBug] - Enables special hooks for debugging PDF.js
* (see `web/debugger.js`). The default value is `false`.
- * @property {Object} [canvasFactory] - The factory instance that will be used
- * when creating canvases. The default value is {new DOMCanvasFactory()}.
- * @property {Object} [filterFactory] - A factory instance that will be used
- * to create SVG filters when rendering some images on the main canvas.
+ * @property {Object} [CanvasFactory] - The factory that will be used when
+ * creating canvases. The default value is {DOMCanvasFactory}.
+ * @property {Object} [FilterFactory] - The factory that will be used to
+ * create SVG filters when rendering some images on the main canvas.
+ * The default value is {DOMFilterFactory}.
* @property {boolean} [enableHWA] - Enables hardware acceleration for
* rendering. The default value is `false`.
*/
@@ -291,6 +293,8 @@ function getDocument(src = {}) {
const disableStream = src.disableStream === true;
const disableAutoFetch = src.disableAutoFetch === true;
const pdfBug = src.pdfBug === true;
+ const CanvasFactory = src.CanvasFactory || DefaultCanvasFactory;
+ const FilterFactory = src.FilterFactory || DefaultFilterFactory;
const enableHWA = src.enableHWA === true;
// Parameters whose default values depend on other parameters.
@@ -309,10 +313,19 @@ function getDocument(src = {}) {
standardFontDataUrl &&
isValidFetchUrl(cMapUrl, document.baseURI) &&
isValidFetchUrl(standardFontDataUrl, document.baseURI));
- const canvasFactory =
- src.canvasFactory || new DefaultCanvasFactory({ ownerDocument, enableHWA });
- const filterFactory =
- src.filterFactory || new DefaultFilterFactory({ docId, ownerDocument });
+
+ if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
+ if (src.canvasFactory) {
+ deprecated(
+ "`canvasFactory`-instance option, please use `CanvasFactory` instead."
+ );
+ }
+ if (src.filterFactory) {
+ deprecated(
+ "`filterFactory`-instance option, please use `FilterFactory` instead."
+ );
+ }
+ }
// Parameters only intended for development/testing purposes.
const styleElement =
@@ -326,8 +339,8 @@ function getDocument(src = {}) {
// Ensure that the various factories can be initialized, when necessary,
// since the user may provide *custom* ones.
const transportFactory = {
- canvasFactory,
- filterFactory,
+ canvasFactory: new CanvasFactory({ ownerDocument, enableHWA }),
+ filterFactory: new FilterFactory({ docId, ownerDocument }),
};
if (!useWorkerFetch) {
transportFactory.cMapReaderFactory = new CMapReaderFactory({
@@ -413,34 +426,34 @@ function getDocument(src = {}) {
});
} else if (!data) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
- throw new Error("Not implemented: createPDFNetworkStream");
+ throw new Error("Not implemented: NetworkStream");
}
if (!url) {
throw new Error("getDocument - no `url` parameter provided.");
}
- const createPDFNetworkStream = params => {
- if (
- typeof PDFJSDev !== "undefined" &&
- PDFJSDev.test("GENERIC") &&
- isNodeJS
- ) {
- const isFetchSupported = function () {
- return (
- typeof fetch !== "undefined" &&
- typeof Response !== "undefined" &&
- "body" in Response.prototype
- );
- };
- return isFetchSupported() && isValidFetchUrl(params.url)
- ? new PDFFetchStream(params)
- : new PDFNodeStream(params);
- }
- return isValidFetchUrl(params.url)
- ? new PDFFetchStream(params)
- : new PDFNetworkStream(params);
- };
+ let NetworkStream;
- networkStream = createPDFNetworkStream({
+ if (
+ typeof PDFJSDev !== "undefined" &&
+ PDFJSDev.test("GENERIC") &&
+ isNodeJS
+ ) {
+ const isFetchSupported =
+ typeof fetch !== "undefined" &&
+ typeof Response !== "undefined" &&
+ "body" in Response.prototype;
+
+ NetworkStream =
+ isFetchSupported && isValidFetchUrl(url)
+ ? PDFFetchStream
+ : PDFNodeStream;
+ } else {
+ NetworkStream = isValidFetchUrl(url)
+ ? PDFFetchStream
+ : PDFNetworkStream;
+ }
+
+ networkStream = new NetworkStream({
url,
length,
httpHeaders,
@@ -781,6 +794,13 @@ class PDFDocumentProxy {
return this._transport.annotationStorage;
}
+ /**
+ * @type {Object} The canvas factory instance.
+ */
+ get canvasFactory() {
+ return this._transport.canvasFactory;
+ }
+
/**
* @type {Object} The filter factory instance.
*/
diff --git a/src/display/base_factory.js b/src/display/base_factory.js
index 5f5b2b0e9..e6773daa9 100644
--- a/src/display/base_factory.js
+++ b/src/display/base_factory.js
@@ -51,7 +51,7 @@ class BaseFilterFactory {
class BaseCanvasFactory {
#enableHWA = false;
- constructor({ enableHWA = false } = {}) {
+ constructor({ enableHWA = false }) {
if (
(typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
this.constructor === BaseCanvasFactory
diff --git a/src/display/canvas.js b/src/display/canvas.js
index 5c0b05500..13f0790a9 100644
--- a/src/display/canvas.js
+++ b/src/display/canvas.js
@@ -2701,9 +2701,12 @@ class CanvasGraphics {
} else {
resetCtxToDefault(this.ctx);
+ // Consume a potential path before clipping.
+ this.endPath();
+
this.ctx.rect(rect[0], rect[1], width, height);
this.ctx.clip();
- this.endPath();
+ this.ctx.beginPath();
}
}
diff --git a/src/display/display_utils.js b/src/display/display_utils.js
index 31fd39023..ae6172632 100644
--- a/src/display/display_utils.js
+++ b/src/display/display_utils.js
@@ -63,7 +63,7 @@ class DOMFilterFactory extends BaseFilterFactory {
#id = 0;
- constructor({ docId, ownerDocument = globalThis.document } = {}) {
+ constructor({ docId, ownerDocument = globalThis.document }) {
super();
this.#docId = docId;
this.#document = ownerDocument;
@@ -496,7 +496,7 @@ class DOMFilterFactory extends BaseFilterFactory {
}
class DOMCanvasFactory extends BaseCanvasFactory {
- constructor({ ownerDocument = globalThis.document, enableHWA = false } = {}) {
+ constructor({ ownerDocument = globalThis.document, enableHWA = false }) {
super({ enableHWA });
this._document = ownerDocument;
}
@@ -1103,8 +1103,12 @@ function setLayerDimensions(
const w = `var(--scale-factor) * ${pageWidth}px`,
h = `var(--scale-factor) * ${pageHeight}px`;
- const widthStr = useRound ? `round(${w}, 1px)` : `calc(${w})`,
- heightStr = useRound ? `round(${h}, 1px)` : `calc(${h})`;
+ const widthStr = useRound
+ ? `round(down, ${w}, var(--scale-round-x, 1px))`
+ : `calc(${w})`,
+ heightStr = useRound
+ ? `round(down, ${h}, var(--scale-round-y, 1px))`
+ : `calc(${h})`;
if (!mustFlip || viewport.rotation % 180 === 0) {
style.width = widthStr;
@@ -1120,6 +1124,36 @@ function setLayerDimensions(
}
}
+/**
+ * Scale factors for the canvas, necessary with HiDPI displays.
+ */
+class OutputScale {
+ constructor() {
+ const pixelRatio = window.devicePixelRatio || 1;
+
+ /**
+ * @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;
+ }
+
+ get symmetric() {
+ return this.sx === this.sy;
+ }
+}
+
export {
deprecated,
DOMCanvasFactory,
@@ -1139,6 +1173,7 @@ export {
isPdfFile,
isValidFetchUrl,
noContextMenu,
+ OutputScale,
PageViewport,
PDFDateString,
PixelsPerInch,
diff --git a/src/display/draw_layer.js b/src/display/draw_layer.js
index c5beb3589..0b114bbe1 100644
--- a/src/display/draw_layer.js
+++ b/src/display/draw_layer.js
@@ -225,6 +225,10 @@ class DrawLayer {
this.#mapping.get(id).classList.remove(className);
}
+ getSVGRoot(id) {
+ return this.#mapping.get(id);
+ }
+
remove(id) {
if (this.#parent === null) {
return;
diff --git a/src/display/editor/alt_text.js b/src/display/editor/alt_text.js
index b9f771503..cc7181d85 100644
--- a/src/display/editor/alt_text.js
+++ b/src/display/editor/alt_text.js
@@ -38,11 +38,19 @@ class AltText {
#useNewAltTextFlow = false;
+ static #l10nNewButton = null;
+
static _l10nPromise = null;
constructor(editor) {
this.#editor = editor;
this.#useNewAltTextFlow = editor._uiManager.useNewAltTextFlow;
+
+ AltText.#l10nNewButton ||= Object.freeze({
+ added: "pdfjs-editor-new-alt-text-added-button-label",
+ missing: "pdfjs-editor-new-alt-text-missing-button-label",
+ review: "pdfjs-editor-new-alt-text-to-review-button-label",
+ });
}
static initialize(l10nPromise) {
@@ -55,9 +63,7 @@ class AltText {
let msg;
if (this.#useNewAltTextFlow) {
altText.classList.add("new");
- msg = await AltText._l10nPromise.get(
- "pdfjs-editor-new-alt-text-missing-button-label"
- );
+ msg = await AltText._l10nPromise.get(AltText.#l10nNewButton.missing);
} else {
msg = await AltText._l10nPromise.get(
"pdfjs-editor-alt-text-button-label"
@@ -213,6 +219,13 @@ class AltText {
this.#altTextButton.disabled = !enabled;
}
+ shown() {
+ this.#editor._reportTelemetry({
+ action: "pdfjs.image.alt_text.image_status_label_displayed",
+ data: { label: this.#label },
+ });
+ }
+
destroy() {
this.#altTextButton?.remove();
this.#altTextButton = null;
@@ -228,20 +241,9 @@ class AltText {
}
if (this.#useNewAltTextFlow) {
- // If we've an alt text, we get an "added".
- // If we've a guessed text and the alt text has never been set, we get a
- // "to-review" been set.
- // Otherwise, we get a "missing".
- const label = this.#label;
- // TODO: Update the l10n keys to avoid this.
- const type = label === "review" ? "to-review" : label;
- this.#editor._reportTelemetry({
- action: "pdfjs.image.alt_text.image_status_label_displayed",
- data: { label },
- });
button.classList.toggle("done", !!this.#altText);
AltText._l10nPromise
- .get(`pdfjs-editor-new-alt-text-${type}-button-label`)
+ .get(AltText.#l10nNewButton[this.#label])
.then(msg => {
button.setAttribute("aria-label", msg);
// We can't just use button.textContent here, because it would remove
@@ -276,8 +278,7 @@ class AltText {
this.#altTextTooltip = tooltip = document.createElement("span");
tooltip.className = "tooltip";
tooltip.setAttribute("role", "tooltip");
- const id = (tooltip.id = `alt-text-tooltip-${this.#editor.id}`);
- button.setAttribute("aria-describedby", id);
+ tooltip.id = `alt-text-tooltip-${this.#editor.id}`;
const DELAY_TO_SHOW_TOOLTIP = 100;
const signal = this.#editor._uiManager._signal;
diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js
index 75a190809..d25e97d2b 100644
--- a/src/display/editor/annotation_editor_layer.js
+++ b/src/display/editor/annotation_editor_layer.js
@@ -323,8 +323,10 @@ class AnnotationEditorLayer {
editor = changedAnnotations.get(id);
if (editor) {
this.#uiManager.addChangedExistingAnnotation(editor);
- editor.renderAnnotationElement(editable);
- editor.show(false);
+ if (editor.renderAnnotationElement(editable)) {
+ // Content has changed, so we need to hide the editor.
+ editor.show(false);
+ }
}
editable.show();
}
@@ -393,7 +395,8 @@ class AnnotationEditorLayer {
const { target } = event;
if (
target === this.#textLayer.div ||
- (target.classList.contains("endOfContent") &&
+ ((target.getAttribute("role") === "img" ||
+ target.classList.contains("endOfContent")) &&
this.#textLayer.div.contains(target))
) {
const { isMac } = FeatureTest.platform;
@@ -411,7 +414,7 @@ class AnnotationEditorLayer {
HighlightEditor.startHighlighting(
this,
this.#uiManager.direction === "ltr",
- event
+ { target: this.#textLayer.div, x: event.x, y: event.y }
);
this.#textLayer.div.addEventListener(
"pointerup",
diff --git a/src/display/editor/color_picker.js b/src/display/editor/color_picker.js
index 955014520..c4d5811ea 100644
--- a/src/display/editor/color_picker.js
+++ b/src/display/editor/color_picker.js
@@ -42,6 +42,8 @@ class ColorPicker {
#type;
+ static #l10nColor = null;
+
static get _keyboardManager() {
return shadow(
this,
@@ -81,6 +83,14 @@ class ColorPicker {
editor?.color ||
this.#uiManager?.highlightColors.values().next().value ||
"#FFFF98";
+
+ ColorPicker.#l10nColor ||= Object.freeze({
+ blue: "pdfjs-editor-colorpicker-blue",
+ green: "pdfjs-editor-colorpicker-green",
+ pink: "pdfjs-editor-colorpicker-pink",
+ red: "pdfjs-editor-colorpicker-red",
+ yellow: "pdfjs-editor-colorpicker-yellow",
+ });
}
renderButton() {
@@ -123,7 +133,7 @@ class ColorPicker {
button.role = "option";
button.setAttribute("data-color", color);
button.title = name;
- button.setAttribute("data-l10n-id", `pdfjs-editor-colorpicker-${name}`);
+ button.setAttribute("data-l10n-id", ColorPicker.#l10nColor[name]);
const swatch = document.createElement("span");
button.append(swatch);
swatch.className = "swatch";
diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js
index dfee06d90..e6cd333f9 100644
--- a/src/display/editor/editor.js
+++ b/src/display/editor/editor.js
@@ -86,7 +86,9 @@ class AnnotationEditor {
_focusEventsAllowed = true;
- _l10nPromise = null;
+ static _l10nPromise = null;
+
+ static _l10nResizer = null;
#isDraggable = false;
@@ -206,38 +208,31 @@ class AnnotationEditor {
* @param {Object} l10n
*/
static initialize(l10n, _uiManager, options) {
- AnnotationEditor._l10nPromise ||= new Map(
- [
+ AnnotationEditor._l10nResizer ||= Object.freeze({
+ topLeft: "pdfjs-editor-resizer-top-left",
+ topMiddle: "pdfjs-editor-resizer-top-middle",
+ topRight: "pdfjs-editor-resizer-top-right",
+ middleRight: "pdfjs-editor-resizer-middle-right",
+ bottomRight: "pdfjs-editor-resizer-bottom-right",
+ bottomMiddle: "pdfjs-editor-resizer-bottom-middle",
+ bottomLeft: "pdfjs-editor-resizer-bottom-left",
+ middleLeft: "pdfjs-editor-resizer-middle-left",
+ });
+
+ AnnotationEditor._l10nPromise ||= new Map([
+ ...[
"pdfjs-editor-alt-text-button-label",
"pdfjs-editor-alt-text-edit-button-label",
"pdfjs-editor-alt-text-decorative-tooltip",
"pdfjs-editor-new-alt-text-added-button-label",
"pdfjs-editor-new-alt-text-missing-button-label",
"pdfjs-editor-new-alt-text-to-review-button-label",
- "pdfjs-editor-resizer-label-topLeft",
- "pdfjs-editor-resizer-label-topMiddle",
- "pdfjs-editor-resizer-label-topRight",
- "pdfjs-editor-resizer-label-middleRight",
- "pdfjs-editor-resizer-label-bottomRight",
- "pdfjs-editor-resizer-label-bottomMiddle",
- "pdfjs-editor-resizer-label-bottomLeft",
- "pdfjs-editor-resizer-label-middleLeft",
- ].map(str => [
- str,
- l10n.get(str.replaceAll(/([A-Z])/g, c => `-${c.toLowerCase()}`)),
- ])
- );
-
- // The string isn't in the above list because the string has a parameter
- // (i.e. the guessed text) and we must pass it to the l10n function to get
- // the correct translation.
- AnnotationEditor._l10nPromise.set(
- "pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer",
- l10n.get.bind(
- l10n,
- "pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer"
- )
- );
+ ].map(str => [str, l10n.get(str)]),
+ ...[
+ // Strings that need l10n-arguments.
+ "pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer",
+ ].map(str => [str, l10n.get.bind(l10n, str)]),
+ ]);
if (options?.strings) {
for (const str of options.strings) {
@@ -664,11 +659,7 @@ class AnnotationEditor {
parentScale,
pageDimensions: [pageWidth, pageHeight],
} = this;
- const scaledWidth = pageWidth * parentScale;
- const scaledHeight = pageHeight * parentScale;
- return FeatureTest.isCSSRoundSupported
- ? [Math.round(scaledWidth), Math.round(scaledHeight)]
- : [scaledWidth, scaledHeight];
+ return [pageWidth * parentScale, pageHeight * parentScale];
}
/**
@@ -980,7 +971,7 @@ class AnnotationEditor {
this._editToolbar = new EditorToolbar(this);
this.div.append(this._editToolbar.render());
if (this.#altText) {
- this._editToolbar.addAltTextButton(await this.#altText.render());
+ await this._editToolbar.addAltText(this.#altText);
}
return this._editToolbar;
@@ -998,6 +989,15 @@ class AnnotationEditor {
this.#altText?.destroy();
}
+ addContainer(container) {
+ const editToolbarDiv = this._editToolbar?.div;
+ if (editToolbarDiv) {
+ editToolbarDiv.before(container);
+ } else {
+ this.div.append(container);
+ }
+ }
+
getClientDimensions() {
return this.div.getBoundingClientRect();
}
@@ -1372,6 +1372,7 @@ class AnnotationEditor {
data.rect,
pageHeight
);
+
editor.x = x / pageWidth;
editor.y = y / pageHeight;
editor.width = width / pageWidth;
@@ -1480,9 +1481,7 @@ class AnnotationEditor {
div.addEventListener("focus", this.#resizerFocus.bind(this, name), {
signal,
});
- AnnotationEditor._l10nPromise
- .get(`pdfjs-editor-resizer-label-${name}`)
- .then(msg => div.setAttribute("aria-label", msg));
+ div.setAttribute("data-l10n-id", AnnotationEditor._l10nResizer[name]);
}
}
@@ -1517,9 +1516,7 @@ class AnnotationEditor {
for (const child of children) {
const div = this.#allResizerDivs[i++];
const name = div.getAttribute("data-resizer-name");
- AnnotationEditor._l10nPromise
- .get(`pdfjs-editor-resizer-label-${name}`)
- .then(msg => child.setAttribute("aria-label", msg));
+ child.setAttribute("data-l10n-id", AnnotationEditor._l10nResizer[name]);
}
}
@@ -1774,7 +1771,7 @@ class AnnotationEditor {
/**
* Render an annotation in the annotation layer.
* @param {Object} annotation
- * @returns {HTMLElement}
+ * @returns {HTMLElement|null}
*/
renderAnnotationElement(annotation) {
let content = annotation.container.querySelector(".annotationContent");
@@ -1795,7 +1792,7 @@ class AnnotationEditor {
resetAnnotationElement(annotation) {
const { firstChild } = annotation.container;
if (
- firstChild.nodeName === "DIV" &&
+ firstChild?.nodeName === "DIV" &&
firstChild.classList.contains("annotationContent")
) {
firstChild.remove();
diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js
index 1e590db4f..19f633758 100644
--- a/src/display/editor/freetext.js
+++ b/src/display/editor/freetext.js
@@ -403,8 +403,16 @@ class FreeTextEditor extends AnnotationEditor {
// We don't use innerText because there are some bugs with line breaks.
const buffer = [];
this.editorDiv.normalize();
+ let prevChild = null;
for (const child of this.editorDiv.childNodes) {
+ if (prevChild?.nodeType === Node.TEXT_NODE && child.nodeName === "BR") {
+ // It can happen if the user uses shift+enter to add a new line.
+ // If we don't skip it, we'll end up with an extra line (one for the
+ // text and one for the br element).
+ continue;
+ }
buffer.push(FreeTextEditor.#getNodeContent(child));
+ prevChild = child;
}
return buffer.join("\n");
}
diff --git a/src/display/editor/highlight.js b/src/display/editor/highlight.js
index 154777e35..65fc74456 100644
--- a/src/display/editor/highlight.js
+++ b/src/display/editor/highlight.js
@@ -21,6 +21,10 @@ import {
} from "../../shared/util.js";
import { bindEvents, KeyboardManager } from "./tools.js";
import { FreeOutliner, Outliner } from "./outliner.js";
+import {
+ HighlightAnnotationElement,
+ InkAnnotationElement,
+} from "../annotation_layer.js";
import { AnnotationEditor } from "./editor.js";
import { ColorPicker } from "./color_picker.js";
import { noContextMenu } from "../display_utils.js";
@@ -51,6 +55,8 @@ class HighlightEditor extends AnnotationEditor {
#id = null;
+ #initialData = null;
+
#isFreeHighlight = false;
#lastPoint = null;
@@ -71,8 +77,6 @@ class HighlightEditor extends AnnotationEditor {
static _defaultThickness = 12;
- static _l10nPromise;
-
static _type = "highlight";
static _editorType = AnnotationEditorType.HIGHLIGHT;
@@ -111,7 +115,7 @@ class HighlightEditor extends AnnotationEditor {
this.#isFreeHighlight = true;
this.#createFreeOutlines(params);
this.#addToDrawLayer();
- } else {
+ } else if (this.#boxes) {
this.#anchorNode = params.anchorNode;
this.#anchorOffset = params.anchorOffset;
this.#focusNode = params.focusNode;
@@ -316,15 +320,22 @@ class HighlightEditor extends AnnotationEditor {
* @param {string} color
*/
#updateColor(color) {
- const setColor = col => {
+ const setColorAndOpacity = (col, opa) => {
this.color = col;
this.parent?.drawLayer.changeColor(this.#id, col);
this.#colorPicker?.updateColor(col);
+ this.#opacity = opa;
+ this.parent?.drawLayer.changeOpacity(this.#id, opa);
};
const savedColor = this.color;
+ const savedOpacity = this.#opacity;
this.addCommands({
- cmd: setColor.bind(this, color),
- undo: setColor.bind(this, savedColor),
+ cmd: setColorAndOpacity.bind(
+ this,
+ color,
+ HighlightEditor._defaultOpacity
+ ),
+ undo: setColorAndOpacity.bind(this, savedColor, savedOpacity),
post: this._uiManager.updateUI.bind(this._uiManager, this),
mustExec: true,
type: AnnotationEditorParamsType.HIGHLIGHT_COLOR,
@@ -410,7 +421,9 @@ class HighlightEditor extends AnnotationEditor {
/** @inheritdoc */
onceAdded() {
- this.parent.addUndoableEditor(this);
+ if (!this.annotationElementId) {
+ this.parent.addUndoableEditor(this);
+ }
this.div.focus();
}
@@ -769,29 +782,114 @@ class HighlightEditor extends AnnotationEditor {
/** @inheritdoc */
static deserialize(data, parent, uiManager) {
+ let initialData = null;
+ if (data instanceof HighlightAnnotationElement) {
+ const {
+ data: { quadPoints, rect, rotation, id, color, opacity },
+ parent: {
+ page: { pageNumber },
+ },
+ } = data;
+ initialData = data = {
+ annotationType: AnnotationEditorType.HIGHLIGHT,
+ color: Array.from(color),
+ opacity,
+ quadPoints,
+ boxes: null,
+ pageIndex: pageNumber - 1,
+ rect: rect.slice(0),
+ rotation,
+ id,
+ deleted: false,
+ };
+ } else if (data instanceof InkAnnotationElement) {
+ const {
+ data: {
+ inkLists,
+ rect,
+ rotation,
+ id,
+ color,
+ borderStyle: { rawWidth: thickness },
+ },
+ parent: {
+ page: { pageNumber },
+ },
+ } = data;
+ initialData = data = {
+ annotationType: AnnotationEditorType.HIGHLIGHT,
+ color: Array.from(color),
+ thickness,
+ inkLists,
+ boxes: null,
+ pageIndex: pageNumber - 1,
+ rect: rect.slice(0),
+ rotation,
+ id,
+ deleted: false,
+ };
+ }
+
+ const { color, quadPoints, inkLists, opacity } = data;
const editor = super.deserialize(data, parent, uiManager);
- const {
- rect: [blX, blY, trX, trY],
- color,
- quadPoints,
- } = data;
editor.color = Util.makeHexColor(...color);
- editor.#opacity = data.opacity;
+ editor.#opacity = opacity || 1;
+ if (inkLists) {
+ editor.#thickness = data.thickness;
+ }
+ editor.annotationElementId = data.id || null;
+ editor.#initialData = initialData;
const [pageWidth, pageHeight] = editor.pageDimensions;
- editor.width = (trX - blX) / pageWidth;
- editor.height = (trY - blY) / pageHeight;
- const boxes = (editor.#boxes = []);
- for (let i = 0; i < quadPoints.length; i += 8) {
- boxes.push({
- x: (quadPoints[4] - trX) / pageWidth,
- y: (trY - (1 - quadPoints[i + 5])) / pageHeight,
- width: (quadPoints[i + 2] - quadPoints[i]) / pageWidth,
- height: (quadPoints[i + 5] - quadPoints[i + 1]) / pageHeight,
+ const [pageX, pageY] = editor.pageTranslation;
+
+ if (quadPoints) {
+ const boxes = (editor.#boxes = []);
+ for (let i = 0; i < quadPoints.length; i += 8) {
+ boxes.push({
+ x: (quadPoints[i] - pageX) / pageWidth,
+ y: 1 - (quadPoints[i + 1] - pageY) / pageHeight,
+ width: (quadPoints[i + 2] - quadPoints[i]) / pageWidth,
+ height: (quadPoints[i + 1] - quadPoints[i + 5]) / pageHeight,
+ });
+ }
+ editor.#createOutlines();
+ editor.#addToDrawLayer();
+ editor.rotate(editor.rotation);
+ } else if (inkLists) {
+ editor.#isFreeHighlight = true;
+ const points = inkLists[0];
+ const point = {
+ x: points[0] - pageX,
+ y: pageHeight - (points[1] - pageY),
+ };
+ const outliner = new FreeOutliner(
+ point,
+ [0, 0, pageWidth, pageHeight],
+ 1,
+ editor.#thickness / 2,
+ true,
+ 0.001
+ );
+ for (let i = 0, ii = points.length; i < ii; i += 2) {
+ point.x = points[i] - pageX;
+ point.y = pageHeight - (points[i + 1] - pageY);
+ outliner.add(point);
+ }
+ const { id, clipPathId } = parent.drawLayer.highlight(
+ outliner,
+ editor.color,
+ editor._defaultOpacity,
+ /* isPathUpdatable = */ true
+ );
+ editor.#createFreeOutlines({
+ highlightOutlines: outliner.getOutlines(),
+ highlightId: id,
+ clipPathId,
});
+ editor.#addToDrawLayer();
}
- editor.#createOutlines();
return editor;
}
@@ -803,10 +901,18 @@ class HighlightEditor extends AnnotationEditor {
return null;
}
+ if (this.deleted) {
+ return {
+ pageIndex: this.pageIndex,
+ id: this.annotationElementId,
+ deleted: true,
+ };
+ }
+
const rect = this.getRect(0, 0);
const color = AnnotationEditor._colorManager.convert(this.color);
- return {
+ const serialized = {
annotationType: AnnotationEditorType.HIGHLIGHT,
color,
opacity: this.#opacity,
@@ -818,6 +924,27 @@ class HighlightEditor extends AnnotationEditor {
rotation: this.#getRotation(),
structTreeParentId: this._structTreeParentId,
};
+
+ if (this.annotationElementId && !this.#hasElementChanged(serialized)) {
+ return null;
+ }
+
+ serialized.id = this.annotationElementId;
+ return serialized;
+ }
+
+ #hasElementChanged(serialized) {
+ const { color } = this.#initialData;
+ return serialized.color.some((c, i) => c !== color[i]);
+ }
+
+ /** @inheritdoc */
+ renderAnnotationElement(annotation) {
+ annotation.updateEdited({
+ rect: this.getRect(0, 0),
+ });
+
+ return null;
}
static canCreateNewEmptyEditor() {
diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js
index 24b17b007..6dd348d32 100644
--- a/src/display/editor/stamp.js
+++ b/src/display/editor/stamp.js
@@ -14,8 +14,8 @@
*/
import { AnnotationEditorType, shadow } from "../../shared/util.js";
+import { OutputScale, PixelsPerInch } from "../display_utils.js";
import { AnnotationEditor } from "./editor.js";
-import { PixelsPerInch } from "../display_utils.js";
import { StampAnnotationElement } from "../annotation_layer.js";
/**
@@ -159,7 +159,7 @@ class StampEditor extends AnnotationEditor {
) {
this._reportTelemetry({
action: "pdfjs.image.image_added",
- data: { alt_text_modal: false },
+ data: { alt_text_modal: false, alt_text_type: "empty" },
});
try {
// The alt-text dialog isn't opened but we still want to guess the alt
@@ -185,7 +185,7 @@ class StampEditor extends AnnotationEditor {
}
const { data, width, height } =
imageData ||
- this.copyCanvas(null, /* createImageData = */ true).imageData;
+ this.copyCanvas(null, null, /* createImageData = */ true).imageData;
const response = await mlManager.guess({
name: "altText",
request: {
@@ -373,6 +373,7 @@ class StampEditor extends AnnotationEditor {
super.render();
this.div.hidden = true;
+ this.div.setAttribute("role", "figure");
this.addAltTextButton();
@@ -425,7 +426,9 @@ class StampEditor extends AnnotationEditor {
this._uiManager.enableWaiting(false);
const canvas = (this.#canvas = document.createElement("canvas"));
- div.append(canvas);
+ canvas.setAttribute("role", "img");
+ this.addContainer(canvas);
+
if (
!this._uiManager.useNewAltTextWhenAddingImage ||
!this._uiManager.useNewAltTextFlow
@@ -450,61 +453,108 @@ class StampEditor extends AnnotationEditor {
}
}
- copyCanvas(maxDimension, createImageData = false) {
- if (!maxDimension) {
+ copyCanvas(maxDataDimension, maxPreviewDimension, createImageData = false) {
+ if (!maxDataDimension) {
// TODO: get this value from Firefox
// (https://bugzilla.mozilla.org/show_bug.cgi?id=1908184)
// It's the maximum dimension that the AI can handle.
- maxDimension = 224;
+ maxDataDimension = 224;
}
const { width: bitmapWidth, height: bitmapHeight } = this.#bitmap;
- const canvas = document.createElement("canvas");
+ const outputScale = new OutputScale();
let bitmap = this.#bitmap;
let width = bitmapWidth,
height = bitmapHeight;
- if (bitmapWidth > maxDimension || bitmapHeight > maxDimension) {
- const ratio = Math.min(
- maxDimension / bitmapWidth,
- maxDimension / bitmapHeight
- );
- width = Math.floor(bitmapWidth * ratio);
- height = Math.floor(bitmapHeight * ratio);
+ let canvas = null;
+
+ if (maxPreviewDimension) {
+ if (
+ bitmapWidth > maxPreviewDimension ||
+ bitmapHeight > maxPreviewDimension
+ ) {
+ const ratio = Math.min(
+ maxPreviewDimension / bitmapWidth,
+ maxPreviewDimension / bitmapHeight
+ );
+ width = Math.floor(bitmapWidth * ratio);
+ height = Math.floor(bitmapHeight * ratio);
+ }
+
+ canvas = document.createElement("canvas");
+ const scaledWidth = (canvas.width = Math.ceil(width * outputScale.sx));
+ const scaledHeight = (canvas.height = Math.ceil(height * outputScale.sy));
if (!this.#isSvg) {
- bitmap = this.#scaleBitmap(width, height);
+ bitmap = this.#scaleBitmap(scaledWidth, scaledHeight);
}
+
+ const ctx = canvas.getContext("2d");
+ ctx.filter = this._uiManager.hcmFilter;
+
+ // Add a checkerboard pattern as a background in case the image has some
+ // transparency.
+ let white = "white",
+ black = "#cfcfd8";
+ if (this._uiManager.hcmFilter !== "none") {
+ black = "black";
+ } else if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
+ white = "#8f8f9d";
+ black = "#42414d";
+ }
+ const boxDim = 15;
+ const boxDimWidth = boxDim * outputScale.sx;
+ const boxDimHeight = boxDim * outputScale.sy;
+ const pattern = new OffscreenCanvas(boxDimWidth * 2, boxDimHeight * 2);
+ const patternCtx = pattern.getContext("2d");
+ patternCtx.fillStyle = white;
+ patternCtx.fillRect(0, 0, boxDimWidth * 2, boxDimHeight * 2);
+ patternCtx.fillStyle = black;
+ patternCtx.fillRect(0, 0, boxDimWidth, boxDimHeight);
+ patternCtx.fillRect(boxDimWidth, boxDimHeight, boxDimWidth, boxDimHeight);
+ ctx.fillStyle = ctx.createPattern(pattern, "repeat");
+ ctx.fillRect(0, 0, scaledWidth, scaledHeight);
+ ctx.drawImage(
+ bitmap,
+ 0,
+ 0,
+ bitmap.width,
+ bitmap.height,
+ 0,
+ 0,
+ scaledWidth,
+ scaledHeight
+ );
}
- canvas.width = width;
- canvas.height = height;
- const ctx = canvas.getContext("2d");
- ctx.filter = this._uiManager.hcmFilter;
-
- // Add a checkerboard pattern as a background in case the image has some
- // transparency.
- let white = "white",
- black = "#cfcfd8";
- if (this._uiManager.hcmFilter !== "none") {
- black = "black";
- } else if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
- white = "#8f8f9d";
- black = "#42414d";
- }
- const boxDim = 15;
- const pattern = new OffscreenCanvas(boxDim * 2, boxDim * 2);
- const patternCtx = pattern.getContext("2d");
- patternCtx.fillStyle = white;
- patternCtx.fillRect(0, 0, boxDim * 2, boxDim * 2);
- patternCtx.fillStyle = black;
- patternCtx.fillRect(0, 0, boxDim, boxDim);
- patternCtx.fillRect(boxDim, boxDim, boxDim, boxDim);
- ctx.fillStyle = ctx.createPattern(pattern, "repeat");
- ctx.fillRect(0, 0, width, height);
-
+ let imageData = null;
if (createImageData) {
- const offscreen = new OffscreenCanvas(width, height);
+ let dataWidth, dataHeight;
+ if (
+ outputScale.symmetric &&
+ bitmap.width < maxDataDimension &&
+ bitmap.height < maxDataDimension
+ ) {
+ dataWidth = bitmap.width;
+ dataHeight = bitmap.height;
+ } else {
+ bitmap = this.#bitmap;
+ if (bitmapWidth > maxDataDimension || bitmapHeight > maxDataDimension) {
+ const ratio = Math.min(
+ maxDataDimension / bitmapWidth,
+ maxDataDimension / bitmapHeight
+ );
+ dataWidth = Math.floor(bitmapWidth * ratio);
+ dataHeight = Math.floor(bitmapHeight * ratio);
+
+ if (!this.#isSvg) {
+ bitmap = this.#scaleBitmap(dataWidth, dataHeight);
+ }
+ }
+ }
+
+ const offscreen = new OffscreenCanvas(dataWidth, dataHeight);
const offscreenCtx = offscreen.getContext("2d", {
willReadFrequently: true,
});
@@ -516,27 +566,17 @@ class StampEditor extends AnnotationEditor {
bitmap.height,
0,
0,
- width,
- height
+ dataWidth,
+ dataHeight
);
- const data = offscreenCtx.getImageData(0, 0, width, height).data;
- ctx.drawImage(offscreen, 0, 0);
-
- return { canvas, imageData: { width, height, data } };
+ imageData = {
+ width: dataWidth,
+ height: dataHeight,
+ data: offscreenCtx.getImageData(0, 0, dataWidth, dataHeight).data,
+ };
}
- ctx.drawImage(
- bitmap,
- 0,
- 0,
- bitmap.width,
- bitmap.height,
- 0,
- 0,
- width,
- height
- );
- return { canvas, imageData: null };
+ return { canvas, width, height, imageData };
}
/**
@@ -550,7 +590,6 @@ class StampEditor extends AnnotationEditor {
const [parentWidth, parentHeight] = this.parentDimensions;
this.width = width / parentWidth;
this.height = height / parentHeight;
- this.setDims(width, height);
if (this._initialOptions?.isCentered) {
this.center();
} else {
@@ -617,17 +656,23 @@ class StampEditor extends AnnotationEditor {
}
#drawBitmap(width, height) {
- width = Math.ceil(width);
- height = Math.ceil(height);
+ const outputScale = new OutputScale();
+ const scaledWidth = Math.ceil(width * outputScale.sx);
+ const scaledHeight = Math.ceil(height * outputScale.sy);
+
const canvas = this.#canvas;
- if (!canvas || (canvas.width === width && canvas.height === height)) {
+ if (
+ !canvas ||
+ (canvas.width === scaledWidth && canvas.height === scaledHeight)
+ ) {
return;
}
- canvas.width = width;
- canvas.height = height;
+ canvas.width = scaledWidth;
+ canvas.height = scaledHeight;
+
const bitmap = this.#isSvg
? this.#bitmap
- : this.#scaleBitmap(width, height);
+ : this.#scaleBitmap(scaledWidth, scaledHeight);
const ctx = canvas.getContext("2d");
ctx.filter = this._uiManager.hcmFilter;
@@ -639,8 +684,8 @@ class StampEditor extends AnnotationEditor {
bitmap.height,
0,
0,
- width,
- height
+ scaledWidth,
+ scaledHeight
);
}
diff --git a/src/display/editor/toolbar.js b/src/display/editor/toolbar.js
index 66c0b93ed..64ed92d79 100644
--- a/src/display/editor/toolbar.js
+++ b/src/display/editor/toolbar.js
@@ -24,8 +24,19 @@ class EditorToolbar {
#buttons = null;
+ #altText = null;
+
+ static #l10nRemove = null;
+
constructor(editor) {
this.#editor = editor;
+
+ EditorToolbar.#l10nRemove ||= Object.freeze({
+ freetext: "pdfjs-editor-remove-freetext-button",
+ highlight: "pdfjs-editor-remove-highlight-button",
+ ink: "pdfjs-editor-remove-ink-button",
+ stamp: "pdfjs-editor-remove-stamp-button",
+ });
}
render() {
@@ -60,6 +71,10 @@ class EditorToolbar {
return editToolbar;
}
+ get div() {
+ return this.#toolbar;
+ }
+
static #pointerDown(e) {
e.stopPropagation();
}
@@ -99,23 +114,23 @@ class EditorToolbar {
show() {
this.#toolbar.classList.remove("hidden");
+ this.#altText?.shown();
}
#addDeleteButton() {
+ const { editorType, _uiManager } = this.#editor;
+
const button = document.createElement("button");
button.className = "delete";
button.tabIndex = 0;
- button.setAttribute(
- "data-l10n-id",
- `pdfjs-editor-remove-${this.#editor.editorType}-button`
- );
+ button.setAttribute("data-l10n-id", EditorToolbar.#l10nRemove[editorType]);
this.#addListenersToElement(button);
button.addEventListener(
"click",
e => {
- this.#editor._uiManager.delete();
+ _uiManager.delete();
},
- { signal: this.#editor._uiManager._signal }
+ { signal: _uiManager._signal }
);
this.#buttons.append(button);
}
@@ -126,9 +141,11 @@ class EditorToolbar {
return divider;
}
- addAltTextButton(button) {
+ async addAltText(altText) {
+ const button = await altText.render();
this.#addListenersToElement(button);
this.#buttons.prepend(button, this.#divider);
+ this.#altText = altText;
}
addColorPicker(colorPicker) {
diff --git a/src/display/fetch_stream.js b/src/display/fetch_stream.js
index 00bfa1e42..1d025ce8b 100644
--- a/src/display/fetch_stream.js
+++ b/src/display/fetch_stream.js
@@ -15,6 +15,7 @@
import { AbortException, assert, warn } from "../shared/util.js";
import {
+ createHeaders,
createResponseStatusError,
extractFilenameFromHeader,
validateRangeRequestCapabilities,
@@ -38,18 +39,6 @@ function createFetchOptions(headers, withCredentials, abortController) {
};
}
-function createHeaders(httpHeaders) {
- const headers = new Headers();
- for (const property in httpHeaders) {
- const value = httpHeaders[property];
- if (value === undefined) {
- continue;
- }
- headers.append(property, value);
- }
- return headers;
-}
-
function getArrayBuffer(val) {
if (val instanceof Uint8Array) {
return val.buffer;
@@ -66,7 +55,7 @@ class PDFFetchStream {
constructor(source) {
this.source = source;
this.isHttp = /^https?:/i.test(source.url);
- this.httpHeaders = (this.isHttp && source.httpHeaders) || {};
+ this.headers = createHeaders(this.isHttp, source.httpHeaders);
this._fullRequestReader = null;
this._rangeRequestReaders = [];
@@ -123,17 +112,13 @@ class PDFFetchStreamReader {
this._abortController = new AbortController();
this._isStreamingSupported = !source.disableStream;
this._isRangeSupported = !source.disableRange;
-
- this._headers = createHeaders(this._stream.httpHeaders);
+ // Always create a copy of the headers.
+ const headers = new Headers(stream.headers);
const url = source.url;
fetch(
url,
- createFetchOptions(
- this._headers,
- this._withCredentials,
- this._abortController
- )
+ createFetchOptions(headers, this._withCredentials, this._abortController)
)
.then(response => {
if (!validateResponseStatus(response.status)) {
@@ -142,12 +127,12 @@ class PDFFetchStreamReader {
this._reader = response.body.getReader();
this._headersCapability.resolve();
- const getResponseHeader = name => response.headers.get(name);
+ const responseHeaders = response.headers;
const { allowRangeRequests, suggestedLength } =
validateRangeRequestCapabilities({
- getResponseHeader,
- isHttp: this._stream.isHttp,
+ responseHeaders,
+ isHttp: stream.isHttp,
rangeChunkSize: this._rangeChunkSize,
disableRange: this._disableRange,
});
@@ -156,7 +141,7 @@ class PDFFetchStreamReader {
// Setting right content length.
this._contentLength = suggestedLength || this._contentLength;
- this._filename = extractFilenameFromHeader(getResponseHeader);
+ this._filename = extractFilenameFromHeader(responseHeaders);
// We need to stop reading when range is supported and streaming is
// disabled.
@@ -222,17 +207,14 @@ class PDFFetchStreamRangeReader {
this._isStreamingSupported = !source.disableStream;
this._abortController = new AbortController();
- this._headers = createHeaders(this._stream.httpHeaders);
- this._headers.append("Range", `bytes=${begin}-${end - 1}`);
+ // Always create a copy of the headers.
+ const headers = new Headers(stream.headers);
+ headers.append("Range", `bytes=${begin}-${end - 1}`);
const url = source.url;
fetch(
url,
- createFetchOptions(
- this._headers,
- this._withCredentials,
- this._abortController
- )
+ createFetchOptions(headers, this._withCredentials, this._abortController)
)
.then(response => {
if (!validateResponseStatus(response.status)) {
diff --git a/src/display/network.js b/src/display/network.js
index 65f6ec205..8ce4e1356 100644
--- a/src/display/network.js
+++ b/src/display/network.js
@@ -15,6 +15,7 @@
import { assert, stringToBytes } from "../shared/util.js";
import {
+ createHeaders,
createResponseStatusError,
extractFilenameFromHeader,
validateRangeRequestCapabilities,
@@ -38,11 +39,11 @@ function getArrayBuffer(xhr) {
}
class NetworkManager {
- constructor(url, args = {}) {
+ constructor({ url, httpHeaders, withCredentials }) {
this.url = url;
this.isHttp = /^https?:/i.test(url);
- this.httpHeaders = (this.isHttp && args.httpHeaders) || Object.create(null);
- this.withCredentials = args.withCredentials || false;
+ this.headers = createHeaders(this.isHttp, httpHeaders);
+ this.withCredentials = withCredentials || false;
this.currXhrId = 0;
this.pendingRequests = Object.create(null);
@@ -70,12 +71,8 @@ class NetworkManager {
xhr.open("GET", this.url);
xhr.withCredentials = this.withCredentials;
- for (const property in this.httpHeaders) {
- const value = this.httpHeaders[property];
- if (value === undefined) {
- continue;
- }
- xhr.setRequestHeader(property, value);
+ for (const [key, val] of this.headers) {
+ xhr.setRequestHeader(key, val);
}
if (this.isHttp && "begin" in args && "end" in args) {
xhr.setRequestHeader("Range", `bytes=${args.begin}-${args.end - 1}`);
@@ -194,10 +191,7 @@ class NetworkManager {
class PDFNetworkStream {
constructor(source) {
this._source = source;
- this._manager = new NetworkManager(source.url, {
- httpHeaders: source.httpHeaders,
- withCredentials: source.withCredentials,
- });
+ this._manager = new NetworkManager(source);
this._rangeChunkSize = source.rangeChunkSize;
this._fullRequestReader = null;
this._rangeRequestReaders = [];
@@ -255,7 +249,7 @@ class PDFNetworkStreamFullRequestReader {
};
this._url = source.url;
this._fullRequestId = manager.requestFull(args);
- this._headersReceivedCapability = Promise.withResolvers();
+ this._headersCapability = Promise.withResolvers();
this._disableRange = source.disableRange || false;
this._contentLength = source.length; // Optional
this._rangeChunkSize = source.rangeChunkSize;
@@ -279,11 +273,20 @@ class PDFNetworkStreamFullRequestReader {
const fullRequestXhrId = this._fullRequestId;
const fullRequestXhr = this._manager.getRequestXhr(fullRequestXhrId);
- const getResponseHeader = name => fullRequestXhr.getResponseHeader(name);
+ const responseHeaders = new Headers(
+ fullRequestXhr
+ .getAllResponseHeaders()
+ .trim()
+ .split(/[\r\n]+/)
+ .map(x => {
+ const [key, ...val] = x.split(": ");
+ return [key, val.join(": ")];
+ })
+ );
const { allowRangeRequests, suggestedLength } =
validateRangeRequestCapabilities({
- getResponseHeader,
+ responseHeaders,
isHttp: this._manager.isHttp,
rangeChunkSize: this._rangeChunkSize,
disableRange: this._disableRange,
@@ -295,7 +298,7 @@ class PDFNetworkStreamFullRequestReader {
// Setting right content length.
this._contentLength = suggestedLength || this._contentLength;
- this._filename = extractFilenameFromHeader(getResponseHeader);
+ this._filename = extractFilenameFromHeader(responseHeaders);
if (this._isRangeSupported) {
// NOTE: by cancelling the full request, and then issuing range
@@ -305,7 +308,7 @@ class PDFNetworkStreamFullRequestReader {
this._manager.abortRequest(fullRequestXhrId);
}
- this._headersReceivedCapability.resolve();
+ this._headersCapability.resolve();
}
_onDone(data) {
@@ -329,7 +332,7 @@ class PDFNetworkStreamFullRequestReader {
_onError(status) {
this._storedError = createResponseStatusError(status, this._url);
- this._headersReceivedCapability.reject(this._storedError);
+ this._headersCapability.reject(this._storedError);
for (const requestCapability of this._requests) {
requestCapability.reject(this._storedError);
}
@@ -361,7 +364,7 @@ class PDFNetworkStreamFullRequestReader {
}
get headersReady() {
- return this._headersReceivedCapability.promise;
+ return this._headersCapability.promise;
}
async read() {
@@ -382,7 +385,7 @@ class PDFNetworkStreamFullRequestReader {
cancel(reason) {
this._done = true;
- this._headersReceivedCapability.reject(reason);
+ this._headersCapability.reject(reason);
for (const requestCapability of this._requests) {
requestCapability.resolve({ value: undefined, done: true });
}
diff --git a/src/display/network_utils.js b/src/display/network_utils.js
index 0c470a954..80f895885 100644
--- a/src/display/network_utils.js
+++ b/src/display/network_utils.js
@@ -21,8 +21,23 @@ import {
import { getFilenameFromContentDispositionHeader } from "./content_disposition.js";
import { isPdfFile } from "./display_utils.js";
+function createHeaders(isHttp, httpHeaders) {
+ const headers = new Headers();
+
+ if (!isHttp || !httpHeaders || typeof httpHeaders !== "object") {
+ return headers;
+ }
+ for (const key in httpHeaders) {
+ const val = httpHeaders[key];
+ if (val !== undefined) {
+ headers.append(key, val);
+ }
+ }
+ return headers;
+}
+
function validateRangeRequestCapabilities({
- getResponseHeader,
+ responseHeaders,
isHttp,
rangeChunkSize,
disableRange,
@@ -38,7 +53,7 @@ function validateRangeRequestCapabilities({
suggestedLength: undefined,
};
- const length = parseInt(getResponseHeader("Content-Length"), 10);
+ const length = parseInt(responseHeaders.get("Content-Length"), 10);
if (!Number.isInteger(length)) {
return returnValues;
}
@@ -54,11 +69,11 @@ function validateRangeRequestCapabilities({
if (disableRange || !isHttp) {
return returnValues;
}
- if (getResponseHeader("Accept-Ranges") !== "bytes") {
+ if (responseHeaders.get("Accept-Ranges") !== "bytes") {
return returnValues;
}
- const contentEncoding = getResponseHeader("Content-Encoding") || "identity";
+ const contentEncoding = responseHeaders.get("Content-Encoding") || "identity";
if (contentEncoding !== "identity") {
return returnValues;
}
@@ -67,8 +82,8 @@ function validateRangeRequestCapabilities({
return returnValues;
}
-function extractFilenameFromHeader(getResponseHeader) {
- const contentDisposition = getResponseHeader("Content-Disposition");
+function extractFilenameFromHeader(responseHeaders) {
+ const contentDisposition = responseHeaders.get("Content-Disposition");
if (contentDisposition) {
let filename = getFilenameFromContentDispositionHeader(contentDisposition);
if (filename.includes("%")) {
@@ -98,6 +113,7 @@ function validateResponseStatus(status) {
}
export {
+ createHeaders,
createResponseStatusError,
extractFilenameFromHeader,
validateRangeRequestCapabilities,
diff --git a/src/display/node_stream.js b/src/display/node_stream.js
index 3ffabffe0..6808488d6 100644
--- a/src/display/node_stream.js
+++ b/src/display/node_stream.js
@@ -15,6 +15,7 @@
import { AbortException, assert, MissingPDFException } from "../shared/util.js";
import {
+ createHeaders,
extractFilenameFromHeader,
validateRangeRequestCapabilities,
} from "./network_utils.js";
@@ -26,34 +27,34 @@ if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
);
}
-const fileUriRegex = /^file:\/\/\/[a-zA-Z]:\//;
+const urlRegex = /^[a-z][a-z0-9\-+.]+:/i;
-function parseUrl(sourceUrl) {
+function parseUrlOrPath(sourceUrl) {
+ if (urlRegex.test(sourceUrl)) {
+ return new URL(sourceUrl);
+ }
const url = NodePackages.get("url");
- const parsedUrl = url.parse(sourceUrl);
- if (parsedUrl.protocol === "file:" || parsedUrl.host) {
- return parsedUrl;
+ return new URL(url.pathToFileURL(sourceUrl));
+}
+
+function createRequest(url, headers, callback) {
+ if (url.protocol === "http:") {
+ const http = NodePackages.get("http");
+ return http.request(url, { headers }, callback);
}
- // Prepending 'file:///' to Windows absolute path.
- if (/^[a-z]:[/\\]/i.test(sourceUrl)) {
- return url.parse(`file:///${sourceUrl}`);
- }
- // Changes protocol to 'file:' if url refers to filesystem.
- if (!parsedUrl.host) {
- parsedUrl.protocol = "file:";
- }
- return parsedUrl;
+ const https = NodePackages.get("https");
+ return https.request(url, { headers }, callback);
}
class PDFNodeStream {
constructor(source) {
this.source = source;
- this.url = parseUrl(source.url);
+ this.url = parseUrlOrPath(source.url);
this.isHttp =
this.url.protocol === "http:" || this.url.protocol === "https:";
// Check if url refers to filesystem.
this.isFsUrl = this.url.protocol === "file:";
- this.httpHeaders = (this.isHttp && source.httpHeaders) || {};
+ this.headers = createHeaders(this.isHttp, source.httpHeaders);
this._fullRequestReader = null;
this._rangeRequestReaders = [];
@@ -287,22 +288,13 @@ class BaseRangeReader {
}
}
-function createRequestOptions(parsedUrl, headers) {
- return {
- protocol: parsedUrl.protocol,
- auth: parsedUrl.auth,
- host: parsedUrl.hostname,
- port: parsedUrl.port,
- path: parsedUrl.path,
- method: "GET",
- headers,
- };
-}
-
class PDFNodeStreamFullReader extends BaseFullReader {
constructor(stream) {
super(stream);
+ // Node.js requires the `headers` to be a regular Object.
+ const headers = Object.fromEntries(stream.headers);
+
const handleResponse = response => {
if (response.statusCode === 404) {
const error = new MissingPDFException(`Missing PDF "${this._url}".`);
@@ -313,14 +305,11 @@ class PDFNodeStreamFullReader extends BaseFullReader {
this._headersCapability.resolve();
this._setReadableStream(response);
- // Make sure that headers name are in lower case, as mentioned
- // here: https://nodejs.org/api/http.html#http_message_headers.
- const getResponseHeader = name =>
- this._readableStream.headers[name.toLowerCase()];
+ const responseHeaders = new Headers(this._readableStream.headers);
const { allowRangeRequests, suggestedLength } =
validateRangeRequestCapabilities({
- getResponseHeader,
+ responseHeaders,
isHttp: stream.isHttp,
rangeChunkSize: this._rangeChunkSize,
disableRange: this._disableRange,
@@ -330,23 +319,10 @@ class PDFNodeStreamFullReader extends BaseFullReader {
// Setting right content length.
this._contentLength = suggestedLength || this._contentLength;
- this._filename = extractFilenameFromHeader(getResponseHeader);
+ this._filename = extractFilenameFromHeader(responseHeaders);
};
- this._request = null;
- if (this._url.protocol === "http:") {
- const http = NodePackages.get("http");
- this._request = http.request(
- createRequestOptions(this._url, stream.httpHeaders),
- handleResponse
- );
- } else {
- const https = NodePackages.get("https");
- this._request = https.request(
- createRequestOptions(this._url, stream.httpHeaders),
- handleResponse
- );
- }
+ this._request = createRequest(this._url, headers, handleResponse);
this._request.on("error", reason => {
this._storedError = reason;
@@ -363,15 +339,9 @@ class PDFNodeStreamRangeReader extends BaseRangeReader {
constructor(stream, start, end) {
super(stream);
- this._httpHeaders = {};
- for (const property in stream.httpHeaders) {
- const value = stream.httpHeaders[property];
- if (value === undefined) {
- continue;
- }
- this._httpHeaders[property] = value;
- }
- this._httpHeaders.Range = `bytes=${start}-${end - 1}`;
+ // Node.js requires the `headers` to be a regular Object.
+ const headers = Object.fromEntries(stream.headers);
+ headers.Range = `bytes=${start}-${end - 1}`;
const handleResponse = response => {
if (response.statusCode === 404) {
@@ -382,20 +352,7 @@ class PDFNodeStreamRangeReader extends BaseRangeReader {
this._setReadableStream(response);
};
- this._request = null;
- if (this._url.protocol === "http:") {
- const http = NodePackages.get("http");
- this._request = http.request(
- createRequestOptions(this._url, this._httpHeaders),
- handleResponse
- );
- } else {
- const https = NodePackages.get("https");
- this._request = https.request(
- createRequestOptions(this._url, this._httpHeaders),
- handleResponse
- );
- }
+ this._request = createRequest(this._url, headers, handleResponse);
this._request.on("error", reason => {
this._storedError = reason;
@@ -408,25 +365,18 @@ class PDFNodeStreamFsFullReader extends BaseFullReader {
constructor(stream) {
super(stream);
- let path = decodeURIComponent(this._url.path);
-
- // Remove the extra slash to get right path from url like `file:///C:/`
- if (fileUriRegex.test(this._url.href)) {
- path = path.replace(/^\//, "");
- }
-
const fs = NodePackages.get("fs");
- fs.promises.lstat(path).then(
+ fs.promises.lstat(this._url).then(
stat => {
// Setting right content length.
this._contentLength = stat.size;
- this._setReadableStream(fs.createReadStream(path));
+ this._setReadableStream(fs.createReadStream(this._url));
this._headersCapability.resolve();
},
error => {
if (error.code === "ENOENT") {
- error = new MissingPDFException(`Missing PDF "${path}".`);
+ error = new MissingPDFException(`Missing PDF "${this._url}".`);
}
this._storedError = error;
this._headersCapability.reject(error);
@@ -439,15 +389,10 @@ class PDFNodeStreamFsRangeReader extends BaseRangeReader {
constructor(stream, start, end) {
super(stream);
- let path = decodeURIComponent(this._url.path);
-
- // Remove the extra slash to get right path from url like `file:///C:/`
- if (fileUriRegex.test(this._url.href)) {
- path = path.replace(/^\//, "");
- }
-
const fs = NodePackages.get("fs");
- this._setReadableStream(fs.createReadStream(path, { start, end: end - 1 }));
+ this._setReadableStream(
+ fs.createReadStream(this._url, { start, end: end - 1 })
+ );
}
}
diff --git a/src/display/text_layer.js b/src/display/text_layer.js
index bace7a87e..2f72b712e 100644
--- a/src/display/text_layer.js
+++ b/src/display/text_layer.js
@@ -83,6 +83,8 @@ class TextLayer {
static #canvasContexts = new Map();
+ static #canvasCtxFonts = new WeakMap();
+
static #minFontSize = null;
static #pendingTextLayers = new Set();
@@ -111,8 +113,6 @@ class TextLayer {
this.#scale = viewport.scale * (globalThis.devicePixelRatio || 1);
this.#rotation = viewport.rotation;
this.#layoutTextParams = {
- prevFontSize: null,
- prevFontFamily: null,
div: null,
properties: null,
ctx: null,
@@ -128,13 +128,13 @@ class TextLayer {
// Always clean-up the temporary canvas once rendering is no longer pending.
this.#capability.promise
- .catch(() => {
- // Avoid "Uncaught promise" messages in the console.
- })
- .then(() => {
+ .finally(() => {
TextLayer.#pendingTextLayers.delete(this);
this.#layoutTextParams = null;
this.#styleCache = null;
+ })
+ .catch(() => {
+ // Avoid "Uncaught promise" messages in the console.
});
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
@@ -195,8 +195,6 @@ class TextLayer {
onBefore?.();
this.#scale = scale;
const params = {
- prevFontSize: null,
- prevFontFamily: null,
div: null,
properties: null,
ctx: TextLayer.#getCtx(this.#lang),
@@ -394,7 +392,7 @@ class TextLayer {
}
#layout(params) {
- const { div, properties, ctx, prevFontSize, prevFontFamily } = params;
+ const { div, properties, ctx } = params;
const { style } = div;
let transform = "";
@@ -406,12 +404,7 @@ class TextLayer {
const { fontFamily } = style;
const { canvasWidth, fontSize } = properties;
- if (prevFontSize !== fontSize || prevFontFamily !== fontFamily) {
- ctx.font = `${fontSize * this.#scale}px ${fontFamily}`;
- params.prevFontSize = fontSize;
- params.prevFontFamily = fontFamily;
- }
-
+ TextLayer.#ensureCtxFont(ctx, fontSize * this.#scale, fontFamily);
// Only measure the width for multi-char text divs, see `appendText`.
const { width } = ctx.measureText(div.textContent);
@@ -444,8 +437,8 @@ class TextLayer {
}
static #getCtx(lang = null) {
- let canvasContext = this.#canvasContexts.get((lang ||= ""));
- if (!canvasContext) {
+ let ctx = this.#canvasContexts.get((lang ||= ""));
+ if (!ctx) {
// We don't use an OffscreenCanvas here because we use serif/sans serif
// fonts with it and they depends on the locale.
// In Firefox, the element get a lang attribute that depends on
@@ -460,13 +453,26 @@ class TextLayer {
canvas.className = "hiddenCanvasElement";
canvas.lang = lang;
document.body.append(canvas);
- canvasContext = canvas.getContext("2d", {
+ ctx = canvas.getContext("2d", {
alpha: false,
willReadFrequently: true,
});
- this.#canvasContexts.set(lang, canvasContext);
+ this.#canvasContexts.set(lang, ctx);
+
+ // Also, initialize state for the `#ensureCtxFont` method.
+ this.#canvasCtxFonts.set(ctx, { size: 0, family: "" });
}
- return canvasContext;
+ return ctx;
+ }
+
+ static #ensureCtxFont(ctx, size, family) {
+ const cached = this.#canvasCtxFonts.get(ctx);
+ if (size === cached.size && family === cached.family) {
+ return; // The font is already set.
+ }
+ ctx.font = `${size}px ${family}`;
+ cached.size = size;
+ cached.family = family;
}
/**
@@ -497,9 +503,8 @@ class TextLayer {
}
const ctx = this.#getCtx(lang);
- const savedFont = ctx.font;
ctx.canvas.width = ctx.canvas.height = DEFAULT_FONT_SIZE;
- ctx.font = `${DEFAULT_FONT_SIZE}px ${fontFamily}`;
+ this.#ensureCtxFont(ctx, DEFAULT_FONT_SIZE, fontFamily);
const metrics = ctx.measureText("");
// Both properties aren't available by default in Firefox.
@@ -510,7 +515,6 @@ class TextLayer {
this.#ascentCache.set(fontFamily, ratio);
ctx.canvas.width = ctx.canvas.height = 0;
- ctx.font = savedFont;
return ratio;
}
@@ -550,7 +554,6 @@ class TextLayer {
}
ctx.canvas.width = ctx.canvas.height = 0;
- ctx.font = savedFont;
const ratio = ascent ? ascent / (ascent + descent) : DEFAULT_FONT_ASCENT;
this.#ascentCache.set(fontFamily, ratio);
diff --git a/src/pdf.js b/src/pdf.js
index 064ae6c61..c90244e8a 100644
--- a/src/pdf.js
+++ b/src/pdf.js
@@ -58,6 +58,7 @@ import {
isDataScheme,
isPdfFile,
noContextMenu,
+ OutputScale,
PDFDateString,
PixelsPerInch,
RenderingCancelledException,
@@ -115,6 +116,7 @@ export {
noContextMenu,
normalizeUnicode,
OPS,
+ OutputScale,
PasswordResponses,
PDFDataRangeTransport,
PDFDateString,
diff --git a/src/scripting_api/aform.js b/src/scripting_api/aform.js
index 74cd5a7c5..355fc8450 100644
--- a/src/scripting_api/aform.js
+++ b/src/scripting_api/aform.js
@@ -560,6 +560,22 @@ class AForm {
}
AFSpecial_KeystrokeEx(cMask) {
+ const event = globalThis.event;
+
+ // Simplify the format string by removing all characters that are not
+ // specific to the format because the user could enter 1234567 when the
+ // format is 999-9999.
+ const simplifiedFormatStr = cMask.replaceAll(/[^9AOX]/g, "");
+ this.#AFSpecial_KeystrokeEx_helper(simplifiedFormatStr, false);
+ if (event.rc) {
+ return;
+ }
+
+ event.rc = true;
+ this.#AFSpecial_KeystrokeEx_helper(cMask, true);
+ }
+
+ #AFSpecial_KeystrokeEx_helper(cMask, warn) {
if (!cMask) {
return;
}
@@ -605,20 +621,26 @@ class AForm {
const err = `${GlobalConstants.IDS_INVALID_VALUE} = "${cMask}"`;
if (value.length > cMask.length) {
- this._app.alert(err);
+ if (warn) {
+ this._app.alert(err);
+ }
event.rc = false;
return;
}
if (event.willCommit) {
if (value.length < cMask.length) {
- this._app.alert(err);
+ if (warn) {
+ this._app.alert(err);
+ }
event.rc = false;
return;
}
if (!_checkValidity(value, cMask)) {
- this._app.alert(err);
+ if (warn) {
+ this._app.alert(err);
+ }
event.rc = false;
return;
}
@@ -631,7 +653,9 @@ class AForm {
}
if (!_checkValidity(value, cMask)) {
- this._app.alert(err);
+ if (warn) {
+ this._app.alert(err);
+ }
event.rc = false;
}
}
@@ -651,7 +675,7 @@ class AForm {
case 2:
const value = this.AFMergeChange(event);
formatStr =
- value.length > 8 || value.startsWith("(")
+ value.startsWith("(") || (value.length > 7 && /^\p{N}+$/.test(value))
? "(999) 999-9999"
: "999-9999";
break;
diff --git a/src/scripting_api/util.js b/src/scripting_api/util.js
index 5220c5b64..551a3fb60 100644
--- a/src/scripting_api/util.js
+++ b/src/scripting_api/util.js
@@ -153,7 +153,12 @@ class Util extends PDFObject {
? Math.abs(arg - intPart).toFixed(nPrecision)
: Math.abs(arg - intPart).toString();
if (decPart.length > 2) {
- decPart = `${decimalSep}${decPart.substring(2)}`;
+ if (/^1\.0+$/.test(decPart)) {
+ intPart += Math.sign(arg);
+ decPart = `${decimalSep}${decPart.split(".")[1]}`;
+ } else {
+ decPart = `${decimalSep}${decPart.substring(2)}`;
+ }
} else {
if (decPart === "1") {
intPart += Math.sign(arg);
diff --git a/test/driver.js b/test/driver.js
index c79c30601..8ba30b0bf 100644
--- a/test/driver.js
+++ b/test/driver.js
@@ -782,7 +782,7 @@ class Driver {
}
}
- if (task.skipPages && task.skipPages.includes(task.pageNum)) {
+ if (task.skipPages?.includes(task.pageNum)) {
this._log(
" Skipping page " + task.pageNum + "/" + task.pdfDoc.numPages + "...\n"
);
diff --git a/test/integration/accessibility_spec.mjs b/test/integration/accessibility_spec.mjs
index b628a4283..ad07e43fd 100644
--- a/test/integration/accessibility_spec.mjs
+++ b/test/integration/accessibility_spec.mjs
@@ -13,7 +13,26 @@
* limitations under the License.
*/
-import { closePages, loadAndWait } from "./test_utils.mjs";
+import {
+ awaitPromise,
+ closePages,
+ loadAndWait,
+ waitForPageRendered,
+} from "./test_utils.mjs";
+
+const isStructTreeVisible = async page => {
+ await page.waitForSelector(".structTree");
+ return page.evaluate(() => {
+ let elem = document.querySelector(".structTree");
+ while (elem) {
+ if (elem.getAttribute("aria-hidden") === "true") {
+ return false;
+ }
+ elem = elem.parentElement;
+ }
+ return true;
+ });
+};
describe("accessibility", () => {
describe("structure tree", () => {
@@ -30,19 +49,9 @@ describe("accessibility", () => {
it("must build structure that maps to text layer", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
- await page.waitForSelector(".structTree");
- const isVisible = await page.evaluate(() => {
- let elem = document.querySelector(".structTree");
- while (elem) {
- if (elem.getAttribute("aria-hidden") === "true") {
- return false;
- }
- elem = elem.parentElement;
- }
- return true;
- });
-
- expect(isVisible).withContext(`In ${browserName}`).toBeTrue();
+ expect(await isStructTreeVisible(page))
+ .withContext(`In ${browserName}`)
+ .toBeTrue();
// Check the headings match up.
const head1 = await page.$eval(
@@ -77,6 +86,22 @@ describe("accessibility", () => {
})
);
});
+
+ it("must check that the struct tree is still there after zooming", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ for (let i = 0; i < 8; i++) {
+ expect(await isStructTreeVisible(page))
+ .withContext(`In ${browserName}`)
+ .toBeTrue();
+
+ const handle = await waitForPageRendered(page);
+ await page.click(`#zoom${i < 4 ? "In" : "Out"}Button`);
+ await awaitPromise(handle);
+ }
+ })
+ );
+ });
});
describe("Annotation", () => {
@@ -184,10 +209,10 @@ describe("accessibility", () => {
it("must check the aria-label linked to the stamp annotation", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
- await page.waitForSelector(".structTree");
+ await page.waitForSelector(".annotationLayer");
const ariaLabel = await page.$eval(
- ".structTree [role='figure']",
+ ".annotationLayer section[role='img']",
el => el.getAttribute("aria-label")
);
expect(ariaLabel)
@@ -216,4 +241,44 @@ describe("accessibility", () => {
);
});
});
+
+ describe("Figure in the content stream", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait("bug1708040.pdf", ".textLayer");
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must check that an image is correctly inserted in the text layer", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ expect(await isStructTreeVisible(page))
+ .withContext(`In ${browserName}`)
+ .toBeTrue();
+
+ const spanId = await page.evaluate(() => {
+ const el = document.querySelector(
+ `.structTree span[role="figure"]`
+ );
+ return el.getAttribute("aria-owns") || null;
+ });
+
+ expect(spanId).withContext(`In ${browserName}`).not.toBeNull();
+
+ const ariaLabel = await page.evaluate(id => {
+ const img = document.querySelector(`#${id} > span[role="img"]`);
+ return img.getAttribute("aria-label");
+ }, spanId);
+
+ expect(ariaLabel)
+ .withContext(`In ${browserName}`)
+ .toEqual("A logo of a fox and a globe");
+ })
+ );
+ });
+ });
});
diff --git a/test/integration/find_spec.mjs b/test/integration/find_spec.mjs
index 3e84abcba..b73f499ae 100644
--- a/test/integration/find_spec.mjs
+++ b/test/integration/find_spec.mjs
@@ -40,10 +40,10 @@ describe("find bar", () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
// Highlight all occurrences of the letter A (case insensitive).
- await page.click("#viewFind");
- await page.waitForSelector("#viewFind", { hidden: false });
+ await page.click("#viewFindButton");
+ await page.waitForSelector("#viewFindButton", { hidden: false });
await page.type("#findInput", "a");
- await page.click("#findHighlightAll");
+ await page.click("#findHighlightAll + label");
await page.waitForSelector(".textLayer .highlight");
// The PDF file contains the text 'AB BA' in a monospace font on a
@@ -100,8 +100,8 @@ describe("find bar", () => {
it("must search xfa correctly", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
- await page.click("#viewFind");
- await page.waitForSelector("#viewFind", { hidden: false });
+ await page.click("#viewFindButton");
+ await page.waitForSelector("#viewFindButton", { hidden: false });
await page.type("#findInput", "preferences");
await page.waitForSelector("#findInput[data-status='']");
await page.waitForSelector(".xfaLayer .highlight");
diff --git a/test/integration/freetext_editor_spec.mjs b/test/integration/freetext_editor_spec.mjs
index 609d6761b..fcf7839e6 100644
--- a/test/integration/freetext_editor_spec.mjs
+++ b/test/integration/freetext_editor_spec.mjs
@@ -1675,7 +1675,7 @@ describe("FreeText Editor", () => {
clip: rect,
type: "png",
});
- const editorImage = PNG.sync.read(editorPng);
+ const editorImage = PNG.sync.read(Buffer.from(editorPng));
const editorFirstPix = getFirstPixel(
editorImage.data,
editorImage.width,
@@ -1703,7 +1703,7 @@ describe("FreeText Editor", () => {
clip: rect,
type: "png",
});
- const editorImage = PNG.sync.read(editorPng);
+ const editorImage = PNG.sync.read(Buffer.from(editorPng));
const editorFirstPix = getFirstPixel(
editorImage.data,
editorImage.width,
@@ -1836,7 +1836,7 @@ describe("FreeText Editor", () => {
clip: rect,
type: "png",
});
- const editorImage = PNG.sync.read(editorPng);
+ const editorImage = PNG.sync.read(Buffer.from(editorPng));
const editorFirstPix = getFirstPixel(
editorImage.data,
editorImage.width,
@@ -1870,7 +1870,7 @@ describe("FreeText Editor", () => {
clip: rect,
type: "png",
});
- const editorImage = PNG.sync.read(editorPng);
+ const editorImage = PNG.sync.read(Buffer.from(editorPng));
const editorFirstPix = getFirstPixel(
editorImage.data,
editorImage.width,
@@ -2741,7 +2741,7 @@ describe("FreeText Editor", () => {
it("must create an editor from the toolbar", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
- await page.focus("#editorFreeText");
+ await page.focus("#editorFreeTextButton");
await page.keyboard.press("Enter");
let selectorEditor = getEditorSelector(0);
@@ -2772,7 +2772,7 @@ describe("FreeText Editor", () => {
// Disable editing mode.
await switchToFreeText(page, /* disable = */ true);
- await page.focus("#editorFreeText");
+ await page.focus("#editorFreeTextButton");
await page.keyboard.press(" ");
selectorEditor = getEditorSelector(1);
await page.waitForSelector(selectorEditor, {
@@ -3589,7 +3589,7 @@ describe("FreeText Editor", () => {
"[data-annotation-id='998R']",
el => (el.hidden = false)
);
- let editorImage = PNG.sync.read(editorPng);
+ let editorImage = PNG.sync.read(Buffer.from(editorPng));
expect(editorImage.data.every(x => x === 0xff))
.withContext(`In ${browserName}`)
.toBeTrue();
@@ -3636,7 +3636,7 @@ describe("FreeText Editor", () => {
clip: editorRect,
type: "png",
});
- editorImage = PNG.sync.read(editorPng);
+ editorImage = PNG.sync.read(Buffer.from(editorPng));
expect(editorImage.data.every(x => x === 0xff))
.withContext(`In ${browserName}`)
.toBeFalse();
@@ -3644,4 +3644,47 @@ describe("FreeText Editor", () => {
);
});
});
+
+ describe("Freetext and shift+enter", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait("empty.pdf", ".annotationEditorLayer");
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must check that a freetext has the correct data", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ await switchToFreeText(page);
+
+ const rect = await getRect(page, ".annotationEditorLayer");
+ const editorSelector = getEditorSelector(0);
+
+ const data = "Hello\nPDF.js\nWorld\n!!";
+ await page.mouse.click(rect.x + 100, rect.y + 100);
+ await page.waitForSelector(editorSelector, {
+ visible: true,
+ });
+ for (const line of data.split("\n")) {
+ await page.type(`${editorSelector} .internal`, line);
+ await page.keyboard.down("Shift");
+ await page.keyboard.press("Enter");
+ await page.keyboard.up("Shift");
+ }
+
+ // Commit.
+ await page.keyboard.press("Escape");
+ await page.waitForSelector(`${editorSelector} .overlay.enabled`);
+ await waitForSerialized(page, 1);
+
+ const serialized = await getSerialized(page, x => x.value);
+ expect(serialized[0]).withContext(`In ${browserName}`).toEqual(data);
+ })
+ );
+ });
+ });
});
diff --git a/test/integration/highlight_editor_spec.mjs b/test/integration/highlight_editor_spec.mjs
index e793ec825..6a7c820e2 100644
--- a/test/integration/highlight_editor_spec.mjs
+++ b/test/integration/highlight_editor_spec.mjs
@@ -32,6 +32,9 @@ import {
scrollIntoView,
setCaretAt,
switchToEditor,
+ waitAndClick,
+ waitForAnnotationModeChanged,
+ waitForSelectedEditor,
waitForSerialized,
} from "./test_utils.mjs";
@@ -1921,4 +1924,180 @@ describe("Highlight Editor", () => {
);
});
});
+
+ describe("Highlight (edit existing in double clicking on it)", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait(
+ "highlights.pdf",
+ ".annotationEditorLayer",
+ null,
+ null,
+ {
+ highlightEditorColors:
+ "yellow=#FFFF00,green=#00FF00,blue=#0000FF,pink=#FF00FF,red=#FF0102",
+ }
+ );
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must change the color of an highlight", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ const modeChangedHandle = await waitForAnnotationModeChanged(page);
+ await waitAndClick(page, "[data-annotation-id='687R']", { count: 2 });
+ await awaitPromise(modeChangedHandle);
+ await page.waitForSelector("#highlightParamsToolbarContainer");
+
+ const editorSelector = getEditorSelector(5);
+ await page.waitForSelector(editorSelector);
+
+ await waitAndClick(
+ page,
+ `${editorSelector} .editToolbar button.colorPicker`
+ );
+ await waitAndClick(
+ page,
+ `${editorSelector} .editToolbar button[title = "Red"]`
+ );
+ await page.waitForSelector(
+ `.page[data-page-number = "1"] svg.highlight[fill = "#FF0102"]`
+ );
+ })
+ );
+ });
+ });
+
+ describe("Free Highlight (edit existing in double clicking on it)", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait(
+ "highlights.pdf",
+ ".annotationEditorLayer",
+ null,
+ null,
+ {
+ highlightEditorColors:
+ "yellow=#FFFF00,green=#00FF00,blue=#0000FF,pink=#FF00FF,red=#FF0102",
+ }
+ );
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must change the color of a free highlight", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ const modeChangedHandle = await waitForAnnotationModeChanged(page);
+ await page.click("[data-annotation-id='693R']", { count: 2 });
+ await awaitPromise(modeChangedHandle);
+ await page.waitForSelector("#highlightParamsToolbarContainer");
+
+ const editorSelector = getEditorSelector(6);
+ await page.waitForSelector(editorSelector);
+ await page.focus(editorSelector);
+ await waitForSelectedEditor(page, editorSelector);
+
+ await waitAndClick(
+ page,
+ `${editorSelector} .editToolbar button.colorPicker`
+ );
+ await waitAndClick(
+ page,
+ `${editorSelector} .editToolbar button[title = "Red"]`
+ );
+ await page.waitForSelector(
+ `.page[data-page-number = "1"] svg.highlight[fill = "#FF0102"]`
+ );
+ })
+ );
+ });
+ });
+
+ describe("Highlight editor mustn't throw when disabled", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait(
+ "annotation-highlight.pdf",
+ ".annotationEditorLayer"
+ );
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must enable & disable highlight mode successfully", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ const modeChangedHandle = await waitForAnnotationModeChanged(page);
+ await switchToHighlight(page);
+ await awaitPromise(modeChangedHandle);
+
+ await page.waitForSelector("#highlightParamsToolbarContainer", {
+ visible: true,
+ });
+ await switchToHighlight(page, /* disable */ true);
+ await page.waitForSelector("#highlightParamsToolbarContainer", {
+ visible: false,
+ });
+ })
+ );
+ });
+ });
+
+ describe("Free Highlight with an image in the struct tree", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait(
+ "bug1708040.pdf",
+ ".annotationEditorLayer",
+ null,
+ null,
+ { highlightEditorColors: "red=#AB0000" }
+ );
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must check that it's possible to draw on an image in a struct tree", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ await switchToHighlight(page);
+
+ const rect = await getRect(page, `.textLayer span[role="img"]`);
+
+ const x = rect.x + rect.width / 2;
+ const y = rect.y + rect.height / 2;
+ const clickHandle = await waitForPointerUp(page);
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(rect.x - 1, rect.y - 1);
+ await page.mouse.up();
+ await awaitPromise(clickHandle);
+
+ await page.waitForSelector(getEditorSelector(0));
+ const usedColor = await page.evaluate(() => {
+ const highlight = document.querySelector(
+ `.page[data-page-number = "1"] .canvasWrapper > svg.highlight`
+ );
+ return highlight.getAttribute("fill");
+ });
+
+ expect(usedColor).withContext(`In ${browserName}`).toEqual("#AB0000");
+ })
+ );
+ });
+ });
});
diff --git a/test/integration/scripting_spec.mjs b/test/integration/scripting_spec.mjs
index d6c8df0e8..cf010758f 100644
--- a/test/integration/scripting_spec.mjs
+++ b/test/integration/scripting_spec.mjs
@@ -439,38 +439,40 @@ describe("Interaction", () => {
let pages;
beforeAll(async () => {
- pages = await loadAndWait("doc_actions.pdf", getSelector("47R"));
+ pages = await loadAndWait("doc_actions.pdf", getSelector("47R"), null, {
+ earlySetup: () => {
+ // No need to trigger the print dialog.
+ window.print = () => {};
+ },
+ });
});
it("must execute WillPrint and DidPrint actions", async () => {
- // Run the tests sequentially to avoid to use the same printer at the same
- // time.
- // And to make sure that a printer isn't locked by a process we close the
- // page before running the next test.
- for (const [browserName, page] of pages) {
- await waitForScripting(page);
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ await waitForScripting(page);
- await clearInput(page, getSelector("47R"));
- await page.evaluate(_ => {
- window.document.activeElement.blur();
- });
- await page.waitForFunction(`${getQuerySelector("47R")}.value === ""`);
+ await clearInput(page, getSelector("47R"));
+ await page.evaluate(_ => {
+ window.document.activeElement.blur();
+ });
+ await page.waitForFunction(`${getQuerySelector("47R")}.value === ""`);
- const text = await actAndWaitForInput(
- page,
- getSelector("47R"),
- async () => {
- await page.click("#print");
- }
- );
- expect(text).withContext(`In ${browserName}`).toEqual("WillPrint");
- await page.keyboard.press("Escape");
+ const text = await actAndWaitForInput(
+ page,
+ getSelector("47R"),
+ async () => {
+ await page.click("#printButton");
+ }
+ );
+ expect(text).withContext(`In ${browserName}`).toEqual("WillPrint");
- await page.waitForFunction(
- `${getQuerySelector("50R")}.value === "DidPrint"`
- );
- await closeSinglePage(page);
- }
+ await page.waitForFunction(
+ `${getQuerySelector("50R")}.value === "DidPrint"`
+ );
+ await closeSinglePage(page);
+ })
+ );
});
});
@@ -507,7 +509,7 @@ describe("Interaction", () => {
page,
getSelector("47R"),
async () => {
- await page.click("#download");
+ await page.click("#downloadButton");
}
);
expect(text).withContext(`In ${browserName}`).toEqual("WillSave");
@@ -1742,7 +1744,6 @@ describe("Interaction", () => {
describe("in autoprint.pdf", () => {
let pages;
- const printHandles = new Map();
beforeAll(async () => {
// Autoprinting is triggered by the `Open` event, which is one of the
@@ -1754,20 +1755,16 @@ describe("Interaction", () => {
// too late will cause it to never resolve because printing is already
// done (and the printed page div removed) before we even get to it.
pages = await loadAndWait("autoprint.pdf", "", null /* zoom = */, {
- postPageSetup: async page => {
- printHandles.set(
- page,
- page.evaluateHandle(() => [
- window.PDFViewerApplication._testPrintResolver.promise,
- ])
- );
+ earlySetup: () => {
+ // No need to trigger the print dialog.
+ window.print = () => {};
},
appSetup: app => {
app._testPrintResolver = Promise.withResolvers();
},
eventBusSetup: eventBus => {
eventBus.on(
- "print",
+ "afterprint",
() => {
window.PDFViewerApplication._testPrintResolver.resolve();
},
@@ -1779,7 +1776,6 @@ describe("Interaction", () => {
afterAll(async () => {
await closePages(pages);
- printHandles.clear();
});
it("must check if printing is triggered when the document is open", async () => {
@@ -1787,7 +1783,11 @@ describe("Interaction", () => {
pages.map(async ([browserName, page]) => {
await waitForScripting(page);
- await awaitPromise(await printHandles.get(page));
+ await awaitPromise(
+ await page.evaluateHandle(() => [
+ window.PDFViewerApplication._testPrintResolver.promise,
+ ])
+ );
})
);
});
@@ -2468,4 +2468,32 @@ describe("Interaction", () => {
);
});
});
+
+ describe("Correctly format numbers", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait("bug1918115.pdf", getSelector("33R"));
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must check that the computed value is correct", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page], i) => {
+ await waitForScripting(page);
+
+ const inputSelector = getSelector("33R");
+ await page.click(inputSelector);
+ await page.type(inputSelector, "7");
+ await page.click(getSelector("34R"));
+ await page.waitForFunction(
+ `${getQuerySelector("35R")}.value === "324,00"`
+ );
+ })
+ );
+ });
+ });
});
diff --git a/test/integration/stamp_editor_spec.mjs b/test/integration/stamp_editor_spec.mjs
index c947d9340..24e60499f 100644
--- a/test/integration/stamp_editor_spec.mjs
+++ b/test/integration/stamp_editor_spec.mjs
@@ -41,6 +41,7 @@ import {
waitForSelectedEditor,
waitForSerialized,
waitForStorageEntries,
+ waitForTimeout,
waitForUnselectedEditor,
} from "./test_utils.mjs";
import { fileURLToPath } from "url";
@@ -150,8 +151,8 @@ describe("Stamp Editor", () => {
const ratio = await page.evaluate(
() => window.pdfjsLib.PixelsPerInch.PDF_TO_CSS_UNITS
);
- expect(bitmap.width).toEqual(Math.round(242 * ratio));
- expect(bitmap.height).toEqual(Math.round(80 * ratio));
+ expect(Math.abs(bitmap.width - 242 * ratio) < 1).toBeTrue();
+ expect(Math.abs(bitmap.height - 80 * ratio) < 1).toBeTrue();
await clearAll(page);
})
@@ -1050,7 +1051,188 @@ describe("Stamp Editor", () => {
tooltipSelector
);
expect(tooltipText).toEqual("Hello World");
+
+ // Click on the Review button.
+ await page.click(buttonSelector);
+ await page.waitForSelector("#newAltTextDialog", { visible: true });
+ await page.click("#newAltTextCreateAutomaticallyButton");
+ await page.click("#newAltTextCancel");
+ await page.waitForSelector("#newAltTextDialog", { visible: false });
+ }
+ });
+
+ it("must check the new alt text flow (part 2)", async () => {
+ // Run sequentially to avoid clipboard issues.
+ for (const [, page] of pages) {
+ await switchToStamp(page);
+
+ // Add an image.
+ await copyImage(page, "../images/firefox_logo.png", 0);
+ const editorSelector = getEditorSelector(0);
+ await page.waitForSelector(editorSelector);
+ await waitForSerialized(page, 1);
+
+ // Wait for the dialog to be visible.
+ await page.waitForSelector("#newAltTextDialog", { visible: true });
+
+ // Wait for the spinner to be visible.
+ await page.waitForSelector("#newAltTextDescriptionContainer.loading");
+
+ // Check we've the disclaimer.
+ await page.waitForSelector("#newAltTextDisclaimer", { visible: true });
+
+ // Click in the textarea in order to stop the guessing.
+ await page.click("#newAltTextDescriptionTextarea");
+ await page.waitForFunction(() =>
+ document
+ .getElementById("newAltTextTitle")
+ .textContent.startsWith("Add ")
+ );
+
+ // Check we haven't the disclaimer.
+ await page.waitForSelector("#newAltTextDisclaimer", { visible: false });
+
+ // Click on the Not Now button.
+ await page.click("#newAltTextNotNow");
+ await page.waitForSelector("#newAltTextDialog", { visible: false });
+ }
+ });
+
+ it("must check the new alt text flow (part 3)", async () => {
+ // Run sequentially to avoid clipboard issues.
+ for (const [, page] of pages) {
+ await page.evaluate(() => {
+ window.PDFViewerApplication.mlManager.enableAltTextModelDownload = false;
+ });
+
+ await switchToStamp(page);
+
+ // Add an image.
+ await copyImage(page, "../images/firefox_logo.png", 0);
+ const editorSelector = getEditorSelector(0);
+ await page.waitForSelector(editorSelector);
+ await waitForSerialized(page, 1);
+
+ // Wait for the dialog to be visible.
+ await page.waitForSelector("#newAltTextDialog", { visible: true });
+
+ // Check we haven't the disclaimer.
+ await page.waitForSelector("#newAltTextDisclaimer[hidden]");
}
});
});
+
+ describe("New alt-text flow (bug 1920515)", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait(
+ "empty.pdf",
+ ".annotationEditorLayer",
+ null,
+ {
+ eventBusSetup: eventBus => {
+ eventBus.on("annotationeditoruimanager", ({ uiManager }) => {
+ window.uiManager = uiManager;
+ });
+ },
+ },
+ {
+ enableAltText: false,
+ enableFakeMLManager: false,
+ enableUpdatedAddImage: true,
+ enableGuessAltText: true,
+ }
+ );
+ });
+
+ afterEach(async () => {
+ for (const [, page] of pages) {
+ if (await isVisible(page, "#newAltTextDialog")) {
+ await page.keyboard.press("Escape");
+ await page.waitForSelector("#newAltTextDisclaimer", {
+ visible: false,
+ });
+ }
+ await page.evaluate(() => {
+ window.uiManager.reset();
+ });
+ // Disable editing mode.
+ await switchToStamp(page, /* disable */ true);
+ }
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must check that the toggle button isn't displayed when there is no AI", async () => {
+ // Run sequentially to avoid clipboard issues.
+ for (const [, page] of pages) {
+ await switchToStamp(page);
+
+ // Add an image.
+ await copyImage(page, "../images/firefox_logo.png", 0);
+ const editorSelector = getEditorSelector(0);
+ await page.waitForSelector(editorSelector);
+ await waitForSerialized(page, 1);
+
+ // Wait for the dialog to be visible.
+ await page.waitForSelector("#newAltTextDialog.noAi", { visible: true });
+
+ // enableFakeMLManager is false, so it means that we don't have ML but
+ // we're using the new flow, hence we don't want to have the toggle
+ // button.
+ await page.waitForSelector("#newAltTextCreateAutomatically", {
+ hidden: true,
+ });
+ }
+ });
+ });
+
+ describe("No auto-resize", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait("empty.pdf", ".annotationEditorLayer", 67);
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must check that a stamp editor isn't resizing itself", async () => {
+ // Run sequentially to avoid clipboard issues.
+ const editorSelector = getEditorSelector(0);
+
+ for (const [, page] of pages) {
+ await switchToStamp(page);
+
+ await copyImage(page, "../images/firefox_logo.png", 0);
+ await page.waitForSelector(editorSelector);
+ await waitForSerialized(page, 1);
+ }
+
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ const getDims = () =>
+ page.evaluate(sel => {
+ const bbox = document.querySelector(sel).getBoundingClientRect();
+ return `${bbox.width}::${bbox.height}`;
+ }, editorSelector);
+ const initialDims = await getDims();
+ for (let i = 0; i < 50; i++) {
+ // We want to make sure that the editor doesn't resize itself, so we
+ // check every 10ms that the dimensions are the same.
+
+ // eslint-disable-next-line no-restricted-syntax
+ await waitForTimeout(10);
+
+ const dims = await getDims();
+ expect(dims).withContext(`In ${browserName}`).toEqual(initialDims);
+ }
+ })
+ );
+ });
+ });
});
diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs
index 1a1a4ab2b..66673867d 100644
--- a/test/integration/test_utils.mjs
+++ b/test/integration/test_utils.mjs
@@ -60,11 +60,15 @@ function loadAndWait(filename, selector, zoom, setups, options) {
// and EventBus, so we can inject some code to do whatever we want
// soon enough especially before the first event in the eventBus is
// dispatched.
- const { prePageSetup, appSetup, eventBusSetup } = setups;
+ const { prePageSetup, appSetup, earlySetup, eventBusSetup } = setups;
await prePageSetup?.(page);
- if (appSetup || eventBusSetup) {
+ if (earlySetup || appSetup || eventBusSetup) {
await page.evaluateOnNewDocument(
- (aSetup, eSetup) => {
+ (eaSetup, aSetup, evSetup) => {
+ if (eaSetup) {
+ // eslint-disable-next-line no-eval
+ eval(`(${eaSetup})`)();
+ }
let app;
let eventBus;
Object.defineProperty(window, "PDFViewerApplication", {
@@ -83,13 +87,16 @@ function loadAndWait(filename, selector, zoom, setups, options) {
},
set(newV) {
eventBus = newV;
- // eslint-disable-next-line no-eval
- eval(`(${eSetup})`)(eventBus);
+ if (evSetup) {
+ // eslint-disable-next-line no-eval
+ eval(`(${evSetup})`)(eventBus);
+ }
},
});
},
});
},
+ earlySetup?.toString(),
appSetup?.toString(),
eventBusSetup?.toString()
);
@@ -187,6 +194,11 @@ async function clearInput(page, selector, waitForInputEvent = false) {
: action();
}
+async function waitAndClick(page, selector, clickOptions = {}) {
+ await page.waitForSelector(selector, { visible: true });
+ await page.click(selector, clickOptions);
+}
+
function getSelector(id) {
return `[data-element-id="${id}"]`;
}
@@ -737,7 +749,7 @@ async function switchToEditor(name, page, disable = false) {
{ once: true }
);
});
- await page.click(`#editor${name}`);
+ await page.click(`#editor${name}Button`);
name = name.toLowerCase();
await page.waitForSelector(
".annotationEditorLayer" +
@@ -793,6 +805,7 @@ export {
serializeBitmapDimensions,
setCaretAt,
switchToEditor,
+ waitAndClick,
waitForAnnotationEditorLayer,
waitForAnnotationModeChanged,
waitForEntryInStorage,
diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs
index 61b4f071d..4d14649d2 100644
--- a/test/integration/viewer_spec.mjs
+++ b/test/integration/viewer_spec.mjs
@@ -19,7 +19,10 @@ import {
createPromise,
getSpanRectFromText,
loadAndWait,
+ scrollIntoView,
+ waitForPageRendered,
} from "./test_utils.mjs";
+import { PNG } from "pngjs";
describe("PDF viewer", () => {
describe("Zoom origin", () => {
@@ -365,4 +368,75 @@ describe("PDF viewer", () => {
});
});
});
+
+ describe("Canvas fits the page", () => {
+ let pages;
+
+ beforeAll(async () => {
+ pages = await loadAndWait(
+ "issue18694.pdf",
+ ".textLayer .endOfContent",
+ "page-width"
+ );
+ });
+
+ afterAll(async () => {
+ await closePages(pages);
+ });
+
+ it("must check that canvas perfectly fits the page whatever the zoom level is", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ const debug = false;
+
+ // The pdf has a single page with a red background.
+ // We set the viewer background to red, because when screenshoting
+ // some part of the viewer background can be visible.
+ // But here we don't care about the viewer background: we only
+ // care about the page background and the canvas default color.
+
+ await page.evaluate(() => {
+ document.body.style.background = "#ff0000";
+ const toolbar = document.querySelector(".toolbar");
+ toolbar.style.display = "none";
+ });
+ await page.waitForSelector(".toolbar", { visible: false });
+ await page.evaluate(() => {
+ const p = document.querySelector(`.page[data-page-number="1"]`);
+ p.style.border = "none";
+ });
+
+ for (let i = 0; ; i++) {
+ const handle = await waitForPageRendered(page);
+ await page.evaluate(() => window.PDFViewerApplication.zoomOut());
+ await awaitPromise(handle);
+ await scrollIntoView(page, `.page[data-page-number="1"]`);
+
+ const element = await page.$(`.page[data-page-number="1"]`);
+ const png = await element.screenshot({
+ type: "png",
+ path: debug ? `foo${i}.png` : "",
+ });
+ const pageImage = PNG.sync.read(Buffer.from(png));
+ let buffer = new Uint32Array(pageImage.data.buffer);
+
+ // Search for the first red pixel.
+ const j = buffer.indexOf(0xff0000ff);
+ buffer = buffer.slice(j);
+
+ expect(buffer.every(x => x === 0xff0000ff))
+ .withContext(`In ${browserName}, in the ${i}th zoom in`)
+ .toBe(true);
+
+ const currentScale = await page.evaluate(
+ () => window.PDFViewerApplication.pdfViewer.currentScale
+ );
+ if (currentScale <= 0.1) {
+ break;
+ }
+ }
+ })
+ );
+ });
+ });
});
diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore
index af276cad1..010995875 100644
--- a/test/pdfs/.gitignore
+++ b/test/pdfs/.gitignore
@@ -564,6 +564,7 @@
!poppler-90-0-fuzzed.pdf
!issue14415.pdf
!issue14307.pdf
+!issue18645.pdf
!issue14497.pdf
!bug1799927.pdf
!issue14502.pdf
@@ -661,3 +662,10 @@
!file_pdfjs_test.pdf
!issue18536.pdf
!issue18561.pdf
+!highlights.pdf
+!highlight.pdf
+!bug1708040.pdf
+!issue18694.pdf
+!issue18693.pdf
+!bug1918115.pdf
+!bug1919513.pdf
diff --git a/test/pdfs/bug1708040.pdf b/test/pdfs/bug1708040.pdf
new file mode 100755
index 000000000..2d0dbb29b
Binary files /dev/null and b/test/pdfs/bug1708040.pdf differ
diff --git a/test/pdfs/bug1918115.pdf b/test/pdfs/bug1918115.pdf
new file mode 100755
index 000000000..927fd12bf
Binary files /dev/null and b/test/pdfs/bug1918115.pdf differ
diff --git a/test/pdfs/bug1919513.pdf b/test/pdfs/bug1919513.pdf
new file mode 100755
index 000000000..fa1ca3210
Binary files /dev/null and b/test/pdfs/bug1919513.pdf differ
diff --git a/test/pdfs/highlight.pdf b/test/pdfs/highlight.pdf
new file mode 100755
index 000000000..93f32f5c2
Binary files /dev/null and b/test/pdfs/highlight.pdf differ
diff --git a/test/pdfs/highlights.pdf b/test/pdfs/highlights.pdf
new file mode 100755
index 000000000..09e7ec9a0
Binary files /dev/null and b/test/pdfs/highlights.pdf differ
diff --git a/test/pdfs/issue18058.pdf.link b/test/pdfs/issue18058.pdf.link
new file mode 100644
index 000000000..b774ddf0a
--- /dev/null
+++ b/test/pdfs/issue18058.pdf.link
@@ -0,0 +1 @@
+https://github.com/mozilla/pdf.js/files/15269193/file.pdf
diff --git a/test/pdfs/issue18645.pdf b/test/pdfs/issue18645.pdf
new file mode 100644
index 000000000..deb824088
Binary files /dev/null and b/test/pdfs/issue18645.pdf differ
diff --git a/test/pdfs/issue18693.pdf b/test/pdfs/issue18693.pdf
new file mode 100755
index 000000000..68c628063
Binary files /dev/null and b/test/pdfs/issue18693.pdf differ
diff --git a/test/pdfs/issue18694.pdf b/test/pdfs/issue18694.pdf
new file mode 100755
index 000000000..8ed563810
Binary files /dev/null and b/test/pdfs/issue18694.pdf differ
diff --git a/test/pdfs/issue18765.pdf.link b/test/pdfs/issue18765.pdf.link
new file mode 100644
index 000000000..5926f0986
--- /dev/null
+++ b/test/pdfs/issue18765.pdf.link
@@ -0,0 +1 @@
+https://github.com/user-attachments/files/17065251/LUCID.-.Asif.Rasha-1.pdf
diff --git a/test/test.mjs b/test/test.mjs
index 0d23a76f0..4d16c8ea1 100644
--- a/test/test.mjs
+++ b/test/test.mjs
@@ -26,7 +26,6 @@ import path from "path";
import puppeteer from "puppeteer";
import readline from "readline";
import { translateFont } from "./font/ttxdriver.mjs";
-import url from "url";
import { WebServer } from "./webserver.mjs";
import yargs from "yargs";
@@ -70,6 +69,11 @@ function parseOptions() {
describe: "Skip Chrome when running tests.",
type: "boolean",
})
+ .option("noFirefox", {
+ default: false,
+ describe: "Skip Firefox when running tests.",
+ type: "boolean",
+ })
.option("noDownload", {
default: false,
describe: "Skip downloading of test PDFs.",
@@ -157,7 +161,7 @@ function parseOptions() {
);
})
.check(argv => {
- if (argv.testfilter && argv.testfilter.length > 0 && argv.xfaOnly) {
+ if (argv.testfilter?.length > 0 && argv.xfaOnly) {
throw new Error("--testfilter and --xfaOnly cannot be used together.");
}
return true;
@@ -670,8 +674,7 @@ function checkRefTestResults(browser, id, results) {
});
}
-function refTestPostHandler(req, res) {
- var parsedUrl = url.parse(req.url, true);
+function refTestPostHandler(parsedUrl, req, res) {
var pathname = parsedUrl.pathname;
if (
pathname !== "/tellMeToQuit" &&
@@ -691,7 +694,7 @@ function refTestPostHandler(req, res) {
var session;
if (pathname === "/tellMeToQuit") {
- session = getSession(parsedUrl.query.browser);
+ session = getSession(parsedUrl.searchParams.get("browser"));
monitorBrowserTimeout(session, null);
closeSession(session.name);
return;
@@ -821,8 +824,7 @@ async function startIntegrationTest() {
await Promise.all(sessions.map(session => closeSession(session.name)));
}
-function unitTestPostHandler(req, res) {
- var parsedUrl = url.parse(req.url);
+function unitTestPostHandler(parsedUrl, req, res) {
var pathname = parsedUrl.pathname;
if (
pathname !== "/tellMeToQuit" &&
@@ -882,7 +884,7 @@ async function startBrowser({
extraPrefsFirefox = {},
}) {
const options = {
- product: browserName,
+ browser: browserName,
protocol: "webDriverBiDi",
headless,
dumpio: true,
@@ -971,7 +973,13 @@ async function startBrowsers({ baseUrl, initializeSession }) {
// prevent the disk from filling up over time.
await puppeteer.trimCache();
- const browserNames = options.noChrome ? ["firefox"] : ["firefox", "chrome"];
+ const browserNames = ["firefox", "chrome"];
+ if (options.noChrome) {
+ browserNames.splice(1, 1);
+ }
+ if (options.noFirefox) {
+ browserNames.splice(0, 1);
+ }
for (const browserName of browserNames) {
// The session must be pushed first and augmented with the browser once
// it's initialized. The reason for this is that browser initialization
diff --git a/test/test_manifest.json b/test/test_manifest.json
index 82e0024cb..9f9b60224 100644
--- a/test/test_manifest.json
+++ b/test/test_manifest.json
@@ -3021,6 +3021,16 @@
"lastPage": 1,
"type": "eq"
},
+ {
+ "id": "issue18765",
+ "file": "pdfs/issue18765.pdf",
+ "md5": "7763ead2aa69db3a5263f92bb1922e31",
+ "link": true,
+ "talos": false,
+ "rounds": 1,
+ "lastPage": 1,
+ "type": "eq"
+ },
{
"id": "issue11139",
"file": "pdfs/issue11139.pdf",
@@ -7986,6 +7996,13 @@
}
}
},
+ {
+ "id": "issue18645",
+ "file": "pdfs/issue18645.pdf",
+ "md5": "ad05b63db4f21f612adb0900093a3e34",
+ "rounds": 1,
+ "type": "eq"
+ },
{
"id": "bug857031",
"file": "pdfs/bug857031.pdf",
@@ -10195,5 +10212,293 @@
"rounds": 1,
"link": true,
"type": "eq"
+ },
+ {
+ "id": "highlight-update-print",
+ "file": "pdfs/highlight.pdf",
+ "md5": "74671e2d9541931a606e886114bf3efa",
+ "rounds": 1,
+ "type": "eq",
+ "print": true,
+ "annotationStorage": {
+ "pdfjs_internal_editor_0": {
+ "annotationType": 9,
+ "color": [83, 255, 188],
+ "opacity": 1,
+ "thickness": 12,
+ "quadPoints": [
+ 224.95899963378906, 649.6790161132812, 257.0950012207031,
+ 649.6790161132812, 224.95899963378906, 665.8670043945312,
+ 257.0950012207031, 665.8670043945312
+ ],
+ "outlines": [
+ [
+ 224.62632386, 649.4661241, 224.62632386, 666.0718055, 257.44364369,
+ 666.0718055, 257.44364369, 649.4661241
+ ]
+ ],
+ "pageIndex": 0,
+ "rect": [224.62632386, 649.4661241, 257.44364369, 666.0718055],
+ "rotation": 0,
+ "structTreeParentId": null,
+ "id": "24R"
+ },
+ "pdfjs_internal_editor_1": {
+ "annotationType": 9,
+ "color": [128, 235, 255],
+ "opacity": 1,
+ "thickness": 12,
+ "quadPoints": null,
+ "outlines": {
+ "outline": [
+ null,
+ null,
+ null,
+ null,
+ 231.02000427246094,
+ 575.9500122070312,
+ 234.9244860593802,
+ 575.8426675799172,
+ 235.67976763594078,
+ 575.8027971554493,
+ 236.50453905085675,
+ 575.765994157359,
+ 237.32931046577278,
+ 575.7291911592689,
+ 238.15139102232476,
+ 575.6967454939373,
+ 238.97078072051278,
+ 575.6686571613642,
+ null,
+ null,
+ null,
+ null,
+ 240.19986526779482,
+ 575.6265246625046,
+ null,
+ null,
+ null,
+ null,
+ 248.19520403340397,
+ 575.3534715117306,
+ null,
+ null,
+ null,
+ null,
+ 248.6047837595648,
+ 587.3464796601443,
+ null,
+ null,
+ null,
+ null,
+ 240.60944499395563,
+ 587.6195328109183,
+ 238.6632878655088,
+ 587.6857348021831,
+ 237.88825461323634,
+ 587.7160188758909,
+ 237.11665096007022,
+ 587.7501062268005,
+ 236.3450473069041,
+ 587.7841935777101,
+ 235.50103901924535,
+ 587.8260330788092,
+ 234.5846260970941,
+ 587.875624730098,
+ null,
+ null,
+ null,
+ null,
+ 231.02000427246094,
+ 587.9500122070312
+ ],
+ "points": [
+ [
+ 231.02000427246094, 581.9500122070312, 241.21000671386722,
+ 581.9500122070312, 243.60000610351562, 581.3499755859375, 246,
+ 581.3499755859375, 248.39999389648438, 581.3499755859375,
+ 248.39999389648438, 581.3499755859375
+ ]
+ ]
+ },
+ "pageIndex": 0,
+ "rect": [
+ 230.69150427246095, 575.1494715117307, 248.9332837595648,
+ 588.1540122070312
+ ],
+ "rotation": 0,
+ "structTreeParentId": null,
+ "id": "41R"
+ }
+ }
+ },
+ {
+ "id": "highlight-update-save-print",
+ "file": "pdfs/highlight.pdf",
+ "md5": "74671e2d9541931a606e886114bf3efa",
+ "rounds": 1,
+ "type": "eq",
+ "save": true,
+ "print": true,
+ "annotationStorage": {
+ "pdfjs_internal_editor_0": {
+ "annotationType": 9,
+ "color": [83, 255, 188],
+ "opacity": 1,
+ "thickness": 12,
+ "quadPoints": [
+ 224.95899963378906, 649.6790161132812, 257.0950012207031,
+ 649.6790161132812, 224.95899963378906, 665.8670043945312,
+ 257.0950012207031, 665.8670043945312
+ ],
+ "outlines": [
+ [
+ 224.62632386, 649.4661241, 224.62632386, 666.0718055, 257.44364369,
+ 666.0718055, 257.44364369, 649.4661241
+ ]
+ ],
+ "pageIndex": 0,
+ "rect": [224.62632386, 649.4661241, 257.44364369, 666.0718055],
+ "rotation": 0,
+ "structTreeParentId": null,
+ "id": "24R"
+ },
+ "pdfjs_internal_editor_1": {
+ "annotationType": 9,
+ "color": [128, 235, 255],
+ "opacity": 1,
+ "thickness": 12,
+ "quadPoints": null,
+ "outlines": {
+ "outline": [
+ null,
+ null,
+ null,
+ null,
+ 231.02000427246094,
+ 575.9500122070312,
+ 234.9244860593802,
+ 575.8426675799172,
+ 235.67976763594078,
+ 575.8027971554493,
+ 236.50453905085675,
+ 575.765994157359,
+ 237.32931046577278,
+ 575.7291911592689,
+ 238.15139102232476,
+ 575.6967454939373,
+ 238.97078072051278,
+ 575.6686571613642,
+ null,
+ null,
+ null,
+ null,
+ 240.19986526779482,
+ 575.6265246625046,
+ null,
+ null,
+ null,
+ null,
+ 248.19520403340397,
+ 575.3534715117306,
+ null,
+ null,
+ null,
+ null,
+ 248.6047837595648,
+ 587.3464796601443,
+ null,
+ null,
+ null,
+ null,
+ 240.60944499395563,
+ 587.6195328109183,
+ 238.6632878655088,
+ 587.6857348021831,
+ 237.88825461323634,
+ 587.7160188758909,
+ 237.11665096007022,
+ 587.7501062268005,
+ 236.3450473069041,
+ 587.7841935777101,
+ 235.50103901924535,
+ 587.8260330788092,
+ 234.5846260970941,
+ 587.875624730098,
+ null,
+ null,
+ null,
+ null,
+ 231.02000427246094,
+ 587.9500122070312
+ ],
+ "points": [
+ [
+ 231.02000427246094, 581.9500122070312, 241.21000671386722,
+ 581.9500122070312, 243.60000610351562, 581.3499755859375, 246,
+ 581.3499755859375, 248.39999389648438, 581.3499755859375,
+ 248.39999389648438, 581.3499755859375
+ ]
+ ]
+ },
+ "pageIndex": 0,
+ "rect": [
+ 230.69150427246095, 575.1494715117307, 248.9332837595648,
+ 588.1540122070312
+ ],
+ "rotation": 0,
+ "structTreeParentId": null,
+ "id": "41R"
+ }
+ }
+ },
+ {
+ "id": "highlight-delete-print",
+ "file": "pdfs/highlight.pdf",
+ "md5": "74671e2d9541931a606e886114bf3efa",
+ "rounds": 1,
+ "type": "eq",
+ "print": true,
+ "annotationStorage": {
+ "pdfjs_internal_editor_0": {
+ "annotationType": 9,
+ "id": "24R",
+ "deleted": true,
+ "pageIndex": 0
+ }
+ }
+ },
+ {
+ "id": "highlight-delete-save-print",
+ "file": "pdfs/highlight.pdf",
+ "md5": "74671e2d9541931a606e886114bf3efa",
+ "rounds": 1,
+ "type": "eq",
+ "print": true,
+ "save": true,
+ "annotationStorage": {
+ "pdfjs_internal_editor_1": {
+ "annotationType": 9,
+ "id": "41R",
+ "deleted": true,
+ "pageIndex": 0
+ }
+ }
+ },
+ {
+ "id": "issue18058",
+ "file": "pdfs/issue18058.pdf",
+ "md5": "da8fcdf25c83ecb2d4be315be5affc3c",
+ "rounds": 1,
+ "link": true,
+ "type": "eq"
+ },
+ {
+ "id": "bug1919513",
+ "file": "pdfs/bug1919513.pdf",
+ "md5": "83d1a7fab2c81e632910c334879e7334",
+ "rounds": 1,
+ "talos": false,
+ "type": "eq"
}
]
diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js
index 94b145892..3a792c715 100644
--- a/test/unit/api_spec.js
+++ b/test/unit/api_spec.js
@@ -69,7 +69,7 @@ describe("api", function () {
let tempServer = null;
beforeAll(function () {
- CanvasFactory = new DefaultCanvasFactory();
+ CanvasFactory = new DefaultCanvasFactory({});
if (isNodeJS) {
tempServer = createTemporaryNodeServer();
@@ -3807,11 +3807,13 @@ Caron Broadcasting, Inc., an Ohio corporation (“Lessee”).`)
role: "Figure",
children: [{ type: "content", id: "p406R_mc11" }],
alt: "d h c s logo",
+ bbox: [57.75, 676, 133.35, 752],
},
{
role: "Figure",
children: [{ type: "content", id: "p406R_mc1" }],
alt: "Great Seal of the State of California",
+ bbox: [481.5, 678, 544.5, 741],
},
{
role: "P",
diff --git a/test/unit/custom_spec.js b/test/unit/custom_spec.js
index ae33736ec..052c60d64 100644
--- a/test/unit/custom_spec.js
+++ b/test/unit/custom_spec.js
@@ -35,7 +35,7 @@ describe("custom canvas rendering", function () {
let page;
beforeAll(async function () {
- CanvasFactory = new DefaultCanvasFactory();
+ CanvasFactory = new DefaultCanvasFactory({});
loadingTask = getDocument(transparentGetDocumentParams);
const doc = await loadingTask.promise;
diff --git a/test/unit/display_utils_spec.js b/test/unit/display_utils_spec.js
index 1d684bfd7..e152d5d9c 100644
--- a/test/unit/display_utils_spec.js
+++ b/test/unit/display_utils_spec.js
@@ -28,7 +28,7 @@ describe("display_utils", function () {
let canvasFactory;
beforeAll(function () {
- canvasFactory = new DOMCanvasFactory();
+ canvasFactory = new DOMCanvasFactory({});
});
afterAll(function () {
diff --git a/test/unit/network_utils_spec.js b/test/unit/network_utils_spec.js
index ee23f2e10..e697e24de 100644
--- a/test/unit/network_utils_spec.js
+++ b/test/unit/network_utils_spec.js
@@ -14,6 +14,7 @@
*/
import {
+ createHeaders,
createResponseStatusError,
extractFilenameFromHeader,
validateRangeRequestCapabilities,
@@ -25,6 +26,44 @@ import {
} from "../../src/shared/util.js";
describe("network_utils", function () {
+ describe("createHeaders", function () {
+ it("returns empty `Headers` for invalid input", function () {
+ const headersArr = [
+ createHeaders(
+ /* isHttp = */ false,
+ /* httpHeaders = */ { "Content-Length": 100 }
+ ),
+ createHeaders(/* isHttp = */ true, /* httpHeaders = */ undefined),
+ createHeaders(/* isHttp = */ true, /* httpHeaders = */ null),
+ createHeaders(/* isHttp = */ true, /* httpHeaders = */ "abc"),
+ createHeaders(/* isHttp = */ true, /* httpHeaders = */ 123),
+ ];
+ const emptyObj = Object.create(null);
+
+ for (const headers of headersArr) {
+ expect(Object.fromEntries(headers)).toEqual(emptyObj);
+ }
+ });
+
+ it("returns populated `Headers` for valid input", function () {
+ const headers = createHeaders(
+ /* isHttp = */ true,
+ /* httpHeaders = */ {
+ "Content-Length": 100,
+ "Accept-Ranges": "bytes",
+ "Dummy-null": null,
+ "Dummy-undefined": undefined,
+ }
+ );
+
+ expect(Object.fromEntries(headers)).toEqual({
+ "content-length": "100",
+ "accept-ranges": "bytes",
+ "dummy-null": "null",
+ });
+ });
+ });
+
describe("validateRangeRequestCapabilities", function () {
it("rejects invalid rangeChunkSize", function () {
expect(function () {
@@ -45,12 +84,9 @@ describe("network_utils", function () {
validateRangeRequestCapabilities({
disableRange: true,
isHttp: true,
- getResponseHeader: headerName => {
- if (headerName === "Content-Length") {
- return 8;
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- },
+ responseHeaders: new Headers({
+ "Content-Length": 8,
+ }),
rangeChunkSize: 64,
})
).toEqual({
@@ -62,12 +98,9 @@ describe("network_utils", function () {
validateRangeRequestCapabilities({
disableRange: false,
isHttp: false,
- getResponseHeader: headerName => {
- if (headerName === "Content-Length") {
- return 8;
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- },
+ responseHeaders: new Headers({
+ "Content-Length": 8,
+ }),
rangeChunkSize: 64,
})
).toEqual({
@@ -81,14 +114,10 @@ describe("network_utils", function () {
validateRangeRequestCapabilities({
disableRange: false,
isHttp: true,
- getResponseHeader: headerName => {
- if (headerName === "Accept-Ranges") {
- return "none";
- } else if (headerName === "Content-Length") {
- return 8;
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- },
+ responseHeaders: new Headers({
+ "Accept-Ranges": "none",
+ "Content-Length": 8,
+ }),
rangeChunkSize: 64,
})
).toEqual({
@@ -102,16 +131,11 @@ describe("network_utils", function () {
validateRangeRequestCapabilities({
disableRange: false,
isHttp: true,
- getResponseHeader: headerName => {
- if (headerName === "Accept-Ranges") {
- return "bytes";
- } else if (headerName === "Content-Encoding") {
- return "gzip";
- } else if (headerName === "Content-Length") {
- return 8;
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- },
+ responseHeaders: new Headers({
+ "Accept-Ranges": "bytes",
+ "Content-Encoding": "gzip",
+ "Content-Length": 8,
+ }),
rangeChunkSize: 64,
})
).toEqual({
@@ -125,16 +149,10 @@ describe("network_utils", function () {
validateRangeRequestCapabilities({
disableRange: false,
isHttp: true,
- getResponseHeader: headerName => {
- if (headerName === "Accept-Ranges") {
- return "bytes";
- } else if (headerName === "Content-Encoding") {
- return null;
- } else if (headerName === "Content-Length") {
- return "eight";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- },
+ responseHeaders: new Headers({
+ "Accept-Ranges": "bytes",
+ "Content-Length": "eight",
+ }),
rangeChunkSize: 64,
})
).toEqual({
@@ -148,16 +166,10 @@ describe("network_utils", function () {
validateRangeRequestCapabilities({
disableRange: false,
isHttp: true,
- getResponseHeader: headerName => {
- if (headerName === "Accept-Ranges") {
- return "bytes";
- } else if (headerName === "Content-Encoding") {
- return null;
- } else if (headerName === "Content-Length") {
- return 8;
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- },
+ responseHeaders: new Headers({
+ "Accept-Ranges": "bytes",
+ "Content-Length": 8,
+ }),
rangeChunkSize: 64,
})
).toEqual({
@@ -171,16 +183,10 @@ describe("network_utils", function () {
validateRangeRequestCapabilities({
disableRange: false,
isHttp: true,
- getResponseHeader: headerName => {
- if (headerName === "Accept-Ranges") {
- return "bytes";
- } else if (headerName === "Content-Encoding") {
- return null;
- } else if (headerName === "Content-Length") {
- return 8192;
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- },
+ responseHeaders: new Headers({
+ "Accept-Ranges": "bytes",
+ "Content-Length": 8192,
+ }),
rangeChunkSize: 64,
})
).toEqual({
@@ -193,194 +199,173 @@ describe("network_utils", function () {
describe("extractFilenameFromHeader", function () {
it("returns null when content disposition header is blank", function () {
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return null;
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ // Empty headers.
+ })
+ )
).toBeNull();
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return undefined;
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
- ).toBeNull();
-
- expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": "",
+ })
+ )
).toBeNull();
});
it("gets the filename from the response header", function () {
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "inline";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": "inline",
+ })
+ )
).toBeNull();
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "attachment";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": "attachment",
+ })
+ )
).toBeNull();
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return 'attachment; filename="filename.pdf"';
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": 'attachment; filename="filename.pdf"',
+ })
+ )
).toEqual("filename.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return 'attachment; filename="filename.pdf and spaces.pdf"';
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition":
+ 'attachment; filename="filename.pdf and spaces.pdf"',
+ })
+ )
).toEqual("filename.pdf and spaces.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return 'attachment; filename="tl;dr.pdf"';
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": 'attachment; filename="tl;dr.pdf"',
+ })
+ )
).toEqual("tl;dr.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "attachment; filename=filename.pdf";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": "attachment; filename=filename.pdf",
+ })
+ )
).toEqual("filename.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "attachment; filename=filename.pdf someotherparam";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition":
+ "attachment; filename=filename.pdf someotherparam",
+ })
+ )
).toEqual("filename.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return 'attachment; filename="%e4%b8%ad%e6%96%87.pdf"';
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition":
+ 'attachment; filename="%e4%b8%ad%e6%96%87.pdf"',
+ })
+ )
).toEqual("中文.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return 'attachment; filename="100%.pdf"';
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": 'attachment; filename="100%.pdf"',
+ })
+ )
).toEqual("100%.pdf");
});
it("gets the filename from the response header (RFC 6266)", function () {
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "attachment; filename*=filename.pdf";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": "attachment; filename*=filename.pdf",
+ })
+ )
).toEqual("filename.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "attachment; filename*=''filename.pdf";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": "attachment; filename*=''filename.pdf",
+ })
+ )
).toEqual("filename.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "attachment; filename*=utf-8''filename.pdf";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": "attachment; filename*=utf-8''filename.pdf",
+ })
+ )
).toEqual("filename.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "attachment; filename=no.pdf; filename*=utf-8''filename.pdf";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition":
+ "attachment; filename=no.pdf; filename*=utf-8''filename.pdf",
+ })
+ )
).toEqual("filename.pdf");
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "attachment; filename*=utf-8''filename.pdf; filename=no.pdf";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition":
+ "attachment; filename*=utf-8''filename.pdf; filename=no.pdf",
+ })
+ )
).toEqual("filename.pdf");
});
it("gets the filename from the response header (RFC 2231)", function () {
// Tests continuations (RFC 2231 section 3, via RFC 5987 section 3.1).
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return "attachment; filename*0=filename; filename*1=.pdf";
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition":
+ "attachment; filename*0=filename; filename*1=.pdf",
+ })
+ )
).toEqual("filename.pdf");
});
it("only extracts filename with pdf extension", function () {
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return 'attachment; filename="filename.png"';
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition": 'attachment; filename="filename.png"',
+ })
+ )
).toBeNull();
});
it("extension validation is case insensitive", function () {
expect(
- extractFilenameFromHeader(headerName => {
- if (headerName === "Content-Disposition") {
- return 'form-data; name="fieldName"; filename="file.PdF"';
- }
- throw new Error(`Unexpected headerName: ${headerName}`);
- })
+ extractFilenameFromHeader(
+ new Headers({
+ "Content-Disposition":
+ 'form-data; name="fieldName"; filename="file.PdF"',
+ })
+ )
).toEqual("file.PdF");
});
});
diff --git a/test/unit/node_stream_spec.js b/test/unit/node_stream_spec.js
index f3a2daa75..775131caa 100644
--- a/test/unit/node_stream_spec.js
+++ b/test/unit/node_stream_spec.js
@@ -24,17 +24,13 @@ if (!isNodeJS) {
);
}
-const path = await __non_webpack_import__("path");
const url = await __non_webpack_import__("url");
describe("node_stream", function () {
let tempServer = null;
- const pdf = url.parse(
- encodeURI(
- "file://" + path.join(process.cwd(), "./test/pdfs/tracemonkey.pdf")
- )
- ).href;
+ const cwdURL = url.pathToFileURL(process.cwd()) + "/";
+ const pdf = new URL("./test/pdfs/tracemonkey.pdf", cwdURL).href;
const pdfLength = 1016315;
beforeAll(function () {
diff --git a/test/unit/parser_spec.js b/test/unit/parser_spec.js
index b286dde86..404d729a1 100644
--- a/test/unit/parser_spec.js
+++ b/test/unit/parser_spec.js
@@ -201,11 +201,12 @@ describe("parser", function () {
});
describe("getHexString", function () {
- it("should not throw exception on bad input", function () {
- // '7 0 2 15 5 2 2 2 4 3 2 4' should be parsed as '70 21 55 22 24 32'.
+ it("should handle an odd number of digits", function () {
+ // '7 0 2 15 5 2 2 2 4 3 2 4' should be parsed as
+ // '70 21 55 22 24 32 40'.
const input = new StringStream("<7 0 2 15 5 2 2 2 4 3 2 4>");
const lexer = new Lexer(input);
- expect(lexer.getHexString()).toEqual('p!U"$2');
+ expect(lexer.getHexString()).toEqual('p!U"$2@');
});
});
diff --git a/test/unit/pdf_find_controller_spec.js b/test/unit/pdf_find_controller_spec.js
index e1f3169d5..408cba67d 100644
--- a/test/unit/pdf_find_controller_spec.js
+++ b/test/unit/pdf_find_controller_spec.js
@@ -1062,6 +1062,26 @@ describe("pdf_find_controller", function () {
await testOnFind({ eventBus });
});
+ it("performs a search in a text with compound word on two lines", async function () {
+ const { eventBus, pdfFindController } =
+ await initPdfFindController("issue18693.pdf");
+
+ await testSearch({
+ eventBus,
+ pdfFindController,
+ state: {
+ query: "hel-Lo",
+ },
+ matchesPerPage: [1],
+ selectedMatch: {
+ pageIndex: 0,
+ matchIndex: 0,
+ },
+ pageMatches: [[6]],
+ pageMatchesLength: [[7]],
+ });
+ });
+
describe("custom matcher", () => {
it("calls to the matcher with the right arguments", async () => {
const QUERY = "Foo bar";
diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js
index 7e2224c8d..0a93ced31 100644
--- a/test/unit/pdf_spec.js
+++ b/test/unit/pdf_spec.js
@@ -50,6 +50,7 @@ import {
isDataScheme,
isPdfFile,
noContextMenu,
+ OutputScale,
PDFDateString,
PixelsPerInch,
RenderingCancelledException,
@@ -93,6 +94,7 @@ const expectedAPI = Object.freeze({
noContextMenu,
normalizeUnicode,
OPS,
+ OutputScale,
PasswordResponses,
PDFDataRangeTransport,
PDFDateString,
diff --git a/test/unit/scripting_spec.js b/test/unit/scripting_spec.js
index 505a990ff..12c7161c2 100644
--- a/test/unit/scripting_spec.js
+++ b/test/unit/scripting_spec.js
@@ -1571,6 +1571,67 @@ describe("Scripting", function () {
send_queue.delete(refId);
});
+ it("should validate a US phone number with digits only (long) on a keystroke event", async () => {
+ const refId = getId();
+ const data = {
+ objects: {
+ field: [
+ {
+ id: refId,
+ value: "",
+ actions: {
+ Keystroke: [`AFSpecial_Keystroke(2);`],
+ },
+ type: "text",
+ },
+ ],
+ },
+ appInfo: { language: "en-US", platform: "Linux x86_64" },
+ calculationOrder: [],
+ dispatchEventName: "_dispatchMe",
+ };
+ sandbox.createSandbox(data);
+
+ let value = "";
+ const changes = "1234567890";
+ let i = 0;
+
+ for (; i < changes.length; i++) {
+ const change = changes.charAt(i);
+ await sandbox.dispatchEventInSandbox({
+ id: refId,
+ value,
+ change,
+ name: "Keystroke",
+ willCommit: false,
+ selStart: i,
+ selEnd: i,
+ });
+ expect(send_queue.has(refId)).toEqual(true);
+ send_queue.delete(refId);
+ value += change;
+ }
+
+ await sandbox.dispatchEventInSandbox({
+ id: refId,
+ value,
+ change: "A",
+ name: "Keystroke",
+ willCommit: false,
+ selStart: i,
+ selEnd: i,
+ });
+ expect(send_queue.has(refId)).toEqual(true);
+ expect(send_queue.get(refId)).toEqual({
+ id: refId,
+ siblings: null,
+ value,
+ selRange: [i, i],
+ });
+
+ send_queue.delete(refId);
+ });
+
it("should validate a US phone number (short) on a keystroke event", async () => {
const refId = getId();
const data = {
@@ -1631,6 +1692,67 @@ describe("Scripting", function () {
send_queue.delete(refId);
});
+
+ it("should validate a US phone number with digits only (short) on a keystroke event", async () => {
+ const refId = getId();
+ const data = {
+ objects: {
+ field: [
+ {
+ id: refId,
+ value: "",
+ actions: {
+ Keystroke: [`AFSpecial_Keystroke(2);`],
+ },
+ type: "text",
+ },
+ ],
+ },
+ appInfo: { language: "en-US", platform: "Linux x86_64" },
+ calculationOrder: [],
+ dispatchEventName: "_dispatchMe",
+ };
+ sandbox.createSandbox(data);
+
+ let value = "";
+ const changes = "1234567";
+ let i = 0;
+
+ for (; i < changes.length; i++) {
+ const change = changes.charAt(i);
+ await sandbox.dispatchEventInSandbox({
+ id: refId,
+ value,
+ change,
+ name: "Keystroke",
+ willCommit: false,
+ selStart: i,
+ selEnd: i,
+ });
+ expect(send_queue.has(refId)).toEqual(true);
+ send_queue.delete(refId);
+ value += change;
+ }
+
+ await sandbox.dispatchEventInSandbox({
+ id: refId,
+ value,
+ change: "A",
+ name: "Keystroke",
+ willCommit: false,
+ selStart: i,
+ selEnd: i,
+ });
+ expect(send_queue.has(refId)).toEqual(true);
+ expect(send_queue.get(refId)).toEqual({
+ id: refId,
+ siblings: null,
+ value,
+ selRange: [i, i],
+ });
+
+ send_queue.delete(refId);
+ });
});
describe("eMailValidate", function () {
diff --git a/test/unit/struct_tree_spec.js b/test/unit/struct_tree_spec.js
index a4841bffc..055156559 100644
--- a/test/unit/struct_tree_spec.js
+++ b/test/unit/struct_tree_spec.js
@@ -107,4 +107,48 @@ describe("struct tree", function () {
await loadingTask.destroy();
});
});
+
+ it("parses structure with a figure and its bounding box", async function () {
+ const filename = "bug1708040.pdf";
+ const params = buildGetDocumentParams(filename);
+ const loadingTask = getDocument(params);
+ const doc = await loadingTask.promise;
+ const page = await doc.getPage(1);
+ const struct = await page.getStructTree();
+ equalTrees(
+ {
+ children: [
+ {
+ role: "Document",
+ children: [
+ {
+ role: "Sect",
+ children: [
+ {
+ role: "P",
+ children: [{ type: "content", id: "p21R_mc0" }],
+ lang: "EN-US",
+ },
+ {
+ role: "P",
+ children: [{ type: "content", id: "p21R_mc1" }],
+ lang: "EN-US",
+ },
+ {
+ role: "Figure",
+ children: [{ type: "content", id: "p21R_mc2" }],
+ alt: "A logo of a fox and a globe\u0000",
+ bbox: [72, 287.782, 456, 695.032],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ role: "Root",
+ },
+ struct
+ );
+ await loadingTask.destroy();
+ });
});
diff --git a/test/unit/test_utils.js b/test/unit/test_utils.js
index bc1d839d5..507fc1564 100644
--- a/test/unit/test_utils.js
+++ b/test/unit/test_utils.js
@@ -45,15 +45,8 @@ class DOMFileReaderFactory {
class NodeFileReaderFactory {
static async fetch(params) {
- return new Promise((resolve, reject) => {
- fs.readFile(params.path, (error, data) => {
- if (error || !data) {
- reject(error || new Error(`Empty file for: ${params.path}`));
- return;
- }
- resolve(new Uint8Array(data));
- });
- });
+ const data = await fs.promises.readFile(params.path);
+ return new Uint8Array(data);
}
}
@@ -152,33 +145,34 @@ function createTemporaryNodeServer() {
const server = http
.createServer((request, response) => {
const filePath = process.cwd() + "/test/pdfs" + request.url;
- fs.lstat(filePath, (error, stat) => {
- if (error) {
+ fs.promises.lstat(filePath).then(
+ stat => {
+ if (!request.headers.range) {
+ const contentLength = stat.size;
+ const stream = fs.createReadStream(filePath);
+ response.writeHead(200, {
+ "Content-Type": "application/pdf",
+ "Content-Length": contentLength,
+ "Accept-Ranges": "bytes",
+ });
+ stream.pipe(response);
+ } else {
+ const [start, end] = request.headers.range
+ .split("=")[1]
+ .split("-")
+ .map(x => Number(x));
+ const stream = fs.createReadStream(filePath, { start, end });
+ response.writeHead(206, {
+ "Content-Type": "application/pdf",
+ });
+ stream.pipe(response);
+ }
+ },
+ error => {
response.writeHead(404);
response.end(`File ${request.url} not found!`);
- return;
}
- if (!request.headers.range) {
- const contentLength = stat.size;
- const stream = fs.createReadStream(filePath);
- response.writeHead(200, {
- "Content-Type": "application/pdf",
- "Content-Length": contentLength,
- "Accept-Ranges": "bytes",
- });
- stream.pipe(response);
- } else {
- const [start, end] = request.headers.range
- .split("=")[1]
- .split("-")
- .map(x => Number(x));
- const stream = fs.createReadStream(filePath, { start, end });
- response.writeHead(206, {
- "Content-Type": "application/pdf",
- });
- stream.pipe(response);
- }
- });
+ );
})
.listen(0); /* Listen on a random free port */
diff --git a/test/unit/text_layer_spec.js b/test/unit/text_layer_spec.js
index 5b0b8a1df..644e74245 100644
--- a/test/unit/text_layer_spec.js
+++ b/test/unit/text_layer_spec.js
@@ -90,4 +90,164 @@ describe("textLayer", function () {
await loadingTask.destroy();
});
+
+ it("creates textLayers in parallel, from ReadableStream", async function () {
+ if (isNodeJS) {
+ pending("document.createElement is not supported in Node.js.");
+ }
+ if (typeof ReadableStream.from !== "function") {
+ pending("ReadableStream.from is not supported.");
+ }
+ const getTransform = container => {
+ const transform = [];
+
+ for (const span of container.childNodes) {
+ const t = span.style.transform;
+ expect(t).toMatch(/^scaleX\([\d.]+\)$/);
+
+ transform.push(t);
+ }
+ return transform;
+ };
+
+ const loadingTask = getDocument(buildGetDocumentParams("basicapi.pdf"));
+ const pdfDocument = await loadingTask.promise;
+ const [page1, page2] = await Promise.all([
+ pdfDocument.getPage(1),
+ pdfDocument.getPage(2),
+ ]);
+
+ // Create text-content streams with dummy content.
+ const items1 = [
+ {
+ str: "Chapter A",
+ dir: "ltr",
+ width: 100,
+ height: 20,
+ transform: [20, 0, 0, 20, 45, 744],
+ fontName: "g_d0_f1",
+ hasEOL: false,
+ },
+ {
+ str: "page 1",
+ dir: "ltr",
+ width: 50,
+ height: 20,
+ transform: [20, 0, 0, 20, 45, 744],
+ fontName: "g_d0_f1",
+ hasEOL: false,
+ },
+ ];
+ const items2 = [
+ {
+ str: "Chapter B",
+ dir: "ltr",
+ width: 120,
+ height: 10,
+ transform: [10, 0, 0, 10, 492, 16],
+ fontName: "g_d0_f2",
+ hasEOL: false,
+ },
+ {
+ str: "page 2",
+ dir: "ltr",
+ width: 60,
+ height: 10,
+ transform: [10, 0, 0, 10, 492, 16],
+ fontName: "g_d0_f2",
+ hasEOL: false,
+ },
+ ];
+
+ const styles = {
+ g_d0_f1: {
+ ascent: 0.75,
+ descent: -0.25,
+ fontFamily: "serif",
+ vertical: false,
+ },
+ g_d0_f2: {
+ ascent: 0.5,
+ descent: -0.5,
+ fontFamily: "sans-serif",
+ vertical: false,
+ },
+ };
+ const lang = "en";
+
+ // Render the textLayers serially, to have something to compare against.
+ const serialContainer1 = document.createElement("div"),
+ serialContainer2 = document.createElement("div");
+
+ const serialTextLayer1 = new TextLayer({
+ textContentSource: { items: items1, styles, lang },
+ container: serialContainer1,
+ viewport: page1.getViewport({ scale: 1 }),
+ });
+ await serialTextLayer1.render();
+
+ const serialTextLayer2 = new TextLayer({
+ textContentSource: { items: items2, styles, lang },
+ container: serialContainer2,
+ viewport: page2.getViewport({ scale: 1 }),
+ });
+ await serialTextLayer2.render();
+
+ const serialTransform1 = getTransform(serialContainer1),
+ serialTransform2 = getTransform(serialContainer2);
+
+ expect(serialTransform1.length).toEqual(2);
+ expect(serialTransform2.length).toEqual(2);
+
+ // Reset any global textLayer-state before rendering in parallel.
+ TextLayer.cleanup();
+
+ const container1 = document.createElement("div"),
+ container2 = document.createElement("div");
+ const waitCapability1 = Promise.withResolvers();
+
+ const streamGenerator1 = (async function* () {
+ for (const item of items1) {
+ yield { items: [item], styles, lang };
+ await waitCapability1.promise;
+ }
+ })();
+ const streamGenerator2 = (async function* () {
+ for (const item of items2) {
+ yield { items: [item], styles, lang };
+ }
+ })();
+
+ const textLayer1 = new TextLayer({
+ textContentSource: ReadableStream.from(streamGenerator1),
+ container: container1,
+ viewport: page1.getViewport({ scale: 1 }),
+ });
+ const textLayer1Promise = textLayer1.render();
+
+ const textLayer2 = new TextLayer({
+ textContentSource: ReadableStream.from(streamGenerator2),
+ container: container2,
+ viewport: page2.getViewport({ scale: 1 }),
+ });
+ await textLayer2.render();
+
+ // Ensure that the first textLayer has its rendering "paused" while
+ // the second textLayer renders.
+ waitCapability1.resolve();
+ await textLayer1Promise;
+
+ // Sanity check to make sure that all text was parsed.
+ expect(textLayer1.textContentItemsStr).toEqual(["Chapter A", "page 1"]);
+ expect(textLayer2.textContentItemsStr).toEqual(["Chapter B", "page 2"]);
+
+ // Ensure that the transforms are identical when parsing in series/parallel.
+ const transform1 = getTransform(container1),
+ transform2 = getTransform(container2);
+
+ expect(transform1).toEqual(serialTransform1);
+ expect(transform2).toEqual(serialTransform2);
+
+ await loadingTask.destroy();
+ });
});
diff --git a/test/unit/ui_utils_spec.js b/test/unit/ui_utils_spec.js
index 4935489f7..0ead19043 100644
--- a/test/unit/ui_utils_spec.js
+++ b/test/unit/ui_utils_spec.js
@@ -16,6 +16,7 @@
import {
backtrackBeforeAllVisibleElements,
binarySearchFirstItem,
+ calcRound,
getPageSizeInches,
getVisibleElements,
isPortraitOrientation,
@@ -627,4 +628,17 @@ describe("ui_utils", function () {
});
});
});
+
+ describe("calcRound", function () {
+ it("should handle different browsers/environments correctly", function () {
+ if (
+ typeof window !== "undefined" &&
+ window.navigator?.userAgent?.includes("Firefox")
+ ) {
+ expect(calcRound(1.6)).not.toEqual(1.6);
+ } else {
+ expect(calcRound(1.6)).toEqual(1.6);
+ }
+ });
+ });
});
diff --git a/test/webserver.mjs b/test/webserver.mjs
index d597aa251..e0295f3a2 100644
--- a/test/webserver.mjs
+++ b/test/webserver.mjs
@@ -21,6 +21,7 @@ import fs from "fs";
import fsPromises from "fs/promises";
import http from "http";
import path from "path";
+import { pathToFileURL } from "url";
const MIME_TYPES = {
".css": "text/css",
@@ -42,7 +43,8 @@ const DEFAULT_MIME_TYPE = "application/octet-stream";
class WebServer {
constructor({ root, host, port, cacheExpirationTime }) {
- this.root = root || ".";
+ const cwdURL = pathToFileURL(process.cwd()) + "/";
+ this.rootURL = new URL(`${root || "."}/`, cwdURL);
this.host = host || "localhost";
this.port = port || 0;
this.server = null;
@@ -82,27 +84,10 @@ class WebServer {
}
async #handler(request, response) {
- // Validate and parse the request URL.
- const url = request.url.replaceAll("//", "/");
- const urlParts = /([^?]*)((?:\?(.*))?)/.exec(url);
- let pathPart;
- try {
- // Guard against directory traversal attacks such as
- // `/../../../../../../../etc/passwd`, which let you make GET requests
- // for files outside of `this.root`.
- pathPart = path.normalize(decodeURI(urlParts[1]));
- // `path.normalize` returns a path on the basis of the current platform.
- // Windows paths cause issues in `checkRequest` and underlying methods.
- // Converting to a Unix path avoids platform checks in said functions.
- pathPart = pathPart.replaceAll("\\", "/");
- } catch {
- // If the URI cannot be decoded, a `URIError` is thrown. This happens for
- // malformed URIs such as `http://localhost:8888/%s%s` and should be
- // handled as a bad request.
- response.writeHead(400);
- response.end("Bad request", "utf8");
- return;
- }
+ // URLs are normalized and automatically disallow directory traversal
+ // attacks. For example, http://HOST:PORT/../../../../../../../etc/passwd
+ // is equivalent to http://HOST:PORT/etc/passwd.
+ const url = new URL(`http://${this.host}:${this.port}${request.url}`);
// Validate the request method and execute method hooks.
const methodHooks = this.hooks[request.method];
@@ -111,24 +96,34 @@ class WebServer {
response.end("Unsupported request method", "utf8");
return;
}
- const handled = methodHooks.some(hook => hook(request, response));
+ const handled = methodHooks.some(hook => hook(url, request, response));
if (handled) {
return;
}
// Check the request and serve the file/folder contents.
- if (pathPart === "/favicon.ico") {
- pathPart = "test/resources/favicon.ico";
+ if (url.pathname === "/favicon.ico") {
+ url.pathname = "/test/resources/favicon.ico";
}
- await this.#checkRequest(request, response, url, urlParts, pathPart);
+ await this.#checkRequest(request, response, url);
}
- async #checkRequest(request, response, url, urlParts, pathPart) {
+ async #checkRequest(request, response, url) {
+ const localURL = new URL(`.${url.pathname}`, this.rootURL);
+
// Check if the file/folder exists.
- let filePath;
try {
- filePath = await fsPromises.realpath(path.join(this.root, pathPart));
- } catch {
+ await fsPromises.realpath(localURL);
+ } catch (e) {
+ if (e instanceof URIError) {
+ // If the URI cannot be decoded, a `URIError` is thrown. This happens
+ // for malformed URIs such as `http://localhost:8888/%s%s` and should be
+ // handled as a bad request.
+ response.writeHead(400);
+ response.end("Bad request", "utf8");
+ return;
+ }
+
response.writeHead(404);
response.end();
if (this.verbose) {
@@ -140,7 +135,7 @@ class WebServer {
// Get the properties of the file/folder.
let stats;
try {
- stats = await fsPromises.stat(filePath);
+ stats = await fsPromises.stat(localURL);
} catch {
response.writeHead(500);
response.end();
@@ -150,15 +145,14 @@ class WebServer {
const isDir = stats.isDirectory();
// If a folder is requested, serve the directory listing.
- if (isDir && !/\/$/.test(pathPart)) {
- response.setHeader("Location", `${pathPart}/${urlParts[2]}`);
+ if (isDir && !/\/$/.test(url.pathname)) {
+ response.setHeader("Location", `${url.pathname}/${url.search}`);
response.writeHead(301);
response.end("Redirected", "utf8");
return;
}
if (isDir) {
- const queryPart = urlParts[3];
- await this.#serveDirectoryIndex(response, pathPart, queryPart, filePath);
+ await this.#serveDirectoryIndex(response, url, localURL);
return;
}
@@ -182,7 +176,7 @@ class WebServer {
}
this.#serveFileRange(
response,
- filePath,
+ localURL,
fileSize,
start,
isNaN(end) ? fileSize : end + 1
@@ -194,19 +188,19 @@ class WebServer {
if (this.verbose) {
console.log(url);
}
- this.#serveFile(response, filePath, fileSize);
+ this.#serveFile(response, localURL, fileSize);
}
- async #serveDirectoryIndex(response, pathPart, queryPart, directory) {
+ async #serveDirectoryIndex(response, url, localUrl) {
response.setHeader("Content-Type", "text/html");
response.writeHead(200);
- if (queryPart === "frame") {
+ if (url.searchParams.has("frame")) {
response.end(
`
`,
"utf8"
@@ -216,7 +210,7 @@ class WebServer {
let files;
try {
- files = await fsPromises.readdir(directory);
+ files = await fsPromises.readdir(localUrl);
} catch {
response.end();
return;
@@ -228,13 +222,13 @@ class WebServer {
- Index of ${pathPart}
`
+ Index of ${url.pathname}
`
);
- if (pathPart !== "/") {
+ if (url.pathname !== "/") {
response.write('..
');
}
- const all = queryPart === "all";
+ const all = url.searchParams.has("all");
const escapeHTML = untrusted =>
// Escape untrusted input so that it can safely be used in a HTML response
// in HTML and in HTML attributes.
@@ -247,13 +241,13 @@ class WebServer {
for (const file of files) {
let stat;
- const item = pathPart + file;
+ const item = url.pathname + file;
let href = "";
let label = "";
let extraAttributes = "";
try {
- stat = fs.statSync(path.join(directory, file));
+ stat = fs.statSync(new URL(file, localUrl));
} catch (ex) {
href = encodeURI(item);
label = `${file} (${ex})`;
@@ -284,7 +278,7 @@ class WebServer {
if (files.length === 0) {
response.write("No files found
");
}
- if (!all && queryPart !== "side") {
+ if (!all && !url.searchParams.has("side")) {
response.write(
'
(only PDF files are shown, show all)
'
);
@@ -292,8 +286,8 @@ class WebServer {
response.end("