diff --git a/src/core/annotation.js b/src/core/annotation.js index f4932c195..676918a40 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -2804,19 +2804,72 @@ class TextWidgetAnnotation extends WidgetAnnotation { const { data: { actions }, } = this; - for (const keystrokeAction of actions?.Keystroke || []) { - const m = keystrokeAction - .trim() - .match(/^AF(Date|Time)_Keystroke(?:Ex)?\(['"]?([^'"]+)['"]?\);$/); - if (m) { - let format = m[2]; - const num = parseInt(format, 10); - if (!isNaN(num) && Math.floor(Math.log10(num)) + 1 === m[2].length) { - format = (m[1] === "Date" ? DateFormats : TimeFormats)[num] ?? format; - } - this.data[m[1] === "Date" ? "dateFormat" : "timeFormat"] = format; + + if (!actions) { + return; + } + + const AFDateTime = + /^AF(Date|Time)_(?:Keystroke|Format)(?:Ex)?\(['"]?([^'"]+)['"]?\);$/; + let canUseHTMLDateTime = false; + if ( + (actions.Format?.length === 1 && + actions.Keystroke?.length === 1 && + AFDateTime.test(actions.Format[0]) && + AFDateTime.test(actions.Keystroke[0])) || + (actions.Format?.length === 0 && + actions.Keystroke?.length === 1 && + AFDateTime.test(actions.Keystroke[0])) || + (actions.Keystroke?.length === 0 && + actions.Format?.length === 1 && + AFDateTime.test(actions.Format[0])) + ) { + // If the Format and Keystroke actions are the same, we can just use + // the Format action. + canUseHTMLDateTime = true; + } + const actionsToVisit = []; + if (actions.Format) { + actionsToVisit.push(...actions.Format); + } + if (actions.Keystroke) { + actionsToVisit.push(...actions.Keystroke); + } + if (canUseHTMLDateTime) { + delete actions.Keystroke; + actions.Format = actionsToVisit; + } + + for (const formatAction of actionsToVisit) { + const m = formatAction.match(AFDateTime); + if (!m) { + continue; + } + const isDate = m[1] === "Date"; + let format = m[2]; + const num = parseInt(format, 10); + if (!isNaN(num) && Math.floor(Math.log10(num)) + 1 === m[2].length) { + format = (isDate ? DateFormats : TimeFormats)[num] ?? format; + } + this.data.datetimeFormat = format; + if (!canUseHTMLDateTime) { + // The datetime format will just be used as a tooltip. break; } + if (isDate) { + // We can have a date and a time so we'll use a time input in this + // case. + if (/HH|MM|ss|h/.test(format)) { + this.data.datetimeType = "datetime-local"; + this.data.timeStep = /ss/.test(format) ? 1 : 60; + } else { + this.data.datetimeType = "date"; + } + break; + } + this.data.datetimeType = "time"; + this.data.timeStep = /ss/.test(format) ? 1 : 60; + break; } } @@ -3013,6 +3066,8 @@ class TextWidgetAnnotation extends WidgetAnnotation { strokeColor: this.data.borderColor, fillColor: this.data.backgroundColor, rotation: this.rotation, + datetimeFormat: this.data.datetimeFormat, + hasDatetimeHTML: !!this.data.datetimeType, type: "text", }; } diff --git a/src/core/core_utils.js b/src/core/core_utils.js index c5f3ed987..f3218ac61 100644 --- a/src/core/core_utils.js +++ b/src/core/core_utils.js @@ -429,7 +429,7 @@ function _collectJS(entry, xref, list, parents) { /* keepEscapeSequence = */ true ).replaceAll("\x00", ""); if (code) { - list.push(code); + list.push(code.trim()); } } _collectJS(entry.getRaw("Next"), xref, list, parents); diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 8ff424df7..a77fe3f09 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -45,6 +45,7 @@ import { XfaLayer } from "./xfa_layer.js"; const DEFAULT_FONT_SIZE = 9; const GetElementsByNameSet = new WeakSet(); +const TIMEZONE_OFFSET = new Date().getTimezoneOffset() * 60 * 1000; /** * @typedef {Object} AnnotationElementParameters @@ -1354,9 +1355,10 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { element.disabled = this.data.readOnly; element.name = this.data.fieldName; element.tabIndex = 0; - const format = this.data.dateFormat || this.data.timeFormat; - if (format) { - element.title = format; + const { datetimeFormat, datetimeType, timeStep } = this.data; + const hasDateOrTime = !!datetimeType && this.enableScripting; + if (datetimeFormat) { + element.title = datetimeFormat; } this._setRequired(element, this.data.required); @@ -1397,8 +1399,34 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { return; } const { target } = event; + if (hasDateOrTime) { + target.type = datetimeType; + if (timeStep) { + target.step = timeStep; + } + } + if (elementData.userValue) { - target.value = elementData.userValue; + const value = elementData.userValue; + if (hasDateOrTime) { + if (datetimeType === "time") { + const date = new Date(value); + const parts = [ + date.getHours(), + date.getMinutes(), + date.getSeconds(), + ]; + target.value = parts + .map(v => v.toString().padStart(2, "0")) + .join(":"); + } else { + target.value = new Date(value - TIMEZONE_OFFSET) + .toISOString() + .split(datetimeType === "date" ? "T" : ".", 1)[0]; + } + } else { + target.value = value; + } } elementData.lastCommittedValue = target.value; elementData.commitKey = 1; @@ -1412,7 +1440,11 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { const actions = { value(event) { elementData.userValue = event.detail.value ?? ""; - storage.setValue(id, { value: elementData.userValue.toString() }); + if (!hasDateOrTime) { + storage.setValue(id, { + value: elementData.userValue.toString(), + }); + } event.target.value = elementData.userValue; }, formattedValue(event) { @@ -1426,9 +1458,16 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { // Input hasn't the focus so display formatted string event.target.value = formattedValue; } - storage.setValue(id, { + const data = { formattedValue, - }); + }; + if (hasDateOrTime) { + // If the field is a date or time, we store the formatted value + // in the `value` property, so that it can be used by the + // `Keystroke` action. + data.value = formattedValue; + } + storage.setValue(id, data); }, selRange(event) { event.target.setSelectionRange(...event.detail.selRange); @@ -1516,7 +1555,25 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { if (!this.data.actions?.Blur) { elementData.focused = false; } - const { value } = event.target; + const { target } = event; + let { value } = target; + if (hasDateOrTime) { + if (value && datetimeType === "time") { + const parts = value.split(":").map(v => parseInt(v, 10)); + value = new Date( + 2000, + 0, + 1, + parts[0], + parts[1], + parts[2] || 0 + ).valueOf(); + target.step = ""; + } else { + value = new Date(value).valueOf(); + } + target.type = "text"; + } elementData.userValue = value; if (elementData.lastCommittedValue !== value) { this.linkService.eventBus?.dispatch("dispatcheventinsandbox", { diff --git a/src/scripting_api/console.js b/src/scripting_api/console.js index a32550276..9ed15577b 100644 --- a/src/scripting_api/console.js +++ b/src/scripting_api/console.js @@ -25,9 +25,14 @@ class Console extends PDFObject { } println(msg) { - if (typeof msg === "string") { - this._send({ command: "println", value: "PDF.js Console:: " + msg }); + if (typeof msg !== "string") { + try { + msg = JSON.stringify(msg); + } catch { + msg = msg.toString?.() || "[Unserializable object]"; + } } + this._send({ command: "println", value: "PDF.js Console:: " + msg }); } show() { diff --git a/src/scripting_api/doc.js b/src/scripting_api/doc.js index 0481f02e5..c76ccc047 100644 --- a/src/scripting_api/doc.js +++ b/src/scripting_api/doc.js @@ -104,6 +104,20 @@ class Doc extends PDFObject { } _initActions() { + for (const { obj } of this._fields.values()) { + // Some fields may have compute their values so we need to send them + // to the view. + const initialValue = obj._initialValue; + if (initialValue) { + this._send({ + id: obj._id, + siblings: obj._siblings, + value: initialValue, + formattedValue: obj.value.toString(), + }); + } + } + const dontRun = new Set([ "WillClose", "WillSave", diff --git a/src/scripting_api/field.js b/src/scripting_api/field.js index e484150aa..b5de544e7 100644 --- a/src/scripting_api/field.js +++ b/src/scripting_api/field.js @@ -86,6 +86,9 @@ class Field extends PDFObject { this._fieldType = getFieldType(this._actions); this._siblings = data.siblings || null; this._rotation = data.rotation || 0; + this._datetimeFormat = data.datetimeFormat || null; + this._hasDateOrTime = !!data.hasDatetimeHTML; + this._util = data.util; this._globalEval = data.globalEval; this._appObjects = data.appObjects; @@ -246,6 +249,16 @@ class Field extends PDFObject { return; } + if (this._hasDateOrTime && value) { + const date = this._util.scand(this._datetimeFormat, value); + if (date) { + this._originalValue = date.valueOf(); + value = this._util.printd(this._datetimeFormat, date); + this._value = !isNaN(value) ? parseFloat(value) : value; + return; + } + } + if ( value === "" || typeof value !== "string" || @@ -262,6 +275,10 @@ class Field extends PDFObject { this._value = !isNaN(_value) ? parseFloat(_value) : value; } + get _initialValue() { + return (this._hasDateOrTime && this._originalValue) || null; + } + _getValue() { return this._originalValue ?? this.value; } diff --git a/src/scripting_api/initialization.js b/src/scripting_api/initialization.js index 946e68048..9e4727bcb 100644 --- a/src/scripting_api/initialization.js +++ b/src/scripting_api/initialization.js @@ -95,6 +95,7 @@ function initSandbox(params) { obj.doc = _document; obj.fieldPath = name; obj.appObjects = appObjects; + obj.util = util; const otherFields = annotations.slice(1); diff --git a/src/scripting_api/util.js b/src/scripting_api/util.js index 3a2e8fbcb..91229b0d7 100644 --- a/src/scripting_api/util.js +++ b/src/scripting_api/util.js @@ -619,10 +619,10 @@ class Util extends PDFObject { } const data = { - year: new Date().getFullYear(), + year: 2000, // 2000 because it's 00 in yy format. month: 0, day: 1, - hours: 12, + hours: 0, minutes: 0, seconds: 0, am: null, diff --git a/test/integration/scripting_spec.mjs b/test/integration/scripting_spec.mjs index ed4543555..8193c3cf9 100644 --- a/test/integration/scripting_spec.mjs +++ b/test/integration/scripting_spec.mjs @@ -2513,4 +2513,143 @@ describe("Interaction", () => { ); }); }); + + describe("Date HTML element", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait("dates.pdf", "[data-annotation-id='26R']"); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("must check that the inputs are correct", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + await waitForSandboxTrip(page); + + const firstInputSelector = "[data-annotation-id='26R'] > input"; + await page.waitForSelector(`${firstInputSelector}[type="text"]`); + await page.click(firstInputSelector); + await waitForSandboxTrip(page); + await page.waitForSelector(`${firstInputSelector}[type="date"]`); + await page.$eval(firstInputSelector, el => { + el.value = "1975-03-16"; + }); + + const secondInputSelector = "[data-annotation-id='27R'] > input"; + await page.waitForSelector(`${secondInputSelector}[type="text"]`); + await page.click(secondInputSelector); + await waitForSandboxTrip(page); + await page.waitForSelector(`${secondInputSelector}[type="time"]`); + await page.$eval(secondInputSelector, el => { + el.value = "01:23:45"; + }); + + const thirdInputSelector = "[data-annotation-id='28R'] > input"; + await page.waitForSelector(`${thirdInputSelector}[type="text"]`); + await page.click(thirdInputSelector); + await waitForSandboxTrip(page); + await page.waitForSelector( + `${thirdInputSelector}[type="datetime-local"]` + ); + await page.$eval(thirdInputSelector, el => { + el.value = "1975-03-16T01:23:45"; + }); + + const firstInputValue = await page.$eval( + firstInputSelector, + el => el.value + ); + expect(firstInputValue) + .withContext(`In ${browserName}`) + .toEqual("16-Mar-75"); + + const secondInputValue = await page.$eval( + secondInputSelector, + el => el.value + ); + expect(secondInputValue) + .withContext(`In ${browserName}`) + .toEqual("01:23:45"); + + await page.click(firstInputSelector); + await waitForSandboxTrip(page); + + const thirdInputValue = await page.$eval( + thirdInputSelector, + el => el.value + ); + expect(thirdInputValue) + .withContext(`In ${browserName}`) + .toEqual("3/16/1975 01:23"); + }) + ); + }); + }); + + describe("Date HTML element with initial values", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait("dates_save.pdf", "[data-annotation-id='26R']"); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("must check that the inputs are correct", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + await waitForSandboxTrip(page); + + const firstInputSelector = "[data-annotation-id='26R'] > input"; + await page.waitForSelector(`${firstInputSelector}[type="text"]`); + await page.click(firstInputSelector); + await waitForSandboxTrip(page); + await page.waitForSelector(`${firstInputSelector}[type="date"]`); + const firstInputValue = await page.$eval( + firstInputSelector, + el => el.value + ); + expect(firstInputValue) + .withContext(`In ${browserName}`) + .toEqual("2025-07-01"); + + const secondInputSelector = "[data-annotation-id='27R'] > input"; + await page.waitForSelector(`${secondInputSelector}[type="text"]`); + await page.click(secondInputSelector); + await waitForSandboxTrip(page); + await page.waitForSelector(`${secondInputSelector}[type="time"]`); + const secondInputValue = await page.$eval( + secondInputSelector, + el => el.value + ); + expect(secondInputValue) + .withContext(`In ${browserName}`) + .toEqual("00:34:56"); + + const thirdInputSelector = "[data-annotation-id='28R'] > input"; + await page.waitForSelector(`${thirdInputSelector}[type="text"]`); + await page.click(thirdInputSelector); + await waitForSandboxTrip(page); + await page.waitForSelector( + `${thirdInputSelector}[type="datetime-local"]` + ); + const thirdInputValue = await page.$eval( + thirdInputSelector, + el => el.value + ); + expect(thirdInputValue) + .withContext(`In ${browserName}`) + .toEqual("2025-07-02T12:34"); + }) + ); + }); + }); }); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 3e8a6d274..ff077a83b 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -736,3 +736,5 @@ !issue20065.pdf !bug1708041.pdf !bug1978317.pdf +!dates.pdf +!dates_save.pdf diff --git a/test/pdfs/dates.pdf b/test/pdfs/dates.pdf new file mode 100755 index 000000000..20175a03e Binary files /dev/null and b/test/pdfs/dates.pdf differ diff --git a/test/pdfs/dates_save.pdf b/test/pdfs/dates_save.pdf new file mode 100755 index 000000000..1495df321 Binary files /dev/null and b/test/pdfs/dates_save.pdf differ diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 04c47d61a..e5fdf5d30 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -1760,6 +1760,8 @@ describe("api", function () { strokeColor: null, fillColor: null, rotation: 0, + datetimeFormat: undefined, + hasDatetimeHTML: false, type: "text", }, ], diff --git a/test/unit/scripting_spec.js b/test/unit/scripting_spec.js index 0a8170b4b..aeb82fb7a 100644 --- a/test/unit/scripting_spec.js +++ b/test/unit/scripting_spec.js @@ -676,9 +676,8 @@ describe("Scripting", function () { ); }; - const year = new Date().getFullYear(); - await check("05", "dd", `${year}/01/05`); - await check("12", "mm", `${year}/12/01`); + await check("05", "dd", "2000/01/05"); + await check("12", "mm", "2000/12/01"); await check("2022", "yyyy", "2022/01/01"); await check("a1$9bbbb21", "dd/mm/yyyy", "2021/09/01"); await check("1/2/2024", "dd/mm/yyyy", "2024/02/01");