diff --git a/src/display/editor/drawers/signaturedraw.js b/src/display/editor/drawers/signaturedraw.js index f0a0ed36f..d148ea524 100644 --- a/src/display/editor/drawers/signaturedraw.js +++ b/src/display/editor/drawers/signaturedraw.js @@ -13,10 +13,14 @@ * limitations under the License. */ +import { fromBase64Util, toBase64Util, warn } from "../../../shared/util.js"; import { ContourDrawOutline } from "./contour.js"; import { InkDrawOutline } from "./inkdraw.js"; import { Outline } from "./outline.js"; +const BASE_HEADER_LENGTH = 8; +const POINTS_PROPERTIES_NUMBER = 3; + /** * Basic text editor in order to create a Signature annotation. */ @@ -607,12 +611,14 @@ class SignatureExtractor { const ratio = Math.min(pageWidth / width, pageHeight / height); const xScale = ratio / pageWidth; const yScale = ratio / pageHeight; + const newCurves = []; for (const { points } of curves) { const reducedPoints = mustSmooth ? this.#douglasPeucker(points) : points; if (!reducedPoints) { continue; } + newCurves.push(reducedPoints); const len = reducedPoints.length; const newPoints = new Float32Array(len); @@ -660,7 +666,185 @@ class SignatureExtractor { innerMargin ); - return outline; + return { outline, newCurves, areContours, thickness, width, height }; + } + + static async compressSignature({ + outlines, + areContours, + thickness, + width, + height, + }) { + // We create a single array containing all the outlines. + // The format is the following: + // - 4 bytes: data length. + // - 4 bytes: version. + // - 4 bytes: width. + // - 4 bytes: height. + // - 4 bytes: 0 if it's a contour, 1 if it's an ink. + // - 4 bytes: thickness. + // - 4 bytes: number of drawings. + // - 4 bytes: size of the buffer containing the diff of the coordinates. + // - 4 bytes: number of points in the first drawing. + // - 4 bytes: x coordinate of the first point. + // - 4 bytes: y coordinate of the first point. + // - 4 bytes: number of points in the second drawing. + // - 4 bytes: x coordinate of the first point. + // - 4 bytes: y coordinate of the first point. + // - ... + // - The buffer containing the diff of the coordinates. + + // The coordinates are supposed to be positive integers. + + // We also compute the min and max difference between two points. + // This will help us to determine the type of the buffer (Int8, Int16 or + // Int32) in order to minimize the amount of data we have. + let minDiff = Infinity; + let maxDiff = -Infinity; + let outlinesLength = 0; + for (const points of outlines) { + outlinesLength += points.length; + for (let i = 2, ii = points.length; i < ii; i++) { + const dx = points[i] - points[i - 2]; + minDiff = Math.min(minDiff, dx); + maxDiff = Math.max(maxDiff, dx); + } + } + + let bufferType; + if (minDiff >= -128 && maxDiff <= 127) { + bufferType = Int8Array; + } else if (minDiff >= -32768 && maxDiff <= 32767) { + bufferType = Int16Array; + } else { + bufferType = Int32Array; + } + + const len = outlines.length; + const headerLength = BASE_HEADER_LENGTH + POINTS_PROPERTIES_NUMBER * len; + const header = new Uint32Array(headerLength); + + let offset = 0; + header[offset++] = + headerLength * Uint32Array.BYTES_PER_ELEMENT + + (outlinesLength - 2 * len) * bufferType.BYTES_PER_ELEMENT; + header[offset++] = 0; // Version. + header[offset++] = width; + header[offset++] = height; + header[offset++] = areContours ? 0 : 1; + header[offset++] = Math.max(0, Math.floor(thickness ?? 0)); + header[offset++] = len; + header[offset++] = bufferType.BYTES_PER_ELEMENT; + for (const points of outlines) { + header[offset++] = points.length - 2; + header[offset++] = points[0]; + header[offset++] = points[1]; + } + + const cs = new CompressionStream("deflate-raw"); + const writer = cs.writable.getWriter(); + await writer.ready; + + writer.write(header); + const BufferCtor = bufferType.prototype.constructor; + for (const points of outlines) { + const diffs = new BufferCtor(points.length - 2); + for (let i = 2, ii = points.length; i < ii; i++) { + diffs[i - 2] = points[i] - points[i - 2]; + } + writer.write(diffs); + } + + writer.close(); + + const buf = await new Response(cs.readable).arrayBuffer(); + const bytes = new Uint8Array(buf); + + return toBase64Util(bytes); + } + + static async decompressSignature(signatureData) { + try { + const bytes = fromBase64Util(signatureData); + const { readable, writable } = new DecompressionStream("deflate-raw"); + const writer = writable.getWriter(); + await writer.ready; + + // We can't await writer.write() because it'll block until the reader + // starts which happens few lines below. + writer + .write(bytes) + .then(async () => { + await writer.ready; + await writer.close(); + }) + .catch(() => {}); + + let data = null; + let offset = 0; + for await (const chunk of readable) { + data ||= new Uint8Array(new Uint32Array(chunk.buffer)[0]); + data.set(chunk, offset); + offset += chunk.length; + } + + // We take a bit too much data for the header but it's fine. + const header = new Uint32Array(data.buffer, 0, data.length >> 2); + const version = header[1]; + if (version !== 0) { + throw new Error(`Invalid version: ${version}`); + } + const width = header[2]; + const height = header[3]; + const areContours = header[4] === 0; + const thickness = header[5]; + const numberOfDrawings = header[6]; + const bufferType = header[7]; + const outlines = []; + const diffsOffset = + (BASE_HEADER_LENGTH + POINTS_PROPERTIES_NUMBER * numberOfDrawings) * + Uint32Array.BYTES_PER_ELEMENT; + let diffs; + + switch (bufferType) { + case Int8Array.BYTES_PER_ELEMENT: + diffs = new Int8Array(data.buffer, diffsOffset); + break; + case Int16Array.BYTES_PER_ELEMENT: + diffs = new Int16Array(data.buffer, diffsOffset); + break; + case Int32Array.BYTES_PER_ELEMENT: + diffs = new Int32Array(data.buffer, diffsOffset); + break; + } + + offset = 0; + for (let i = 0; i < numberOfDrawings; i++) { + const len = header[POINTS_PROPERTIES_NUMBER * i + BASE_HEADER_LENGTH]; + const points = new Float32Array(len + 2); + outlines.push(points); + + for (let j = 0; j < POINTS_PROPERTIES_NUMBER - 1; j++) { + points[j] = + header[POINTS_PROPERTIES_NUMBER * i + BASE_HEADER_LENGTH + j + 1]; + } + for (let j = 0; j < len; j++) { + points[j + 2] = points[j] + diffs[offset++]; + } + } + + return { + areContours, + thickness, + outlines, + width, + height, + }; + } catch (e) { + warn(`decompressSignature: ${e}`); + return null; + } } } diff --git a/src/display/editor/signature.js b/src/display/editor/signature.js index 2c5c830b9..512a22b3f 100644 --- a/src/display/editor/signature.js +++ b/src/display/editor/signature.js @@ -72,6 +72,7 @@ class SignatureEditor extends DrawingEditor { super({ ...params, mustBeCommitted: true, name: "signatureEditor" }); this._willKeepAspectRatio = true; this._description = ""; + this._signatureUUID = null; } /** @inheritdoc */ diff --git a/src/pdf.js b/src/pdf.js index 869f41db5..b187b7965 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -31,6 +31,7 @@ import { AnnotationType, createValidAbsoluteUrl, FeatureTest, + getUuid, ImageKind, InvalidPDFException, normalizeUnicode, @@ -73,6 +74,7 @@ import { DOMSVGFactory } from "./display/svg_factory.js"; import { DrawLayer } from "./display/draw_layer.js"; import { GlobalWorkerOptions } from "./display/worker_options.js"; import { HighlightOutliner } from "./display/editor/drawers/highlight.js"; +import { SignatureExtractor } from "./display/editor/drawers/signaturedraw.js"; import { TextLayer } from "./display/text_layer.js"; import { TouchManager } from "./display/touch_manager.js"; import { XfaLayer } from "./display/xfa_layer.js"; @@ -110,6 +112,7 @@ export { getDocument, getFilenameFromUrl, getPdfFilenameFromUrl, + getUuid, getXfaPageViewport, GlobalWorkerOptions, ImageKind, @@ -130,6 +133,7 @@ export { ResponseException, setLayerDimensions, shadow, + SignatureExtractor, stopEvent, SupportedImageMimeTypes, TextLayer, diff --git a/test/unit/editor_spec.js b/test/unit/editor_spec.js index 7adda9588..aa8aa458a 100644 --- a/test/unit/editor_spec.js +++ b/test/unit/editor_spec.js @@ -14,6 +14,7 @@ */ import { CommandManager } from "../../src/display/editor/tools.js"; +import { SignatureExtractor } from "../../src/display/editor/drawers/signaturedraw.js"; describe("editor", function () { describe("Command Manager", function () { @@ -90,4 +91,51 @@ describe("editor", function () { manager.add({ ...makeDoUndo(5), mustExec: true }); expect(x).toEqual(11); }); + + it("should check signature compression/decompression", async () => { + let gen = n => new Float32Array(crypto.getRandomValues(new Uint16Array(n))); + let outlines = [102, 28, 254, 4536, 10, 14532, 512].map(gen); + const signature = { + outlines, + areContours: false, + thickness: 1, + width: 123, + height: 456, + }; + let compressed = await SignatureExtractor.compressSignature(signature); + let decompressed = await SignatureExtractor.decompressSignature(compressed); + expect(decompressed).toEqual(signature); + + signature.thickness = 2; + compressed = await SignatureExtractor.compressSignature(signature); + decompressed = await SignatureExtractor.decompressSignature(compressed); + expect(decompressed).toEqual(signature); + + signature.areContours = true; + compressed = await SignatureExtractor.compressSignature(signature); + decompressed = await SignatureExtractor.decompressSignature(compressed); + expect(decompressed).toEqual(signature); + + // Numbers are small enough to be compressed with Uint8Array. + gen = n => + new Float32Array( + crypto.getRandomValues(new Uint8Array(n)).map(x => x / 10) + ); + outlines = [100, 200, 300, 10, 80].map(gen); + signature.outlines = outlines; + compressed = await SignatureExtractor.compressSignature(signature); + decompressed = await SignatureExtractor.decompressSignature(compressed); + expect(decompressed).toEqual(signature); + + // Numbers are large enough to be compressed with Uint16Array. + gen = n => + new Float32Array( + crypto.getRandomValues(new Uint16Array(n)).map(x => x / 10) + ); + outlines = [100, 200, 300, 10, 80].map(gen); + signature.outlines = outlines; + compressed = await SignatureExtractor.compressSignature(signature); + decompressed = await SignatureExtractor.decompressSignature(compressed); + expect(decompressed).toEqual(signature); + }); }); diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js index 73bab4a98..f054e0767 100644 --- a/test/unit/pdf_spec.js +++ b/test/unit/pdf_spec.js @@ -22,6 +22,7 @@ import { AnnotationType, createValidAbsoluteUrl, FeatureTest, + getUuid, ImageKind, InvalidPDFException, normalizeUnicode, @@ -63,6 +64,7 @@ import { ColorPicker } from "../../src/display/editor/color_picker.js"; import { DOMSVGFactory } from "../../src/display/svg_factory.js"; import { DrawLayer } from "../../src/display/draw_layer.js"; import { GlobalWorkerOptions } from "../../src/display/worker_options.js"; +import { SignatureExtractor } from "../../src/display/editor/drawers/signaturedraw.js"; import { TextLayer } from "../../src/display/text_layer.js"; import { TouchManager } from "../../src/display/touch_manager.js"; import { XfaLayer } from "../../src/display/xfa_layer.js"; @@ -87,6 +89,7 @@ const expectedAPI = Object.freeze({ getDocument, getFilenameFromUrl, getPdfFilenameFromUrl, + getUuid, getXfaPageViewport, GlobalWorkerOptions, ImageKind, @@ -107,6 +110,7 @@ const expectedAPI = Object.freeze({ ResponseException, setLayerDimensions, shadow, + SignatureExtractor, stopEvent, SupportedImageMimeTypes, TextLayer, diff --git a/web/app.js b/web/app.js index 06b05cfb8..de0810edf 100644 --- a/web/app.js +++ b/web/app.js @@ -460,13 +460,15 @@ const PDFViewerApplication = { this.editorUndoBar = new EditorUndoBar(appConfig.editorUndoBar, eventBus); } - const signatureManager = appConfig.addSignatureDialog - ? new SignatureManager( - appConfig.addSignatureDialog, - this.overlayManager, - this.l10n - ) - : null; + const signatureManager = + AppOptions.get("enableSignatureEditor") && appConfig.addSignatureDialog + ? new SignatureManager( + appConfig.addSignatureDialog, + this.overlayManager, + this.l10n, + externalServices.createSignatureStorage() + ) + : null; const enableHWA = AppOptions.get("enableHWA"); const pdfViewer = new PDFViewer({ diff --git a/web/chromecom.js b/web/chromecom.js index a2eec5c34..c66734a87 100644 --- a/web/chromecom.js +++ b/web/chromecom.js @@ -19,8 +19,10 @@ import { BaseExternalServices } from "./external_services.js"; import { BasePreferences } from "./preferences.js"; import { GenericL10n } from "./genericl10n.js"; import { GenericScripting } from "./generic_scripting.js"; +import { SignatureStorage } from "./generic_signature_storage.js"; // These strings are from chrome/app/resources/generated_resources_*.xtb. +// eslint-disable-next-line sort-imports import i18nFileAccessLabels from "./chrome-i18n-allow-access-to-file-urls.json" with { type: "json" }; if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME")) { @@ -419,6 +421,10 @@ class ExternalServices extends BaseExternalServices { createScripting() { return new GenericScripting(AppOptions.get("sandboxBundleSrc")); } + + createSignatureStorage() { + return new SignatureStorage(); + } } class MLManager { diff --git a/web/external_services.js b/web/external_services.js index 772a15ef8..b9b199bd4 100644 --- a/web/external_services.js +++ b/web/external_services.js @@ -44,6 +44,10 @@ class BaseExternalServices { throw new Error("Not implemented: createScripting"); } + createSignatureStorage() { + throw new Error("Not implemented: createSignatureStorage"); + } + updateEditorStates(data) { throw new Error("Not implemented: updateEditorStates"); } diff --git a/web/firefoxcom.js b/web/firefoxcom.js index 5b98d17a6..983fcceed 100644 --- a/web/firefoxcom.js +++ b/web/firefoxcom.js @@ -495,6 +495,72 @@ class MLManager { } } +class SignatureStorage { + #signatures = null; + + #handleSignature(data) { + return FirefoxCom.requestAsync("handleSignature", data); + } + + async getAll() { + if (!this.#signatures) { + this.#signatures = Object.create(null); + const data = await this.#handleSignature({ action: "get" }); + if (data) { + for (const { uuid, description, signatureData } of data) { + this.#signatures[uuid] = { description, signatureData }; + } + } + } + return this.#signatures; + } + + async isFull() { + // We want to store at most 5 signatures. + return Object.keys(await this.getAll()).length === 5; + } + + async create(data) { + if (await this.isFull()) { + return null; + } + const uuid = await this.#handleSignature({ + action: "create", + ...data, + }); + if (!uuid) { + return null; + } + this.#signatures[uuid] = data; + return uuid; + } + + async delete(uuid) { + const signatures = await this.getAll(); + if (!signatures[uuid]) { + return false; + } + if (await this.#handleSignature({ action: "delete", uuid })) { + delete signatures[uuid]; + return true; + } + return false; + } + + async update(uuid, data) { + const signatures = await this.getAll(); + const oldData = signatures[uuid]; + if (!oldData) { + return false; + } + if (await this.#handleSignature({ action: "update", uuid, ...data })) { + Object.assign(oldData, data); + return true; + } + return false; + } +} + class ExternalServices extends BaseExternalServices { updateFindControlState(data) { FirefoxCom.request("updateFindControlState", data); @@ -581,6 +647,10 @@ class ExternalServices extends BaseExternalServices { return FirefoxScripting; } + createSignatureStorage() { + return new SignatureStorage(); + } + dispatchGlobalEvent(event) { FirefoxCom.request("dispatchGlobalEvent", event); } diff --git a/web/generic_signature_storage.js b/web/generic_signature_storage.js new file mode 100644 index 000000000..443231162 --- /dev/null +++ b/web/generic_signature_storage.js @@ -0,0 +1,76 @@ +/* Copyright 2025 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getUuid } from "pdfjs-lib"; + +class SignatureStorage { + // TODO: Encrypt the data in using a password and add a UI for entering it. + // We could use the Web Crypto API for this (see https://bradyjoslin.com/blog/encryption-webcrypto/ + // for an example). + + #signatures = null; + + #save() { + localStorage.setItem("pdfjs.signature", JSON.stringify(this.#signatures)); + } + + async getAll() { + if (!this.#signatures) { + const data = localStorage.getItem("pdfjs.signature"); + this.#signatures = data ? JSON.parse(data) : Object.create(null); + } + return this.#signatures; + } + + async isFull() { + return Object.keys(await this.getAll()).length === 5; + } + + async create(data) { + if (await this.isFull()) { + return null; + } + const uuid = getUuid(); + this.#signatures[uuid] = data; + this.#save(); + + return uuid; + } + + async delete(uuid) { + const signatures = await this.getAll(); + if (!signatures[uuid]) { + return false; + } + delete signatures[uuid]; + this.#save(); + + return true; + } + + async update(uuid, data) { + const signatures = await this.getAll(); + const oldData = signatures[uuid]; + if (!oldData) { + return false; + } + Object.assign(oldData, data); + this.#save(); + + return true; + } +} + +export { SignatureStorage }; diff --git a/web/genericcom.js b/web/genericcom.js index 610dac950..9cdf38d23 100644 --- a/web/genericcom.js +++ b/web/genericcom.js @@ -18,6 +18,7 @@ import { BaseExternalServices } from "./external_services.js"; import { BasePreferences } from "./preferences.js"; import { GenericL10n } from "./genericl10n.js"; import { GenericScripting } from "./generic_scripting.js"; +import { SignatureStorage } from "./generic_signature_storage.js"; if (typeof PDFJSDev !== "undefined" && !PDFJSDev.test("GENERIC")) { throw new Error( @@ -45,6 +46,10 @@ class ExternalServices extends BaseExternalServices { createScripting() { return new GenericScripting(AppOptions.get("sandboxBundleSrc")); } + + createSignatureStorage() { + return new SignatureStorage(); + } } class MLManager { diff --git a/web/pdfjs.js b/web/pdfjs.js index 763e194bc..153f00827 100644 --- a/web/pdfjs.js +++ b/web/pdfjs.js @@ -33,6 +33,7 @@ const { getDocument, getFilenameFromUrl, getPdfFilenameFromUrl, + getUuid, getXfaPageViewport, GlobalWorkerOptions, ImageKind, @@ -53,6 +54,7 @@ const { ResponseException, setLayerDimensions, shadow, + SignatureExtractor, stopEvent, SupportedImageMimeTypes, TextLayer, @@ -83,6 +85,7 @@ export { getDocument, getFilenameFromUrl, getPdfFilenameFromUrl, + getUuid, getXfaPageViewport, GlobalWorkerOptions, ImageKind, @@ -103,6 +106,7 @@ export { ResponseException, setLayerDimensions, shadow, + SignatureExtractor, stopEvent, SupportedImageMimeTypes, TextLayer, diff --git a/web/signature_manager.css b/web/signature_manager.css index ede846a02..d7351b87e 100644 --- a/web/signature_manager.css +++ b/web/signature_manager.css @@ -570,14 +570,23 @@ user-select: none; } - #addSignatureSaveWarning { + &:not(.fullStorage) #addSignatureSaveWarning { + display: none; + } + + &.fullStorage #addSignatureSaveWarning { + display: block; + opacity: 1; color: var(--save-warning-color); font-size: 11px; } - &[disabled] { + &:is([disabled], .fullStorage) { pointer-events: none; - opacity: 0.4; + + > :not(#addSignatureSaveWarning) { + opacity: 0.4; + } } } } diff --git a/web/signature_manager.js b/web/signature_manager.js index 945282cf9..d25dda7a2 100644 --- a/web/signature_manager.js +++ b/web/signature_manager.js @@ -16,6 +16,7 @@ import { DOMSVGFactory, noContextMenu, + SignatureExtractor, stopEvent, SupportedImageMimeTypes, } from "pdfjs-lib"; @@ -81,6 +82,8 @@ class SignatureManager { #overlayManager; + #signatureStorage; + #uiManager = null; static #l10nDescription = null; @@ -111,7 +114,8 @@ class SignatureManager { saveContainer, }, overlayManager, - l10n + l10n, + signatureStorage ) { this.#addButton = addButton; this.#clearButton = clearButton; @@ -131,6 +135,7 @@ class SignatureManager { this.#saveContainer = saveContainer; this.#typeInput = typeInput; this.#l10n = l10n; + this.#signatureStorage = signatureStorage; SignatureManager.#l10nDescription ||= Object.freeze({ signature: "pdfjs-editor-add-signature-description-default-when-drawing", @@ -564,7 +569,7 @@ class SignatureManager { return; } - const outline = (this.#extractedSignatureData = + const { outline } = (this.#extractedSignatureData = this.#currentEditor.getFromImage(data.bitmap)); if (!outline) { @@ -617,6 +622,10 @@ class SignatureManager { this.#currentEditor = editor; this.#uiManager.removeEditListeners(); + const isStorageFull = await this.#signatureStorage.isFull(); + this.#saveContainer.classList.toggle("fullStorage", isStorageFull); + this.#saveCheckbox.checked = !isStorageFull; + await this.#overlayManager.open(this.#dialog); const tabType = this.#tabButtons.get("type"); @@ -653,7 +662,7 @@ class SignatureManager { this.#tabsToAltText = null; } - #add() { + async #add() { let data; switch (this.#currentTab) { case "type": @@ -667,12 +676,28 @@ class SignatureManager { break; } this.#currentEditor.addSignature( - data, + data.outline, /* heightInPage */ 40, this.#description.value ); if (this.#saveCheckbox.checked) { - // TODO + const description = this.#description.value; + const { newCurves, areContours, thickness, width, height } = data; + const signatureData = await SignatureExtractor.compressSignature({ + outlines: newCurves, + areContours, + thickness, + width, + height, + }); + const uuid = (this.#currentEditor._signatureUUID = + await this.#signatureStorage.create({ + description, + signatureData, + })); + if (!uuid) { + console.warn("SignatureManager.add: cannot save the signature."); + } } this.#finish(); } diff --git a/web/viewer.html b/web/viewer.html index 7e4bd8656..420a26f7a 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -742,7 +742,7 @@ See https://github.com/adobe-type-tools/cmap-resources - You’ve reached the limit of 5 saved signatures. Remove one to save more. + You’ve reached the limit of 5 saved signatures. Remove one to save more.