diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 886cc87b44029..e3cc9e941780c 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -71,6 +71,10 @@ "type": "string", "default": "" }, + "enableSignatureEditor": { + "type": "boolean", + "default": false + }, "enableUpdatedAddImage": { "type": "boolean", "default": false diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 7d718f7e53ce8..e4cfd009588ee 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -31,6 +31,7 @@ import { FreeTextEditor } from "./freetext.js"; import { HighlightEditor } from "./highlight.js"; import { InkEditor } from "./ink.js"; import { setLayerDimensions } from "../display_utils.js"; +import { SignatureEditor } from "./signature.js"; import { StampEditor } from "./stamp.js"; /** @@ -89,10 +90,13 @@ class AnnotationEditorLayer { static _initialized = false; static #editorTypes = new Map( - [FreeTextEditor, InkEditor, StampEditor, HighlightEditor].map(type => [ - type._editorType, - type, - ]) + [ + FreeTextEditor, + InkEditor, + StampEditor, + HighlightEditor, + SignatureEditor, + ].map(type => [type._editorType, type]) ); /** @@ -758,7 +762,11 @@ class AnnotationEditorLayer { return; } - if (this.#uiManager.getMode() === AnnotationEditorType.STAMP) { + const currentMode = this.#uiManager.getMode(); + if ( + currentMode === AnnotationEditorType.STAMP || + currentMode === AnnotationEditorType.SIGNATURE + ) { this.#uiManager.unselectAll(); return; } diff --git a/src/display/editor/draw.js b/src/display/editor/draw.js index a2f5c1de91da4..28d8b76cb34d7 100644 --- a/src/display/editor/draw.js +++ b/src/display/editor/draw.js @@ -91,6 +91,10 @@ class DrawingEditor extends AnnotationEditor { super(params); this.#mustBeCommitted = params.mustBeCommitted || false; + this._addOutlines(params); + } + + _addOutlines(params) { if (params.drawOutlines) { this.#createDrawOutlines(params); this.#addToDrawLayer(); diff --git a/src/display/editor/drawers/contour.js b/src/display/editor/drawers/contour.js new file mode 100644 index 0000000000000..528e25d71500c --- /dev/null +++ b/src/display/editor/drawers/contour.js @@ -0,0 +1,28 @@ +/* Copyright 2024 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 { InkDrawOutline } from "./inkdraw.js"; + +class ContourDrawOutline extends InkDrawOutline { + toSVGPath() { + let path = super.toSVGPath(); + if (!path.endsWith("Z")) { + path += "Z"; + } + return path; + } +} + +export { ContourDrawOutline }; diff --git a/src/display/editor/drawers/signaturedraw.js b/src/display/editor/drawers/signaturedraw.js new file mode 100644 index 0000000000000..ef92ee961cc20 --- /dev/null +++ b/src/display/editor/drawers/signaturedraw.js @@ -0,0 +1,599 @@ +/* Copyright 2022 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 { ContourDrawOutline } from "./contour.js"; +import { FeatureTest } from "../../../shared/util.js"; +import { Outline } from "./outline.js"; + +/** + * Basic text editor in order to create a Signature annotation. + */ +class SignatureExtractor { + static #PARAMETERS = { + maxDim: 512, + sigmaSFactor: 0.02, + sigmaR: 20, + kernelSize: 16, + }; + + static #neighborIndexToId(i0, j0, i, j) { + /* + 3 2 1 + 4 X 0 + 5 6 7 + */ + + i -= i0; + j -= j0; + + if (i === 0) { + return j > 0 ? 0 : 4; + } + + if (i === 1) { + // eslint-disable-next-line no-nested-ternary + return j === 1 ? 7 : j === 0 ? 6 : 5; + } + + // eslint-disable-next-line no-nested-ternary + return j === 1 ? 1 : j === 0 ? 2 : 3; + } + + static #neighborIdToIndex = new Int32Array([ + 0, 1, -1, 1, -1, 0, -1, -1, 0, -1, 1, -1, 1, 0, 1, 1, + ]); + + static #clockwiseNonZero(buf, width, i0, j0, i, j, offset) { + const id = this.#neighborIndexToId(i0, j0, i, j); + for (let k = 0; k < 8; k++) { + const kk = (-k + id - offset + 16) % 8; + const shiftI = this.#neighborIdToIndex[2 * kk]; + const shiftJ = this.#neighborIdToIndex[2 * kk + 1]; + if (buf[(i0 + shiftI) * width + (j0 + shiftJ)] !== 0) { + return kk; + } + } + return -1; + } + + static #counterclockwiseNonZero(buf, width, i0, j0, i, j, offset) { + const id = this.#neighborIndexToId(i0, j0, i, j); + for (let k = 0; k < 8; k++) { + const kk = (k + id + offset + 16) % 8; + const shiftI = this.#neighborIdToIndex[2 * kk]; + const shiftJ = this.#neighborIdToIndex[2 * kk + 1]; + if (buf[(i0 + shiftI) * width + (j0 + shiftJ)] !== 0) { + return kk; + } + } + return -1; + } + + static #findContours(buf, width, height, threshold) { + // Based on the Suzuki's algorithm: + // https://www.nevis.columbia.edu/~vgenty/public/suzuki_et_al.pdf + + const N = buf.length; + const types = new Int32Array(N); + for (let i = 0; i < N; i++) { + types[i] = buf[i] <= threshold ? 1 : 0; + } + + for (let i = 1; i < height - 1; i++) { + types[i * width] = types[i * width + width - 1] = 0; + } + for (let i = 0; i < width; i++) { + types[i] = types[width * height - 1 - i] = 0; + } + + let nbd = 1; + let lnbd; + const contours = []; + + for (let i = 1; i < height - 1; i++) { + lnbd = 1; + for (let j = 1; j < width - 1; j++) { + const ij = i * width + j; + const pix = types[ij]; + if (pix === 0) { + continue; + } + + let i2 = i; + let j2 = j; + + if (pix === 1 && types[ij - 1] === 0) { + // Outer border. + nbd += 1; + j2 -= 1; + } else if (pix >= 1 && types[ij + 1] === 0) { + // Hole border. + nbd += 1; + j2 += 1; + if (pix > 1) { + lnbd = pix; + } + } else { + if (pix !== 1) { + lnbd = Math.abs(pix); + } + continue; + } + + const points = [j, i]; + const isHole = j2 === j + 1; + const contour = { + isHole, + points, + id: nbd, + parent: 0, + }; + contours.push(contour); + + let contour0; + for (const c of contours) { + if (c.id === lnbd) { + contour0 = c; + break; + } + } + + if (!contour0) { + contour.parent = isHole ? lnbd : 0; + } else if (contour0.isHole) { + contour.parent = isHole ? contour0.parent : lnbd; + } else { + contour.parent = isHole ? lnbd : contour0.parent; + } + + const k = this.#clockwiseNonZero(types, width, i, j, i2, j2, 0); + if (k === -1) { + types[ij] = -nbd; + if (types[ij] !== 1) { + lnbd = Math.abs(types[ij]); + } + continue; + } + + let shiftI = this.#neighborIdToIndex[2 * k]; + let shiftJ = this.#neighborIdToIndex[2 * k + 1]; + const i1 = i + shiftI; + const j1 = j + shiftJ; + i2 = i1; + j2 = j1; + let i3 = i; + let j3 = j; + + while (true) { + const kk = this.#counterclockwiseNonZero( + types, + width, + i3, + j3, + i2, + j2, + 1 + ); + shiftI = this.#neighborIdToIndex[2 * kk]; + shiftJ = this.#neighborIdToIndex[2 * kk + 1]; + const i4 = i3 + shiftI; + const j4 = j3 + shiftJ; + points.push(j4, i4); + const ij3 = i3 * width + j3; + if (types[ij3 + 1] === 0) { + types[ij3] = -nbd; + } else if (types[ij3] === 1) { + types[ij3] = nbd; + } + + if (i4 === i && j4 === j && i3 === i1 && j3 === j1) { + if (types[ij] !== 1) { + lnbd = Math.abs(types[ij]); + } + break; + } else { + i2 = i3; + j2 = j3; + i3 = i4; + j3 = j4; + } + } + } + } + return contours; + } + + static #douglasPeuckerHelper(points, start, end, output) { + // Based on the Douglas-Peucker algorithm: + // https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm + let dmax = 0; + let index = 0; + if (end - start < 6) { + for (let i = start; i < end; i++) { + output.push(points[i]); + } + return; + } + + const ax = points[start]; + const ay = points[start + 1]; + const bx = points[end - 4]; + const by = points[end - 3]; + const abx = bx - ax; + const aby = by - ay; + const dist = Math.hypot(abx, aby); + const nabx = abx / dist; + const naby = aby / dist; + const aa = nabx * ay - naby * ax; + + // Guessing the epsilon value. + // See "A novel framework for making dominant point detection methods + // non-parametric". + const m = aby / abx; + const invS = 1 / dist; + const phi = Math.atan(m); + const cosPhi = Math.cos(phi); + const sinPHi = Math.sin(phi); + const tmax = invS * (Math.abs(cosPhi) + Math.abs(sinPHi)); + const poly = invS * (1 - tmax + tmax ** 2); + const partialPhi = Math.max( + Math.atan(Math.abs(sinPHi + cosPhi) * poly), + Math.atan(Math.abs(sinPHi - cosPhi) * poly) + ); + const epsilon = (dist * partialPhi) ** 2; + + for (let i = start + 2; i < end - 2; i += 2) { + const x = points[i]; + const y = points[i + 1]; + const d = Math.abs(aa - nabx * y + naby * x); + if (d > dmax) { + index = i; + dmax = d; + } + } + if (dmax > epsilon) { + this.#douglasPeuckerHelper(points, start, index + 2, output); + this.#douglasPeuckerHelper(points, index, end, output); + } else { + output.push(ax, ay, bx, by); + } + } + + static #douglasPeucker(points) { + const output = []; + this.#douglasPeuckerHelper(points, 0, points.length, output); + return output.length <= 2 ? null : output; + } + + static _toImage(src, width, height) { + const dest = new Uint32Array(src.length); + if (FeatureTest.isLittleEndian) { + for (let i = 0, ii = src.length; i < ii; i++) { + dest[i] = (src[i] * 0x10101) | 0xff000000; + } + } else { + for (let i = 0, ii = src.length; i < ii; i++) { + dest[i] = (src[i] * 0x1010100) | 0x000000ff; + } + } + const offscreen = new OffscreenCanvas(width, height); + const ctx = offscreen.getContext("2d"); + ctx.putImageData( + new ImageData(new Uint8ClampedArray(dest.buffer), width, height), + 0, + 0 + ); + return offscreen; + } + + static #bilateralFilter(buf, width, height, sigmaS, sigmaR, kernelSize) { + // The bilateral filter is a nonlinear filter that does spatial averaging. + // Its main interest is to preserve edges while removing noise. + // See https://en.wikipedia.org/wiki/Bilateral_filter for more details. + // sigmaS is the standard deviation of the spatial gaussian. + // sigmaR is the standard deviation of the range (in term of pixel + // intensity) gaussian. + + // Create a gaussian kernel + const kernel = new Float32Array(kernelSize ** 2); + const sigmaS2 = -2 * sigmaS ** 2; + const halfSize = kernelSize >> 1; + + for (let i = 0; i < kernelSize; i++) { + const x = (i - halfSize) ** 2; + for (let j = 0; j < kernelSize; j++) { + const y = (j - halfSize) ** 2; + const v = Math.exp((x + y) / sigmaS2); + kernel[i * kernelSize + j] = v; + } + } + + // Create the range values to be used with the distance between pixels. + // It's a way faster with a lookup table than computing the exponential. + const rangeValues = new Float32Array(256); + const sigmaR2 = -2 * sigmaR ** 2; + for (let i = 0; i < 256; i++) { + rangeValues[i] = Math.exp(i ** 2 / sigmaR2); + } + + const N = buf.length; + const out = new Uint8Array(N); + + // We compute the histogram here instead of doing it later: it's slightly + // faster. + const histogram = new Uint32Array(256); + for (let i = 0; i < height; i++) { + for (let j = 0; j < width; j++) { + const ij = i * width + j; + const center = buf[ij]; + let sum = 0; + let norm = 0; + + for (let k = 0; k < kernelSize; k++) { + const y = i + k - halfSize; + if (y < 0 || y >= height) { + continue; + } + for (let l = 0; l < kernelSize; l++) { + const x = j + l - halfSize; + if (x < 0 || x >= width) { + continue; + } + const neighbour = buf[y * width + x]; + const w = + kernel[k * kernelSize + l] * + rangeValues[Math.abs(neighbour - center)]; + sum += neighbour * w; + norm += w; + } + } + + const pix = (out[ij] = Math.round(sum / norm)); + histogram[pix]++; + } + } + + // Translate the histogram so that the first non-zero value is at index 0. + // We want to map the darkest pixel to black. + const min = histogram.findIndex(v => v !== 0); + + // Translate the histogram. + histogram.copyWithin(0, min); + histogram.fill(0, 256 - min); + + // Translate the pixels. + for (let i = 0; i < N; i++) { + out[i] -= min; + } + + return [out, histogram]; + } + + static #toUint8(buf) { + // We have a RGBA buffer, containing a grayscale image. + // We want to convert it into a basic G buffer. + // Also, we want to normalize the values between 0 and 255 in order to + // increase the contrast. + const N = buf.length; + const out = new Uint8Array(N >> 2); + let max = -Infinity; + let min = Infinity; + for (let i = 0, ii = out.length; i < ii; i++) { + const A = buf[(i << 2) + 3]; + if (A === 0) { + max = out[i] = 0xff; + continue; + } + const pix = (out[i] = buf[i << 2]); + if (pix > max) { + max = pix; + } + if (pix < min) { + min = pix; + } + } + const ratio = 255 / (max - min); + for (let i = 0; i < N; i++) { + out[i] = Math.round((out[i] - min) * ratio); + } + + return out; + } + + static #guessThreshold(histogram) { + // We want to find the threshold that will separate the background from the + // foreground. + // We could have used Otsu's method, but unfortunately it doesn't work well + // when the background has too much shade of greys. + // So the idea is to find a maximum in the black part of the histogram and + // figure out the value which will be the first one of the white part. + + let i; + let M = -Infinity; + let L = -Infinity; + let pos = 0; + let spos = 0; + for (i = 0; i < 256; i++) { + const v = histogram[i]; + if (v > M) { + if (i - pos > L) { + L = i - pos; + spos = i - 1; + } + M = v; + pos = i; + } + } + for (i = spos - 1; i >= 0; i--) { + if (histogram[i] > histogram[i + 1]) { + break; + } + } + + return i; + } + + static #getGrayPixels(bitmap) { + const originalBitmap = bitmap; + const { width, height } = bitmap; + const { maxDim } = this.#PARAMETERS; + let newWidth = width; + let newHeight = height; + + if (width > maxDim || height > maxDim) { + let prevWidth = width; + let prevHeight = height; + + let steps = Math.log2(Math.max(width, height) / maxDim); + const isteps = Math.floor(steps); + steps = steps === isteps ? isteps - 1 : isteps; + for (let i = 0; i < steps; i++) { + newWidth = prevWidth; + newHeight = prevHeight; + if (newWidth > maxDim) { + newWidth = Math.ceil(newWidth / 2); + } + if (newHeight > maxDim) { + newHeight = Math.ceil(newHeight / 2); + } + + const offscreen = new OffscreenCanvas(newWidth, newHeight); + const ctx = offscreen.getContext("2d"); + ctx.drawImage( + bitmap, + 0, + 0, + prevWidth, + prevHeight, + 0, + 0, + newWidth, + newHeight + ); + prevWidth = newWidth; + prevHeight = newHeight; + + // Release the resources associated with the bitmap. + if (bitmap !== originalBitmap) { + bitmap.close(); + } + bitmap = offscreen.transferToImageBitmap(); + } + + if (newWidth > maxDim) { + newWidth = maxDim; + } + if (newHeight > maxDim) { + newHeight = maxDim; + } + } + const offscreen = new OffscreenCanvas(newWidth, newHeight); + const ctx = offscreen.getContext("2d", { willReadFrequently: true }); + ctx.filter = "grayscale(1)"; + ctx.drawImage( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + 0, + 0, + newWidth, + newHeight + ); + const grayImage = ctx.getImageData(0, 0, newWidth, newHeight).data; + const uint8Buf = this.#toUint8(grayImage); + + return [uint8Buf, newWidth, newHeight]; + } + + static process(bitmap, pageWidth, pageHeight, rotation, innerMargin) { + const [uint8Buf, width, height] = this.#getGrayPixels(bitmap); + const [uint8Filtered, histogram] = this.#bilateralFilter( + uint8Buf, + width, + height, + Math.hypot(width, height) * this.#PARAMETERS.sigmaSFactor, + this.#PARAMETERS.sigmaR, + this.#PARAMETERS.kernelSize + ); + + const threshold = this.#guessThreshold(histogram); + const contourList = this.#findContours( + uint8Filtered, + width, + height, + threshold + ); + const linesAndPoints = []; + + if (rotation % 180 !== 0) { + [pageWidth, pageHeight] = [pageHeight, pageWidth]; + } + + // The points need to be converted into page coordinates. + const ratio = 0.5 * Math.min(pageWidth / width, pageHeight / height); + const xScale = ratio / pageWidth; + const yScale = ratio / pageHeight; + + for (const { points } of contourList) { + const reducedPoints = this.#douglasPeucker(points); + if (!reducedPoints) { + continue; + } + + const len = reducedPoints.length; + const newPoints = new Float32Array(len); + const line = new Float32Array(len === 4 ? 12 : 3 * (len - 2)); + + let [x1, y1, x2, y2] = reducedPoints; + x1 *= xScale; + y1 *= yScale; + x2 *= xScale; + y2 *= yScale; + newPoints.set([x1, y1, x2, y2], 0); + + if (len === 4) { + line.set([NaN, NaN, NaN, NaN, x1, y1, NaN, NaN, NaN, NaN, x2, y2], 0); + } else { + line.set([NaN, NaN, NaN, NaN, x1, y1], 0); + for (let i = 4; i < len; i += 2) { + const x = (newPoints[i] = reducedPoints[i] * xScale); + const y = (newPoints[i + 1] = reducedPoints[i + 1] * yScale); + line.set( + Outline.createBezierPoints(x1, y1, x2, y2, x, y), + (i - 2) * 3 + ); + [x1, y1, x2, y2] = [x2, y2, x, y]; + } + } + linesAndPoints.push({ line, points: newPoints }); + } + const outline = new ContourDrawOutline(); + outline.build( + linesAndPoints, + pageWidth, + pageHeight, + 1, + rotation, + 0, + innerMargin + ); + + return outline; + } +} + +export { SignatureExtractor }; diff --git a/src/display/editor/signature.js b/src/display/editor/signature.js new file mode 100644 index 0000000000000..a6053046715eb --- /dev/null +++ b/src/display/editor/signature.js @@ -0,0 +1,158 @@ +/* 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 { AnnotationEditorType, shadow } from "../../shared/util.js"; +import { DrawingEditor, DrawingOptions } from "./draw.js"; +import { AnnotationEditor } from "./editor.js"; +import { SignatureExtractor } from "./drawers/signaturedraw.js"; +import { StampEditor } from "./stamp.js"; + +class SignatureOptions extends DrawingOptions { + #viewParameters; + + constructor(viewerParameters) { + super(); + this.#viewParameters = viewerParameters; + + super.updateProperties({ + fill: "black", + "stroke-width": 0, + }); + } + + clone() { + const clone = new SignatureOptions(this.#viewParameters); + clone.updateAll(this); + return clone; + } +} + +/** + * Basic draw editor in order to generate an Ink annotation. + */ +class SignatureEditor extends DrawingEditor { + static _type = "signature"; + + static _editorType = AnnotationEditorType.SIGNATURE; + + static _defaultDrawingOptions = null; + + constructor(params) { + super({ ...params, mustBeCommitted: true, name: "signatureEditor" }); + this._willKeepAspectRatio = false; + } + + /** @inheritdoc */ + static initialize(l10n, uiManager) { + AnnotationEditor.initialize(l10n, uiManager); + this._defaultDrawingOptions = new SignatureOptions( + uiManager.viewParameters + ); + } + + /** @inheritdoc */ + static getDefaultDrawingOptions(options) { + const clone = this._defaultDrawingOptions.clone(); + clone.updateProperties(options); + return clone; + } + + /** @inheritdoc */ + static get supportMultipleDrawings() { + return false; + } + + static get typesMap() { + return shadow(this, "typesMap", new Map()); + } + + static get isDrawer() { + return false; + } + + /** @inheritdoc */ + get isResizable() { + return true; + } + + /** @inheritdoc */ + render() { + if (this.div) { + return this.div; + } + + super.render(); + this.div.hidden = true; + this.div.setAttribute("role", "figure"); + + this.#extractSignature(); + + return this.div; + } + + async #extractSignature() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = StampEditor.supportedTypesStr; + const signal = this._uiManager._signal; + const { promise, resolve } = Promise.withResolvers(); + + input.addEventListener( + "change", + async () => { + if (!input.files || input.files.length === 0) { + resolve(); + } else { + this._uiManager.enableWaiting(true); + const data = await this._uiManager.imageManager.getFromFile( + input.files[0] + ); + this._uiManager.enableWaiting(false); + resolve(data); + } + resolve(); + }, + { signal } + ); + input.addEventListener("cancel", resolve, { signal }); + input.click(); + + const bitmap = await promise; + if (!bitmap?.bitmap) { + this.remove(); + return; + } + const { + rawDims: { pageWidth, pageHeight }, + rotation, + } = this.parent.viewport; + const drawOutlines = SignatureExtractor.process( + bitmap.bitmap, + pageWidth, + pageHeight, + rotation, + SignatureEditor._INNER_MARGIN + ); + this._addOutlines({ + drawOutlines, + drawingOptions: SignatureEditor.getDefaultDrawingOptions(), + }); + this.onScaleChanging(); + this.rotate(); + this.div.hidden = false; + } +} + +export { SignatureEditor }; diff --git a/src/display/editor/toolbar.js b/src/display/editor/toolbar.js index b6a71f51a0b2c..24cd47d1b3505 100644 --- a/src/display/editor/toolbar.js +++ b/src/display/editor/toolbar.js @@ -36,6 +36,7 @@ class EditorToolbar { highlight: "pdfjs-editor-remove-highlight-button", ink: "pdfjs-editor-remove-ink-button", stamp: "pdfjs-editor-remove-stamp-button", + signature: "pdfjs-editor-remove-signature-button", }); } diff --git a/src/shared/util.js b/src/shared/util.js index e0f66716ab9ca..823a9c04f7ade 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -78,6 +78,7 @@ const AnnotationEditorType = { HIGHLIGHT: 9, STAMP: 13, INK: 15, + SIGNATURE: 27, }; const AnnotationEditorParamsType = { diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index d778c7e83d2d8..b34bfe50bcc2a 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -162,7 +162,8 @@ box-sizing: border-box; } -.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) { +.annotationEditorLayer + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) { position: absolute; background: transparent; z-index: 1; @@ -220,7 +221,13 @@ } .annotationEditorLayer - :is(.freeTextEditor, .inkEditor, .stampEditor, .highlightEditor), + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .highlightEditor, + .signatureEditor + ), .textLayer { .editToolbar { --editor-toolbar-delete-image: url(images/editor-toolbar-delete.svg); @@ -622,7 +629,7 @@ } .annotationEditorLayer { - :is(.freeTextEditor, .inkEditor, .stampEditor) { + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) { & > .resizers { position: absolute; inset: 0; diff --git a/web/annotation_editor_params.js b/web/annotation_editor_params.js index a69206214b3c9..5e50839ad517c 100644 --- a/web/annotation_editor_params.js +++ b/web/annotation_editor_params.js @@ -27,6 +27,7 @@ import { AnnotationEditorParamsType } from "pdfjs-lib"; * @property {HTMLButtonElement} editorStampAddImage * @property {HTMLInputElement} editorFreeHighlightThickness * @property {HTMLButtonElement} editorHighlightShowAll + * @property {HTMLButtonElement} editorSignatureAddSignature */ class AnnotationEditorParams { @@ -51,6 +52,7 @@ class AnnotationEditorParams { editorStampAddImage, editorFreeHighlightThickness, editorHighlightShowAll, + editorSignatureAddSignature, }) { const dispatchEvent = (typeStr, value) => { this.eventBus.dispatch("switchannotationeditorparams", { @@ -92,6 +94,9 @@ class AnnotationEditorParams { this.setAttribute("aria-pressed", !checked); dispatchEvent("HIGHLIGHT_SHOW_ALL", !checked); }); + editorSignatureAddSignature.addEventListener("click", () => { + dispatchEvent("CREATE"); + }); this.eventBus._on("annotationeditorparamschanged", evt => { for (const [type, value] of evt.details) { diff --git a/web/app.js b/web/app.js index 109318be687f5..9f8a74b016249 100644 --- a/web/app.js +++ b/web/app.js @@ -537,6 +537,10 @@ const PDFViewerApplication = { typeof AbortSignal.any === "function") && annotationEditorMode !== AnnotationEditorType.DISABLE ) { + const editorSignatureButton = appConfig.toolbar?.editorSignatureButton; + if (editorSignatureButton && AppOptions.get("enableSignatureEditor")) { + editorSignatureButton.parentElement.hidden = false; + } this.annotationEditorParams = new AnnotationEditorParams( appConfig.annotationEditorParams, eventBus diff --git a/web/app_options.js b/web/app_options.js index 09a0720d8f5ac..708ffec98ece7 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -228,6 +228,11 @@ const defaultOptions = { value: typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME"), kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableSignatureEditor: { + /** @type {boolean} */ + value: true, //typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableUpdatedAddImage: { // We'll probably want to make some experiments before enabling this // in Firefox release, but it has to be temporary. diff --git a/web/editor_undo_bar.js b/web/editor_undo_bar.js index aa064e3841f0c..096547062db4c 100644 --- a/web/editor_undo_bar.js +++ b/web/editor_undo_bar.js @@ -39,6 +39,7 @@ class EditorUndoBar { freetext: "pdfjs-editor-undo-bar-message-freetext", stamp: "pdfjs-editor-undo-bar-message-stamp", ink: "pdfjs-editor-undo-bar-message-ink", + signature: "pdfjs-editor-undo-bar-message-signature", _multiple: "pdfjs-editor-undo-bar-message-multiple", }); diff --git a/web/images/toolbarButton-editorSignature.svg b/web/images/toolbarButton-editorSignature.svg new file mode 100644 index 0000000000000..26e484864d6ab --- /dev/null +++ b/web/images/toolbarButton-editorSignature.svg @@ -0,0 +1,6 @@ + + + + diff --git a/web/toolbar.js b/web/toolbar.js index fa0d589acbe28..bc728206392d4 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -117,6 +117,18 @@ class Toolbar { data: { action: "pdfjs.image.icon_click" }, }, }, + { + element: options.editorSignatureButton, + eventName: "switchannotationeditormode", + eventDetails: { + get mode() { + const { classList } = options.editorSignatureButton; + return classList.contains("toggled") + ? AnnotationEditorType.NONE + : AnnotationEditorType.SIGNATURE; + }, + }, + }, ]; // Bind the event listeners for click and various other actions. @@ -279,6 +291,8 @@ class Toolbar { editorInkParamsToolbar, editorStampButton, editorStampParamsToolbar, + editorSignatureButton, + editorSignatureParamsToolbar, } = this.#opts; toggleExpandedBtn( @@ -301,12 +315,18 @@ class Toolbar { mode === AnnotationEditorType.STAMP, editorStampParamsToolbar ); + toggleExpandedBtn( + editorSignatureButton, + mode === AnnotationEditorType.SIGNATURE, + editorSignatureParamsToolbar + ); const isDisable = mode === AnnotationEditorType.DISABLE; editorFreeTextButton.disabled = isDisable; editorHighlightButton.disabled = isDisable; editorInkButton.disabled = isDisable; editorStampButton.disabled = isDisable; + editorSignatureButton.disabled = isDisable; } #updateUIState(resetNumPages = false) { diff --git a/web/viewer.css b/web/viewer.css index 71d7dd5f81a85..d577895b2726f 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -89,6 +89,7 @@ --toolbarButton-editorHighlight-icon: url(images/toolbarButton-editorHighlight.svg); --toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg); --toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg); + --toolbarButton-editorSignature-icon: url(images/toolbarButton-editorSignature.svg); --toolbarButton-menuArrow-icon: url(images/toolbarButton-menuArrow.svg); --toolbarButton-sidebarToggle-icon: url(images/toolbarButton-sidebarToggle.svg); --toolbarButton-secondaryToolbarToggle-icon: url(images/toolbarButton-secondaryToolbarToggle.svg); @@ -572,6 +573,10 @@ body { mask-image: var(--toolbarButton-editorStamp-icon); } +#editorSignatureButton::before { + mask-image: var(--toolbarButton-editorSignature-icon); +} + #printButton::before { mask-image: var(--toolbarButton-print-icon); } @@ -1119,7 +1124,7 @@ dialog :link { z-index: 30000; cursor: default; - #editorStampAddImage::before { + :is(#editorStampAddImage, #editorSignatureAddSignature)::before { mask-image: var(--editorParams-stampAddImage-icon); } diff --git a/web/viewer.html b/web/viewer.html index bee574ad9fdde..a5cebbb9e1d37 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -317,6 +317,18 @@ +
diff --git a/web/viewer.js b/web/viewer.js index a86d6f7e9f39f..2af123a536674 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -68,6 +68,10 @@ function getViewerConfiguration() { editorStampParamsToolbar: document.getElementById( "editorStampParamsToolbar" ), + editorSignatureButton: document.getElementById("editorSignatureButton"), + editorSignatureParamsToolbar: document.getElementById( + "editorSignatureParamsToolbar" + ), download: document.getElementById("downloadButton"), }, secondaryToolbar: { @@ -217,6 +221,9 @@ function getViewerConfiguration() { editorInkThickness: document.getElementById("editorInkThickness"), editorInkOpacity: document.getElementById("editorInkOpacity"), editorStampAddImage: document.getElementById("editorStampAddImage"), + editorSignatureAddSignature: document.getElementById( + "editorSignatureAddSignature" + ), editorFreeHighlightThickness: document.getElementById( "editorFreeHighlightThickness" ),