diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index dabf039e57a13b..51715b910e52fe 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -85,6 +85,10 @@ "type": "boolean", "default": true }, + "enableSignatureEditor": { + "type": "boolean", + "default": false + }, "disableRange": { "title": "Disable range requests", "description": "Whether to disable range requests (not recommended).", diff --git a/src/core/annotation.js b/src/core/annotation.js index bfa9caabe38fb9..558e0417b2b97c 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -266,12 +266,15 @@ class AnnotationFactory { return null; } let imagePromises; - for (const { bitmapId, bitmap } of annotations) { + for (const { bitmapId, bitmap, onlyAlpha } of annotations) { if (!bitmap) { continue; } imagePromises ||= new Map(); - imagePromises.set(bitmapId, StampAnnotation.createImage(bitmap, xref)); + imagePromises.set( + bitmapId, + StampAnnotation.createImage(bitmap, onlyAlpha, xref) + ); } return imagePromises; @@ -288,6 +291,27 @@ class AnnotationFactory { if (annotation.deleted) { continue; } + + let image; + if (annotation.bitmapId && isOffscreenCanvasSupported) { + image = await imagePromises.get(annotation.bitmapId); + if (image.imageStream) { + const { imageStream, smaskStream } = image; + const buffer = []; + if (smaskStream) { + const smaskRef = xref.getNewTemporaryRef(); + await writeObject(smaskRef, smaskStream, buffer, null); + dependencies.push({ ref: smaskRef, data: buffer.join("") }); + imageStream.dict.set("SMask", smaskRef); + buffer.length = 0; + } + const imageRef = (image.imageRef = xref.getNewTemporaryRef()); + await writeObject(imageRef, imageStream, buffer, null); + dependencies.push({ ref: imageRef, data: buffer.join("") }); + image.imageStream = image.smaskStream = null; + } + } + switch (annotation.annotationType) { case AnnotationEditorType.FREETEXT: if (!baseFontRef) { @@ -316,25 +340,10 @@ class AnnotationFactory { ); break; case AnnotationEditorType.STAMP: - if (!isOffscreenCanvasSupported) { + case AnnotationEditorType.SIGNATURE: + if (!image) { break; } - const image = await imagePromises.get(annotation.bitmapId); - if (image.imageStream) { - const { imageStream, smaskStream } = image; - const buffer = []; - if (smaskStream) { - const smaskRef = xref.getNewTemporaryRef(); - await writeObject(smaskRef, smaskStream, buffer, null); - dependencies.push({ ref: smaskRef, data: buffer.join("") }); - imageStream.dict.set("SMask", smaskRef); - buffer.length = 0; - } - const imageRef = (image.imageRef = xref.getNewTemporaryRef()); - await writeObject(imageRef, imageStream, buffer, null); - dependencies.push({ ref: imageRef, data: buffer.join("") }); - image.imageStream = image.smaskStream = null; - } promises.push( StampAnnotation.createNewAnnotation( xref, @@ -369,6 +378,20 @@ class AnnotationFactory { if (annotation.deleted) { continue; } + + let image; + if (annotation.bitmapId && options.isOffscreenCanvasSupported) { + image = await imagePromises.get(annotation.bitmapId); + if (image.imageStream) { + const { imageStream, smaskStream } = image; + if (smaskStream) { + imageStream.dict.set("SMask", smaskStream); + } + image.imageRef = new JpegStream(imageStream, imageStream.length); + image.imageStream = image.smaskStream = null; + } + } + switch (annotation.annotationType) { case AnnotationEditorType.FREETEXT: promises.push( @@ -387,18 +410,10 @@ class AnnotationFactory { ); break; case AnnotationEditorType.STAMP: - if (!options.isOffscreenCanvasSupported) { + case AnnotationEditorType.SIGNATURE: + if (!image) { break; } - const image = await imagePromises.get(annotation.bitmapId); - if (image.imageStream) { - const { imageStream, smaskStream } = image; - if (smaskStream) { - imageStream.dict.set("SMask", smaskStream); - } - image.imageRef = new JpegStream(imageStream, imageStream.length); - image.imageStream = image.smaskStream = null; - } promises.push( StampAnnotation.createNewPrintAnnotation(xref, annotation, { image, @@ -1685,7 +1700,7 @@ class WidgetAnnotation extends Annotation { data.fieldType = fieldType instanceof Name ? fieldType.name : null; const localResources = getInheritableProperty({ dict, key: "DR" }); - const acroFormResources = params.acroForm.get("DR"); + const acroFormResources = params.acroForm?.get("DR"); const appearanceResources = this.appearance?.dict.get("Resources"); this._fieldResources = { @@ -4480,7 +4495,7 @@ class StampAnnotation extends MarkupAnnotation { this.data.hasOwnCanvas = this.data.noRotate; } - static async createImage(bitmap, xref) { + static async createImage(bitmap, onlyAlpha, xref) { // TODO: when printing, we could have a specific internal colorspace // (e.g. something like DeviceRGBA) in order avoid any conversion (i.e. no // jpeg, no rgba to rgb conversion, etc...) @@ -4493,22 +4508,30 @@ class StampAnnotation extends MarkupAnnotation { ctx.drawImage(bitmap, 0, 0); const data = ctx.getImageData(0, 0, width, height).data; const buf32 = new Uint32Array(data.buffer); - const hasAlpha = buf32.some( - FeatureTest.isLittleEndian - ? x => x >>> 24 !== 0xff - : x => (x & 0xff) !== 0xff - ); + const hasAlpha = + onlyAlpha || + buf32.some( + FeatureTest.isLittleEndian + ? x => x >>> 24 !== 0xff + : x => (x & 0xff) !== 0xff + ); if (hasAlpha) { // Redraw the image on a white background in order to remove the thin gray // line which can appear when exporting to jpeg. - ctx.fillStyle = "white"; - ctx.fillRect(0, 0, width, height); - ctx.drawImage(bitmap, 0, 0); + if (!onlyAlpha) { + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, width, height); + ctx.drawImage(bitmap, 0, 0); + } else { + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, width, height); + } } + const quality = onlyAlpha ? 0 : 1; const jpegBufferPromise = canvas - .convertToBlob({ type: "image/jpeg", quality: 1 }) + .convertToBlob({ type: "image/jpeg", quality }) .then(blob => { return blob.arrayBuffer(); }); diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 84b61c536519d4..1672ed30d28cf3 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -70,6 +70,10 @@ class DOMFilterFactory extends BaseFilterFactory { #hcmHighlightUrl; + #signatureFilter; + + #signatureFilterUrl; + #id = 0; constructor({ docId, ownerDocument = globalThis.document } = {}) { @@ -310,6 +314,26 @@ class DOMFilterFactory extends BaseFilterFactory { return this.#hcmHighlightUrl; } + addSignatureFilter() { + if (this.#signatureFilterUrl) { + return this.#signatureFilterUrl; + } + + this.#signatureFilterUrl = "none"; + this.#signatureFilter?.remove(); + + const id = `g_${this.#docId}_signature_filter`; + const filter = (this.#signatureFilter = this.#createFilter(id)); + + const feMorphology = this.#document.createElementNS(SVG_NS, "feMorphology"); + feMorphology.setAttribute("operator", "erode"); + feMorphology.setAttribute("radius", "1"); + filter.append(feMorphology); + + this.#signatureFilterUrl = `url(#${id})`; + return this.#signatureFilterUrl; + } + destroy(keepHCM = false) { if (keepHCM && (this.#hcmUrl || this.#hcmHighlightUrl)) { return; diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index c6fd7f2b6f727b..4a72cb38c10a6f 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -28,6 +28,7 @@ import { bindEvents } from "./tools.js"; import { FreeTextEditor } from "./freetext.js"; import { InkEditor } from "./ink.js"; import { setLayerDimensions } from "../display_utils.js"; +import { SignatureEditor } from "./signature.js"; import { StampEditor } from "./stamp.js"; /** @@ -86,7 +87,12 @@ class AnnotationEditorLayer { viewport, l10n, }) { - const editorTypes = [FreeTextEditor, InkEditor, StampEditor]; + const editorTypes = [ + FreeTextEditor, + InkEditor, + StampEditor, + SignatureEditor, + ]; if (!AnnotationEditorLayer._initialized) { AnnotationEditorLayer._initialized = true; for (const editorType of editorTypes) { @@ -145,6 +151,10 @@ class AnnotationEditorLayer { "stampEditing", mode === AnnotationEditorType.STAMP ); + this.div.classList.toggle( + "signatureEditing", + mode === AnnotationEditorType.SIGNATURE + ); this.div.hidden = false; } } @@ -442,6 +452,8 @@ class AnnotationEditorLayer { return new InkEditor(params); case AnnotationEditorType.STAMP: return new StampEditor(params); + case AnnotationEditorType.SIGNATURE: + return new SignatureEditor(params); } return null; } @@ -459,6 +471,8 @@ class AnnotationEditorLayer { return InkEditor.deserialize(data, this, this.#uiManager); case AnnotationEditorType.STAMP: return StampEditor.deserialize(data, this, this.#uiManager); + case AnnotationEditorType.SIGNATURE: + return SignatureEditor.deserialize(data, this, this.#uiManager); } return null; } @@ -673,4 +687,8 @@ class AnnotationEditorLayer { } } +if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { + AnnotationEditorLayer.SignatureEditor = SignatureEditor; +} + export { AnnotationEditorLayer }; diff --git a/src/display/editor/signature.js b/src/display/editor/signature.js new file mode 100644 index 00000000000000..0d5f994839aa4e --- /dev/null +++ b/src/display/editor/signature.js @@ -0,0 +1,271 @@ +/* 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 { AnnotationEditorType, FeatureTest } from "../../shared/util.js"; +import { StampEditor } from "./stamp.js"; + +/** + * Basic text editor in order to create a Signature annotation. + */ +class SignatureEditor extends StampEditor { + static _type = "signature"; + + constructor(params) { + super({ ...params, name: "signatureEditor" }); + } + + get MAX_RATIO() { + return 0.1; + } + + static #bilateralFilter(buf, width, height, sigmaS, sigmaR, kernelSize) { + // The bilateral filter is a nonlinear filter that does spatial averaging. + // It's main interest is to preserve edges while removing noise. + // See https://en.wikipedia.org/wiki/Bilateral_filter for more details. + + // Create a gaussian kernel + const kernel = new Float32Array(kernelSize * kernelSize); + const sigmaS2 = -2 * sigmaS * sigmaS; + 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 * sigmaR; + 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. + let min; + for (let i = 0; i < 256; i++) { + if (histogram[i] !== 0) { + min = i; + break; + } + } + + // Translate the histogram. + for (let i = 0; i < 256 - min; i++) { + histogram[i] = histogram[i + min]; + } + for (let i = 256 - min; i < 256; i++) { + histogram[i] = 0; + } + + // 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; i < N; i++) { + const pix = (out[i] = buf[i << 2] & 0xff); + 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 #threshold(buf, threshold) { + // Apply the threshold to the buffer and transform the grayscale into + // transparency. + const N = buf.length; + const out = new Uint32Array(N); + if (FeatureTest.isLittleEndian) { + for (let i = 0; i < N; i++) { + const pix = buf[i]; + out[i] = pix <= threshold ? (255 - pix) << 24 : 0; + } + } else { + for (let i = 0; i < N; i++) { + const pix = buf[i]; + out[i] = pix <= threshold ? 255 - pix : 0; + } + } + 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 unfortunatelly 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 < 255; 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; + } + + _preProcess(bitmap) { + return SignatureEditor.preProcess(bitmap); + } + + static preProcess(bitmap) { + const { width, height } = bitmap; + let newWidth = width; + let newHeight = height; + const maxDim = 512; + if (width > maxDim || height > maxDim) { + const ratio = maxDim / Math.max(width, height); + newWidth = Math.floor(width * ratio); + newHeight = Math.floor(height * ratio); + bitmap = this._scaleBitmap(bitmap, newWidth, newHeight); + } + + const offscreen = new OffscreenCanvas(newWidth, newHeight); + const ctx = offscreen.getContext("2d"); + 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); + const sigmaS = Math.hypot(newWidth, newHeight) * 0.02; + const [uint8Filtered, histogram] = this.#bilateralFilter( + uint8Buf, + newWidth, + newHeight, + sigmaS, + 25, + 16 + ); + const threshold = this.#guessThreshold(histogram); + const uint32Thresholded = this.#threshold(uint8Filtered, threshold); + + ctx.putImageData( + new ImageData( + new Uint8ClampedArray(uint32Thresholded.buffer), + newWidth, + newHeight + ), + 0, + 0 + ); + bitmap = offscreen.transferToImageBitmap(); + + return bitmap; + } + + /** @inheritdoc */ + serialize(isForCopying = false, context = null) { + const serialized = super.serialize(isForCopying, context); + serialized.annotationType = AnnotationEditorType.SIGNATURE; + // All the data are in the transparent channel, hence we don't care about + // the other channels. + serialized.onlyAlpha = true; + + return serialized; + } +} + +export { SignatureEditor }; diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js index c1ef08c5e4a2d5..750725a8e7da04 100644 --- a/src/display/editor/stamp.js +++ b/src/display/editor/stamp.js @@ -206,11 +206,21 @@ class StampEditor extends AnnotationEditor { return this.div; } + _preProcess(bitmap) { + return bitmap; + } + + get MAX_RATIO() { + return 0.75; + } + #createCanvas() { const { div } = this; let { width, height } = this.#bitmap; - const [pageWidth, pageHeight] = this.pageDimensions; - const MAX_RATIO = 0.75; + const { + pageDimensions: [pageWidth, pageHeight], + MAX_RATIO, + } = this; if (this.width) { width = this.width * pageWidth; height = this.height * pageHeight; @@ -237,6 +247,7 @@ class StampEditor extends AnnotationEditor { const canvas = (this.#canvas = document.createElement("canvas")); div.append(canvas); + this.#bitmap = this._preProcess(this.#bitmap); this.#drawBitmap(width, height); this.#createObserver(); div.classList.remove("loading"); @@ -275,12 +286,11 @@ class StampEditor extends AnnotationEditor { }, TIME_TO_WAIT); } - #scaleBitmap(width, height) { - const { width: bitmapWidth, height: bitmapHeight } = this.#bitmap; + static _scaleBitmap(bitmap, width, height) { + const { width: bitmapWidth, height: bitmapHeight } = bitmap; let newWidth = bitmapWidth; let newHeight = bitmapHeight; - let bitmap = this.#bitmap; while (newWidth > 2 * width || newHeight > 2 * height) { const prevWidth = newWidth; const prevHeight = newHeight; @@ -329,7 +339,7 @@ class StampEditor extends AnnotationEditor { canvas.height = height; const bitmap = this.#isSvg ? this.#bitmap - : this.#scaleBitmap(width, height); + : StampEditor._scaleBitmap(this.#bitmap, width, height); const ctx = canvas.getContext("2d"); ctx.filter = this._uiManager.hcmFilter; ctx.drawImage( diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 58d209d042c95f..7250de7c12b866 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -627,6 +627,10 @@ class AnnotationEditorUIManager { ); } + get signatureFilter() { + return this.#filterFactory.addSignatureFilter(); + } + onPageChanging({ pageNumber }) { this.#currentPageIndex = pageNumber - 1; } diff --git a/src/shared/util.js b/src/shared/util.js index 70184dd8d3dc34..b16dd8c22ad5d6 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -72,6 +72,7 @@ const AnnotationEditorType = { FREETEXT: 3, STAMP: 13, INK: 15, + SIGNATURE: 27, }; const AnnotationEditorParamsType = { @@ -141,6 +142,7 @@ const AnnotationType = { WATERMARK: 24, THREED: 25, REDACT: 26, + SIGNATURE: 27, }; const AnnotationReplyType = { diff --git a/test/driver.js b/test/driver.js index aff13480268d5e..67110b8064c69d 100644 --- a/test/driver.js +++ b/test/driver.js @@ -15,6 +15,7 @@ /* globals pdfjsLib, pdfjsViewer */ const { + AnnotationEditorLayer, AnnotationLayer, AnnotationMode, getDocument, @@ -28,6 +29,8 @@ const { const { GenericL10n, NullL10n, parseQueryString, SimpleLinkService } = pdfjsViewer; +const SignatureEditor = AnnotationEditorLayer.SignatureEditor; + const WAITING_TIME = 100; // ms const CMAP_URL = "/build/generic/web/cmaps/"; const STANDARD_FONT_DATA_URL = "/build/generic/web/standard_fonts/"; @@ -431,6 +434,7 @@ class Driver { task.pageNum = task.firstPage || 1; task.stats = { times: [] }; task.enableXfa = task.enableXfa === true; + task.signature = task.signature === true; const prevFile = md5FileMap.get(task.md5); if (prevFile) { @@ -461,6 +465,31 @@ class Driver { this._log('Loading file "' + task.file + '"\n'); + if (task.signature) { + fetch(new URL(`./${task.file}`, window.location)) + .then(response => response.blob()) + .then(blob => createImageBitmap(blob)) + .then(bitmap => { + bitmap = SignatureEditor.preProcess(bitmap); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(bitmap, 0, 0); + const dataUrl = canvas.toDataURL("image/png"); + this._sendResult(dataUrl, task, failure).then(() => { + this._log( + "done" + (failure ? " (failed !: " + failure + ")" : "") + "\n" + ); + this.currentTask++; + this._nextTask(); + }); + }); + return; + } + try { let xfaStyleElement = null; if (task.enableXfa) { diff --git a/test/images/signature1.jpg b/test/images/signature1.jpg new file mode 100644 index 00000000000000..0729ecc76bed26 Binary files /dev/null and b/test/images/signature1.jpg differ diff --git a/test/images/signature2.png b/test/images/signature2.png new file mode 100644 index 00000000000000..3871b93a65ceab Binary files /dev/null and b/test/images/signature2.png differ diff --git a/test/test_manifest.json b/test/test_manifest.json index dde91f41fc6fdf..7b10d2c7b13509 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -7964,5 +7964,19 @@ "rotation": 0 } } + }, + { + "id": "signature-hello1", + "file": "images/signature1.jpg", + "type": "eq", + "md5": "bcd840f44b71da179431a11f9db59640", + "signature": true + }, + { + "id": "signature-hello2", + "file": "images/signature2.png", + "type": "eq", + "md5": "8a9fbc71831f636054548db900cf698b", + "signature": true } ] diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 08c5252b91511f..1b532255e797f0 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -146,7 +146,9 @@ } .annotationEditorLayer - :is(.freeTextEditor, .inkEditor, .stampEditor):hover:not(.selectedEditor) { + :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor):hover:not( + .selectedEditor + ) { outline: var(--hover-outline); } @@ -169,12 +171,12 @@ touch-action: none; } -.annotationEditorLayer .stampEditor { +.annotationEditorLayer :is(.stampEditor, .signatureEditor) { width: auto; height: auto; } -.annotationEditorLayer .stampEditor.loading { +.annotationEditorLayer :is(.stampEditor, .signatureEditor).loading { aspect-ratio: 1; width: 10%; height: auto; @@ -187,11 +189,11 @@ transition-delay: var(--loading-icon-delay); } -.annotationEditorLayer .stampEditor.selectedEditor { +.annotationEditorLayer :is(.stampEditor, .signatureEditor).selectedEditor { resize: horizontal; } -.annotationEditorLayer .stampEditor canvas { +.annotationEditorLayer :is(.stampEditor, .signatureEditor) canvas { width: 100%; height: 100%; } diff --git a/web/app.js b/web/app.js index baf06fb5dd247b..5977687780629d 100644 --- a/web/app.js +++ b/web/app.js @@ -566,6 +566,12 @@ const PDFViewerApplication = { if (AppOptions.get("enableStampEditor") && isOffscreenCanvasSupported) { appConfig.toolbar?.editorStampButton?.classList.remove("hidden"); } + if ( + AppOptions.get("enableSignatureEditor") && + isOffscreenCanvasSupported + ) { + appConfig.toolbar?.editorSignatureButton?.classList.remove("hidden"); + } this.annotationEditorParams = new AnnotationEditorParams( appConfig.annotationEditorParams, diff --git a/web/app_options.js b/web/app_options.js index 5814e89f2dc6d6..9861e31cc63ef9 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -103,6 +103,17 @@ const defaultOptions = { value: typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME"), kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableSignatureEditor: { + // We want to be able to add a signature in a pdf. + // It's almost the same thing as the stamp editor but we try to remove the + // background from the image. + // We need to have some inputs from the UI/UX team in order to make it + // suitable for Firefox, it's why it's disabled by default. + // TODO: remove it when unnecessary. + /** @type {boolean} */ + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableStampEditor: { // We'll probably want to make some experiments before enabling this // in Firefox release, but it has to be temporary. diff --git a/web/toolbar.js b/web/toolbar.js index 826f59733fea27..0061d1fb949129 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -102,6 +102,18 @@ class Toolbar { }, }, }, + { + element: options.editorSignatureButton, + eventName: "switchannotationeditormode", + eventDetails: { + get mode() { + const { classList } = options.editorSignatureButton; + return classList.contains("toggled") + ? AnnotationEditorType.NONE + : AnnotationEditorType.SIGNATURE; + }, + }, + }, ]; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { this.buttons.push({ element: options.openFile, eventName: "openfile" }); @@ -218,6 +230,7 @@ class Toolbar { editorInkButton, editorInkParamsToolbar, editorStampButton, + editorSignatureButton, }) { const editorModeChanged = ({ mode }) => { toggleCheckedBtn( @@ -231,11 +244,16 @@ class Toolbar { editorInkParamsToolbar ); toggleCheckedBtn(editorStampButton, mode === AnnotationEditorType.STAMP); + toggleCheckedBtn( + editorSignatureButton, + mode === AnnotationEditorType.SIGNATURE + ); const isDisable = mode === AnnotationEditorType.DISABLE; editorFreeTextButton.disabled = isDisable; editorInkButton.disabled = isDisable; editorStampButton.disabled = isDisable; + editorSignatureButton.disabled = isDisable; }; this.eventBus._on("annotationeditormodechanged", editorModeChanged); diff --git a/web/viewer.css b/web/viewer.css index fd5ec4cbf2b85c..357c5702be1e6e 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -81,6 +81,7 @@ --toolbarButton-editorFreeText-icon: url(images/toolbarButton-editorFreeText.svg); --toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg); --toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg); + --toolbarButton-editorSignature-icon: url(images/toolbarButton-editorInk.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); @@ -901,6 +902,10 @@ body { mask-image: var(--toolbarButton-editorStamp-icon); } +#editorSignature::before { + mask-image: var(--toolbarButton-editorSignature-icon); +} + #print::before, #secondaryPrint::before { mask-image: var(--toolbarButton-print-icon); diff --git a/web/viewer.html b/web/viewer.html index b94f1d8e23c562..fba2b51828ea22 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -330,13 +330,16 @@
- + - -
diff --git a/web/viewer.js b/web/viewer.js index 701d64fcd91b13..b7c18eab79e11d 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -64,6 +64,7 @@ function getViewerConfiguration() { editorInkButton: document.getElementById("editorInk"), editorInkParamsToolbar: document.getElementById("editorInkParamsToolbar"), editorStampButton: document.getElementById("editorStamp"), + editorSignatureButton: document.getElementById("editorSignature"), download: document.getElementById("download"), }, secondaryToolbar: {