1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-19 06:38:07 +02:00

Merge pull request #19425 from calixteman/signature_save

[Editor] Add the possibility to compress/decompress the signature data in  order to store them in the logins storage in Firefox (bug 1946171)
This commit is contained in:
calixteman 2025-02-10 19:20:32 +01:00 committed by GitHub
commit e3cca6d513
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 459 additions and 17 deletions

View file

@ -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;
}
}
}

View file

@ -72,6 +72,7 @@ class SignatureEditor extends DrawingEditor {
super({ ...params, mustBeCommitted: true, name: "signatureEditor" });
this._willKeepAspectRatio = true;
this._description = "";
this._signatureUUID = null;
}
/** @inheritdoc */

View file

@ -30,6 +30,7 @@ import {
AnnotationType,
createValidAbsoluteUrl,
FeatureTest,
getUuid,
ImageKind,
InvalidPDFException,
normalizeUnicode,
@ -72,6 +73,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";
@ -108,6 +110,7 @@ export {
getDocument,
getFilenameFromUrl,
getPdfFilenameFromUrl,
getUuid,
getXfaPageViewport,
GlobalWorkerOptions,
ImageKind,
@ -128,6 +131,7 @@ export {
ResponseException,
setLayerDimensions,
shadow,
SignatureExtractor,
stopEvent,
SupportedImageMimeTypes,
TextLayer,

View file

@ -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);
});
});

View file

@ -21,6 +21,7 @@ import {
AnnotationType,
createValidAbsoluteUrl,
FeatureTest,
getUuid,
ImageKind,
InvalidPDFException,
normalizeUnicode,
@ -62,6 +63,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";
@ -85,6 +87,7 @@ const expectedAPI = Object.freeze({
getDocument,
getFilenameFromUrl,
getPdfFilenameFromUrl,
getUuid,
getXfaPageViewport,
GlobalWorkerOptions,
ImageKind,
@ -105,6 +108,7 @@ const expectedAPI = Object.freeze({
ResponseException,
setLayerDimensions,
shadow,
SignatureExtractor,
stopEvent,
SupportedImageMimeTypes,
TextLayer,

View file

@ -461,13 +461,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({

View file

@ -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 {

View file

@ -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");
}

View file

@ -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);
}

View file

@ -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 };

View file

@ -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 {

View file

@ -32,6 +32,7 @@ const {
getDocument,
getFilenameFromUrl,
getPdfFilenameFromUrl,
getUuid,
getXfaPageViewport,
GlobalWorkerOptions,
ImageKind,
@ -52,6 +53,7 @@ const {
ResponseException,
setLayerDimensions,
shadow,
SignatureExtractor,
stopEvent,
SupportedImageMimeTypes,
TextLayer,
@ -81,6 +83,7 @@ export {
getDocument,
getFilenameFromUrl,
getPdfFilenameFromUrl,
getUuid,
getXfaPageViewport,
GlobalWorkerOptions,
ImageKind,
@ -101,6 +104,7 @@ export {
ResponseException,
setLayerDimensions,
shadow,
SignatureExtractor,
stopEvent,
SupportedImageMimeTypes,
TextLayer,

View file

@ -571,14 +571,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;
}
}
}
}

View file

@ -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();
}

View file

@ -742,7 +742,7 @@ See https://github.com/adobe-type-tools/cmap-resources
<input type="checkbox" id="addSignatureSaveCheckbox" checked="true" tabindex="0"></input>
<label for="addSignatureSaveCheckbox" data-l10n-id="pdfjs-editor-add-signature-save-checkbox">Save signature</label>
<span></span>
<span id="addSignatureSaveWarning" hidden="true" data-l10n-id="pdfjs-editor-add-signature-save-warning-message">Youve reached the limit of 5 saved signatures. Remove one to save more.</span>
<span id="addSignatureSaveWarning" data-l10n-id="pdfjs-editor-add-signature-save-warning-message">Youve reached the limit of 5 saved signatures. Remove one to save more.</span>
</div>
</div>
<div id="addSignatureError" hidden="true" class="messageBar">