[api-minor] Convert getJSActions to return data in a Map

Compared to regular Objects there's a number of advantages to using Maps:
 - They support proper iteration.
 - They have a simple way to check for the existence of data.
 - They have a simple/efficient way to check the number of elements.

If this functionality was added today, I cannot imagine that we'd choose an Object for this data.

Note also how in the scripting-implementation the `actions` were already converted into a Map, via the `createActionsMap` helper.
In the Firefox PDF Viewer sending `Map`s to the scripting-implementation should be fine, since it uses the browser `Cu.cloneInto` functionality; see https://searchfox.org/firefox-main/source/toolkit/components/pdfjs/content/PdfSandbox.sys.mjs
However with QuickJS, used by the GENERIC viewer, all data needs to be stringified and unfortunately `JSON.stringify()` doesn't support Maps. Hence we convert Maps to Objects, via a [`replacer` function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#replacer), since the existing `createActionsMap` usage will convert the actions-Objects back to Maps.
This commit is contained in:
Jonas Jenwald 2026-07-30 14:51:04 +02:00
parent 73707b62e9
commit 803c9d7d21
10 changed files with 108 additions and 95 deletions

View File

@ -2887,10 +2887,7 @@ class TextWidgetAnnotation extends WidgetAnnotation {
this.data.doNotScroll = this.hasFieldFlag(AnnotationFieldFlag.DONOTSCROLL); this.data.doNotScroll = this.hasFieldFlag(AnnotationFieldFlag.DONOTSCROLL);
// Check if we have a date or time. // Check if we have a date or time.
const { const { actions } = this.data;
data: { actions },
} = this;
if (!actions) { if (!actions) {
return; return;
} }
@ -2898,32 +2895,35 @@ class TextWidgetAnnotation extends WidgetAnnotation {
const AFDateTime = const AFDateTime =
/^AF(Date|Time)_(?:Keystroke|Format)(?:Ex)?\(['"]?([^'"]+)['"]?\);$/; /^AF(Date|Time)_(?:Keystroke|Format)(?:Ex)?\(['"]?([^'"]+)['"]?\);$/;
let canUseHTMLDateTime = false; let canUseHTMLDateTime = false;
const aFormat = actions.get("Format"),
aKeystroke = actions.get("Keystroke");
if ( if (
(actions.Format?.length === 1 && (aFormat?.length === 1 &&
actions.Keystroke?.length === 1 && aKeystroke?.length === 1 &&
AFDateTime.test(actions.Format[0]) && AFDateTime.test(aFormat[0]) &&
AFDateTime.test(actions.Keystroke[0])) || AFDateTime.test(aKeystroke[0])) ||
(actions.Format?.length === 0 && (aFormat?.length === 0 &&
actions.Keystroke?.length === 1 && aKeystroke?.length === 1 &&
AFDateTime.test(actions.Keystroke[0])) || AFDateTime.test(aKeystroke[0])) ||
(actions.Keystroke?.length === 0 && (aKeystroke?.length === 0 &&
actions.Format?.length === 1 && aFormat?.length === 1 &&
AFDateTime.test(actions.Format[0])) AFDateTime.test(aFormat[0]))
) { ) {
// If the Format and Keystroke actions are the same, we can just use // If the Format and Keystroke actions are the same, we can just use
// the Format action. // the Format action.
canUseHTMLDateTime = true; canUseHTMLDateTime = true;
} }
const actionsToVisit = []; const actionsToVisit = [];
if (actions.Format) { if (aFormat) {
actionsToVisit.push(...actions.Format); actionsToVisit.push(...aFormat);
} }
if (actions.Keystroke) { if (aKeystroke) {
actionsToVisit.push(...actions.Keystroke); actionsToVisit.push(...aKeystroke);
} }
if (canUseHTMLDateTime) { if (canUseHTMLDateTime) {
delete actions.Keystroke; actions.delete("Keystroke");
actions.Format = actionsToVisit; actions.set("Format", actionsToVisit);
} }
for (const formatAction of actionsToVisit) { for (const formatAction of actionsToVisit) {

View File

@ -19,6 +19,7 @@ import {
DocumentActionEventType, DocumentActionEventType,
FormatError, FormatError,
info, info,
makeArr,
PermissionFlag, PermissionFlag,
shadow, shadow,
stringToUTF8String, stringToUTF8String,
@ -1287,10 +1288,10 @@ class Catalog {
); );
if (javaScript) { if (javaScript) {
actions ??= Object.create(null); actions ??= new Map();
for (const [key, val] of javaScript) { for (const [key, val] of javaScript) {
(actions[key] ??= []).push(val); actions.getOrInsertComputed(key, makeArr).push(val);
} }
} }
return shadow(this, "jsActions", actions); return shadow(this, "jsActions", actions);

View File

@ -449,7 +449,7 @@ function _collectJS(entry, xref, list, parents) {
} }
function collectActions(xref, dict, eventType) { function collectActions(xref, dict, eventType) {
const actions = Object.create(null); const actions = new Map();
const additionalActionsDicts = getInheritableProperty({ const additionalActionsDicts = getInheritableProperty({
dict, dict,
key: "AA", key: "AA",
@ -475,7 +475,7 @@ function collectActions(xref, dict, eventType) {
const list = []; const list = [];
_collectJS(rawActionDict, xref, list, parents); _collectJS(rawActionDict, xref, list, parents);
if (list.length > 0) { if (list.length > 0) {
actions[action] = list; actions.set(action, list);
} }
} }
} }
@ -487,10 +487,10 @@ function collectActions(xref, dict, eventType) {
const list = []; const list = [];
_collectJS(actionDict, xref, list, parents); _collectJS(actionDict, xref, list, parents);
if (list.length > 0) { if (list.length > 0) {
actions.Action = list; actions.set("Action", list);
} }
} }
return Object.keys(actions).length ? actions : null; return actions.size ? actions : null;
} }
const XMLEntities = { const XMLEntities = {

View File

@ -1012,9 +1012,9 @@ class LinkAnnotationElement extends AnnotationElement {
} else { } else {
if ( if (
data.actions && data.actions &&
(data.actions.Action || (data.actions.has("Action") ||
data.actions["Mouse Up"] || data.actions.has("Mouse Up") ||
data.actions["Mouse Down"]) && data.actions.has("Mouse Down")) &&
this.enableScripting && this.enableScripting &&
this.hasJSActions this.hasJSActions
) { ) {
@ -1158,14 +1158,14 @@ class LinkAnnotationElement extends AnnotationElement {
* @param {Object} data * @param {Object} data
* @memberof LinkAnnotationElement * @memberof LinkAnnotationElement
*/ */
_bindJSAction(link, data) { _bindJSAction(link, { actions, id, overlaidText }) {
link.href = this.linkService.getAnchorUrl(""); link.href = this.linkService.getAnchorUrl("");
const map = new Map([ const map = new Map([
["Action", "onclick"], ["Action", "onclick"],
["Mouse Up", "onmouseup"], ["Mouse Up", "onmouseup"],
["Mouse Down", "onmousedown"], ["Mouse Down", "onmousedown"],
]); ]);
for (const name of Object.keys(data.actions)) { for (const name of actions.keys()) {
const jsName = map.get(name); const jsName = map.get(name);
if (!jsName) { if (!jsName) {
continue; continue;
@ -1173,16 +1173,13 @@ class LinkAnnotationElement extends AnnotationElement {
link[jsName] = () => { link[jsName] = () => {
this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { this.linkService.eventBus?.dispatch("dispatcheventinsandbox", {
source: this, source: this,
detail: { detail: { id, name },
id: data.id,
name,
},
}); });
return false; return false;
}; };
} }
if (data.overlaidText) { if (overlaidText) {
link.title = data.overlaidText; link.title = overlaidText;
} }
link.onclick ||= () => false; link.onclick ||= () => false;
@ -1379,8 +1376,10 @@ class WidgetAnnotationElement extends AnnotationElement {
} }
_setEventListeners(element, elementData, names, getter) { _setEventListeners(element, elementData, names, getter) {
const { actions } = this.data;
for (const [baseName, eventName] of names) { for (const [baseName, eventName] of names) {
if (eventName === "Action" || this.data.actions?.[eventName]) { if (eventName === "Action" || actions?.has(eventName)) {
if (eventName === "Focus" || eventName === "Blur") { if (eventName === "Focus" || eventName === "Blur") {
elementData ||= { focused: false }; elementData ||= { focused: false };
} }
@ -1391,10 +1390,10 @@ class WidgetAnnotationElement extends AnnotationElement {
eventName, eventName,
getter getter
); );
if (eventName === "Focus" && !this.data.actions?.Blur) { if (eventName === "Focus" && !actions?.has("Blur")) {
// Ensure that elementData will have the correct value. // Ensure that elementData will have the correct value.
this._setEventListener(element, elementData, "blur", "Blur", null); this._setEventListener(element, elementData, "blur", "Blur", null);
} else if (eventName === "Blur" && !this.data.actions?.Focus) { } else if (eventName === "Blur" && !actions?.has("Focus")) {
this._setEventListener(element, elementData, "focus", "Focus", null); this._setEventListener(element, elementData, "focus", "Focus", null);
} }
} }
@ -1630,7 +1629,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement {
} }
elementData.lastCommittedValue = target.value; elementData.lastCommittedValue = target.value;
elementData.commitKey = 1; elementData.commitKey = 1;
if (!this.data.actions?.Focus) { if (!this.data.actions?.has("Focus")) {
elementData.focused = true; elementData.focused = true;
} }
}); });
@ -1752,7 +1751,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement {
if (!elementData.focused || !event.relatedTarget) { if (!elementData.focused || !event.relatedTarget) {
return; return;
} }
if (!this.data.actions?.Blur) { if (!this.data.actions?.has("Blur")) {
elementData.focused = false; elementData.focused = false;
} }
const { target } = event; const { target } = event;
@ -1800,7 +1799,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement {
_blurListener(event); _blurListener(event);
}); });
if (this.data.actions?.Keystroke) { if (this.data.actions?.has("Keystroke")) {
element.addEventListener("beforeinput", event => { element.addEventListener("beforeinput", event => {
elementData.lastCommittedValue = null; elementData.lastCommittedValue = null;
const { data, target } = event; const { data, target } = event;

View File

@ -867,8 +867,8 @@ class PDFDocumentProxy {
} }
/** /**
* @returns {Promise<Object | null>} A promise that is resolved with * @returns {Promise<Map | null>} A promise that is resolved with a {Map} with
* an {Object} with the JavaScript actions: * the JavaScript actions:
* - from the name tree. * - from the name tree.
* - from A or AA entries in the catalog dictionary. * - from A or AA entries in the catalog dictionary.
* , or `null` if no JavaScript exists. * , or `null` if no JavaScript exists.
@ -1429,8 +1429,8 @@ class PDFPageProxy {
} }
/** /**
* @returns {Promise<Object>} A promise that is resolved with an * @returns {Promise<Map | null>} A promise that is resolved with a {Map} with
* {Object} with JS actions. * the JavaScript actions, or `null` if no JavaScript exists.
*/ */
getJSActions() { getJSActions() {
return this._transport.getPageJSActions(this._pageIndex); return this._transport.getPageJSActions(this._pageIndex);

View File

@ -19,7 +19,9 @@ class SandboxSupport extends SandboxSupportBase {
exportValueToSandbox(val) { exportValueToSandbox(val) {
// The communication with the Quickjs sandbox is based on strings // The communication with the Quickjs sandbox is based on strings
// So we use JSON.stringfy to serialize // So we use JSON.stringfy to serialize
return JSON.stringify(val); return JSON.stringify(val, (k, v) =>
v instanceof Map ? Object.fromEntries(v) : v
);
} }
importValueFromSandbox(val) { importValueFromSandbox(val) {
@ -65,7 +67,7 @@ class Sandbox {
let success = false; let success = false;
let buf = 0; let buf = 0;
try { try {
const sandboxData = JSON.stringify(data); const sandboxData = this.support.exportValueToSandbox(data);
// "pdfjsScripting.initSandbox..." MUST be the last line to be evaluated // "pdfjsScripting.initSandbox..." MUST be the last line to be evaluated
// since the returned value is used for the communication. // since the returned value is used for the communication.
code.push(`pdfjsScripting.initSandbox({ data: ${sandboxData} })`); code.push(`pdfjsScripting.initSandbox({ data: ${sandboxData} })`);

View File

@ -22,7 +22,9 @@ const FieldType = {
}; };
function createActionsMap(actions) { function createActionsMap(actions) {
return new Map(actions ? Object.entries(actions) : null); return actions instanceof Map
? actions
: new Map(actions ? Object.entries(actions) : null);
} }
function getFieldType(actions) { function getFieldType(actions) {
@ -30,10 +32,8 @@ function getFieldType(actions) {
if (!format) { if (!format) {
return FieldType.none; return FieldType.none;
} }
format = format[0].trim();
format = format[0];
format = format.trim();
if (format.startsWith("AFNumber_")) { if (format.startsWith("AFNumber_")) {
return FieldType.number; return FieldType.number;
} }

View File

@ -173,9 +173,9 @@ class Doc extends PDFObject {
_dispatchPageEvent(name, actions, pageNumber) { _dispatchPageEvent(name, actions, pageNumber) {
if (name === "PageOpen") { if (name === "PageOpen") {
this.#pageActions ??= new Map(); this.#pageActions ??= new Map();
if (!this.#pageActions.has(pageNumber)) { this.#pageActions.getOrInsertComputed(pageNumber, () =>
this.#pageActions.set(pageNumber, createActionsMap(actions)); createActionsMap(actions)
} );
this._pageNum = pageNumber - 1; this._pageNum = pageNumber - 1;
} }

View File

@ -2460,17 +2460,14 @@ describe("annotation", function () {
annotationGlobalsMock, annotationGlobalsMock,
idFactoryMock idFactoryMock
); );
const fieldObject = await annotation.getFieldObject(); const { actions } = await annotation.getFieldObject();
const actions = fieldObject.actions; expect(actions).toEqual(
expect(actions["Mouse Enter"]).toEqual(["hello()"]); new Map([
expect(actions["Mouse Exit"]).toEqual([ ["Mouse Enter", ["hello()"]],
"world()", ["Mouse Exit", ["world()", "olleh()", "foo()", "dlrow()", "oof()"]],
"olleh()", ["Mouse Down", ["bar()"]],
"foo()", ])
"dlrow()", );
"oof()",
]);
expect(actions["Mouse Down"]).toEqual(["bar()"]);
}); });
it("should save Japanese text", async function () { it("should save Japanese text", async function () {
@ -3728,7 +3725,7 @@ describe("annotation", function () {
); );
expect(data.annotationType).toEqual(AnnotationType.WIDGET); expect(data.annotationType).toEqual(AnnotationType.WIDGET);
expect(data.pushButton).toBeTrue(); expect(data.pushButton).toBeTrue();
expect(data.actions.Action).toEqual(["do_something();"]); expect(data.actions.get("Action")).toEqual(["do_something();"]);
}); });
it("should handle push buttons that act as a tooltip only", async function () { it("should handle push buttons that act as a tooltip only", async function () {

View File

@ -986,9 +986,9 @@ describe("api", function () {
expect(pdfDocument.numPages).toEqual(1); expect(pdfDocument.numPages).toEqual(1);
const jsActions = await pdfDocument.getJSActions(); const jsActions = await pdfDocument.getJSActions();
expect(jsActions).toEqual({ expect(jsActions).toEqual(
OpenAction: ["func=function(){app.alert(1)};func();"], new Map([["OpenAction", ["func=function(){app.alert(1)};func();"]]])
}); );
const page = await pdfDocument.getPage(1); const page = await pdfDocument.getPage(1);
expect(page).toBeInstanceOf(PDFPageProxy); expect(page).toBeInstanceOf(PDFPageProxy);
@ -1943,12 +1943,17 @@ describe("api", function () {
// PDF document with "JavaScript" action in the OpenAction dictionary. // PDF document with "JavaScript" action in the OpenAction dictionary.
const loadingTask = getDocument(buildGetDocumentParams("issue6106.pdf")); const loadingTask = getDocument(buildGetDocumentParams("issue6106.pdf"));
const pdfDoc = await loadingTask.promise; const pdfDoc = await loadingTask.promise;
const { OpenAction } = await pdfDoc.getJSActions(); const jsActions = await pdfDoc.getJSActions();
expect(OpenAction).toEqual([ expect(jsActions).toEqual(
"this.print({bUI:true,bSilent:false,bShrinkToFit:true});", new Map([
]); [
expect(OpenAction[0]).toMatch(AutoPrintRegExp); "OpenAction",
["this.print({bUI:true,bSilent:false,bShrinkToFit:true});"],
],
])
);
expect(jsActions.get("OpenAction")[0]).toMatch(AutoPrintRegExp);
await loadingTask.destroy(); await loadingTask.destroy();
}); });
@ -1988,21 +1993,27 @@ describe("api", function () {
const page3 = await pdfDoc.getPage(3); const page3 = await pdfDoc.getPage(3);
const page3Actions = await page3.getJSActions(); const page3Actions = await page3.getJSActions();
expect(docActions).toEqual({ expect(docActions).toEqual(
DidPrint: [`this.getField("Text2").value = "DidPrint";`], new Map([
DidSave: [`this.getField("Text2").value = "DidSave";`], ["DidPrint", [`this.getField("Text2").value = "DidPrint";`]],
WillClose: [`this.getField("Text1").value = "WillClose";`], ["DidSave", [`this.getField("Text2").value = "DidSave";`]],
WillPrint: [`this.getField("Text1").value = "WillPrint";`], ["WillClose", [`this.getField("Text1").value = "WillClose";`]],
WillSave: [`this.getField("Text1").value = "WillSave";`], ["WillPrint", [`this.getField("Text1").value = "WillPrint";`]],
}); ["WillSave", [`this.getField("Text1").value = "WillSave";`]],
expect(page1Actions).toEqual({ ])
PageOpen: [`this.getField("Text1").value = "PageOpen 1";`], );
PageClose: [`this.getField("Text2").value = "PageClose 1";`], expect(page1Actions).toEqual(
}); new Map([
expect(page3Actions).toEqual({ ["PageOpen", [`this.getField("Text1").value = "PageOpen 1";`]],
PageOpen: [`this.getField("Text5").value = "PageOpen 3";`], ["PageClose", [`this.getField("Text2").value = "PageClose 1";`]],
PageClose: [`this.getField("Text6").value = "PageClose 3";`], ])
}); );
expect(page3Actions).toEqual(
new Map([
["PageOpen", [`this.getField("Text5").value = "PageOpen 3";`]],
["PageClose", [`this.getField("Text6").value = "PageClose 3";`]],
])
);
await loadingTask.destroy(); await loadingTask.destroy();
}); });
@ -2051,11 +2062,14 @@ describe("api", function () {
name: "Button1", name: "Button1",
rect: [455.436, 719.678, 527.436, 739.678], rect: [455.436, 719.678, 527.436, 739.678],
hidden: false, hidden: false,
actions: { actions: new Map([
Action: [ [
`this.getField("Text1").value = this.info.authors.join("::");`, "Action",
[
`this.getField("Text1").value = this.info.authors.join("::");`,
],
], ],
}, ]),
page: 0, page: 0,
strokeColor: null, strokeColor: null,
fillColor: new Uint8ClampedArray([192, 192, 192]), fillColor: new Uint8ClampedArray([192, 192, 192]),