/* 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, AnnotationPrefix } from "../../shared/util.js"; import { ColorScheme, OutputScale, PixelsPerInch, SupportedImageMimeTypes, } from "../display_utils.js"; import { AnnotationEditor } from "./editor.js"; import { StampAnnotationElement } from "../annotation_layer.js"; /** * Basic text editor in order to create a FreeTex annotation. */ class StampEditor extends AnnotationEditor { #bitmap = null; #bitmapId = null; #bitmapPromise = null; #bitmapUrl = null; #bitmapFile = null; #bitmapFileName = ""; #canvas = null; #missingCanvas = false; #resizeTimeoutId = null; #isSvg = false; #hasBeenAddedInUndoStack = false; static _type = "stamp"; static _editorType = AnnotationEditorType.STAMP; constructor(params) { super({ ...params, name: "stampEditor" }); this.#bitmapUrl = params.bitmapUrl; this.#bitmapFile = params.bitmapFile; this.defaultL10nId = "pdfjs-editor-stamp-editor"; } /** @inheritdoc */ static initialize(l10n, uiManager) { AnnotationEditor.initialize(l10n, uiManager); } /** @inheritdoc */ static isHandlingMimeForPasting(mime) { return SupportedImageMimeTypes.includes(mime); } /** @inheritdoc */ static paste(item, parent) { parent.pasteEditor( { mode: AnnotationEditorType.STAMP }, { bitmapFile: item.getAsFile() } ); } /** @inheritdoc */ altTextFinish() { if (this._uiManager.useNewAltTextFlow) { this.div.hidden = false; } super.altTextFinish(); } /** @inheritdoc */ get telemetryFinalData() { return { type: "stamp", hasAltText: !!this.altTextData?.altText, }; } static computeTelemetryFinalData(data) { const hasAltTextStats = data.get("hasAltText"); return { hasAltText: hasAltTextStats.get(true) ?? 0, hasNoAltText: hasAltTextStats.get(false) ?? 0, }; } #getBitmapFetched(data, fromId = false) { if (!data) { this.remove(); return; } this.#bitmap = data.bitmap; if (!fromId) { this.#bitmapId = data.id; this.#isSvg = data.isSvg; } if (data.file) { this.#bitmapFileName = data.file.name; } this.#createCanvas(); } #getBitmapDone() { this.#bitmapPromise = null; this._uiManager.enableWaiting(false); if (!this.#canvas) { return; } if ( this._uiManager.useNewAltTextWhenAddingImage && this._uiManager.useNewAltTextFlow && this.#bitmap ) { this.addEditToolbar().then(() => { this._editToolbar.hide(); this._uiManager.editAltText(this, /* firstTime = */ true); }); return; } if ( !this._uiManager.useNewAltTextWhenAddingImage && this._uiManager.useNewAltTextFlow && this.#bitmap ) { this._reportTelemetry({ action: "pdfjs.image.image_added", data: { alt_text_modal: false, alt_text_type: "empty" }, }); try { // The alt-text dialog isn't opened but we still want to guess the alt // text. this.mlGuessAltText(); } catch {} } this.div.focus(); } async mlGuessAltText(imageData = null, updateAltTextData = true) { if (this.hasAltTextData()) { return null; } const { mlManager } = this._uiManager; if (!mlManager) { throw new Error("No ML."); } if (!(await mlManager.isEnabledFor("altText"))) { throw new Error("ML isn't enabled for alt text."); } const { data, width, height } = imageData || this.copyCanvas(null, null, /* createImageData = */ true).imageData; const response = await mlManager.guess({ name: "altText", request: { data, width, height, channels: data.length / (width * height), }, }); if (!response) { throw new Error("No response from the AI service."); } if (response.error) { throw new Error("Error from the AI service."); } if (response.cancel) { return null; } if (!response.output) { throw new Error("No valid response from the AI service."); } const altText = response.output; await this.setGuessedAltText(altText); if (updateAltTextData && !this.hasAltTextData()) { this.altTextData = { alt: altText, decorative: false }; } return altText; } #getBitmap() { if (this.#bitmapId) { this._uiManager.enableWaiting(true); this._uiManager.imageManager .getFromId(this.#bitmapId) .then(data => this.#getBitmapFetched(data, /* fromId = */ true)) .finally(() => this.#getBitmapDone()); return; } if (this.#bitmapUrl) { const url = this.#bitmapUrl; this.#bitmapUrl = null; this._uiManager.enableWaiting(true); this.#bitmapPromise = this._uiManager.imageManager .getFromUrl(url) .then(data => this.#getBitmapFetched(data)) .finally(() => this.#getBitmapDone()); return; } if (this.#bitmapFile) { const file = this.#bitmapFile; this.#bitmapFile = null; this._uiManager.enableWaiting(true); this.#bitmapPromise = this._uiManager.imageManager .getFromFile(file) .then(data => this.#getBitmapFetched(data)) .finally(() => this.#getBitmapDone()); return; } const input = document.createElement("input"); if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { input.hidden = true; input.id = "stampEditorFileInput"; document.body.append(input); } input.type = "file"; input.accept = SupportedImageMimeTypes.join(","); const signal = this._uiManager._signal; this.#bitmapPromise = new Promise(resolve => { input.addEventListener( "change", async () => { if (!input.files || input.files.length === 0) { this.remove(); } else { this._uiManager.enableWaiting(true); const data = await this._uiManager.imageManager.getFromFile( input.files[0] ); this._reportTelemetry({ action: "pdfjs.image.image_selected", data: { alt_text_modal: this._uiManager.useNewAltTextFlow }, }); this.#getBitmapFetched(data); } if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { input.remove(); } resolve(); }, { signal } ); input.addEventListener( "cancel", () => { this.remove(); resolve(); }, { signal } ); }).finally(() => this.#getBitmapDone()); if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("TESTING")) { input.click(); } } /** @inheritdoc */ remove() { if (this.#bitmapId) { this.#bitmap = null; this._uiManager.imageManager.deleteId(this.#bitmapId); this.#canvas?.remove(); this.#canvas = null; if (this.#resizeTimeoutId) { clearTimeout(this.#resizeTimeoutId); this.#resizeTimeoutId = null; } } super.remove(); } /** @inheritdoc */ rebuild() { if (!this.parent) { // It's possible to have to rebuild an editor which is not on a visible // page. if (this.#bitmapId) { this.#getBitmap(); } return; } super.rebuild(); if (this.div === null) { return; } if (this.#bitmapId && this.#canvas === null) { this.#getBitmap(); } if (!this.isAttachedToDOM) { // At some point this editor was removed and we're rebuilting it, // hence we must add it to its parent. this.parent.add(this); } } /** @inheritdoc */ onceAdded(focus) { this._isDraggable = true; if (focus) { this.div.focus(); } } /** @inheritdoc */ isEmpty() { return !( this.#bitmapPromise || this.#bitmap || this.#bitmapUrl || this.#bitmapFile || this.#bitmapId || this.#missingCanvas ); } /** @inheritdoc */ get toolbarButtons() { return [["altText", this.createAltText()]]; } /** @inheritdoc */ get isResizable() { return true; } /** @inheritdoc */ render() { if (this.div) { return this.div; } let baseX, baseY; if (this._isCopy) { baseX = this.x; baseY = this.y; } super.render(); this.div.hidden = true; this.createAltText(); if (!this.#missingCanvas) { if (this.#bitmap) { this.#createCanvas(); } else { this.#getBitmap(); } } if (this._isCopy) { this._moveAfterPaste(baseX, baseY); } this._uiManager.addShouldRescale(this); return this.div; } setCanvas(annotationElementId, canvas) { const { id: bitmapId, bitmap } = this._uiManager.imageManager.getFromCanvas( annotationElementId, canvas ); canvas.remove(); if (bitmapId && this._uiManager.imageManager.isValidId(bitmapId)) { this.#bitmapId = bitmapId; if (bitmap) { this.#bitmap = bitmap; } this.#missingCanvas = false; this.#createCanvas(); } } /** @inheritdoc */ _onResized() { // We used a CSS-zoom during the resizing, but now it's resized we can // rescale correctly the bitmap to fit the new dimensions. this.onScaleChanging(); } onScaleChanging() { if (!this.parent) { return; } if (this.#resizeTimeoutId !== null) { clearTimeout(this.#resizeTimeoutId); } // The user's zooming the page, there is no need to redraw the bitmap at // each step, hence we wait a bit before redrawing it. const TIME_TO_WAIT = 200; this.#resizeTimeoutId = setTimeout(() => { this.#resizeTimeoutId = null; this.#drawBitmap(); }, TIME_TO_WAIT); } #createCanvas() { const { div } = this; let { width, height } = this.#bitmap; const [pageWidth, pageHeight] = this.pageDimensions; const MAX_RATIO = 0.75; if (this.width) { width = this.width * pageWidth; height = this.height * pageHeight; } else if ( width > MAX_RATIO * pageWidth || height > MAX_RATIO * pageHeight ) { // If the image is too big compared to the page dimensions // (more than MAX_RATIO) then we scale it down. const factor = Math.min( (MAX_RATIO * pageWidth) / width, (MAX_RATIO * pageHeight) / height ); width *= factor; height *= factor; } const [parentWidth, parentHeight] = this.parentDimensions; this.setDims( (width * parentWidth) / pageWidth, (height * parentHeight) / pageHeight ); this._uiManager.enableWaiting(false); const canvas = (this.#canvas = document.createElement("canvas")); canvas.setAttribute("role", "img"); this.addContainer(canvas); this.width = width / pageWidth; this.height = height / pageHeight; if (this._initialOptions?.isCentered) { this.center(); } else { this.fixAndSetPosition(); } this._initialOptions = null; if ( !this._uiManager.useNewAltTextWhenAddingImage || !this._uiManager.useNewAltTextFlow || this.annotationElementId ) { div.hidden = false; } this.#drawBitmap(); if (!this.#hasBeenAddedInUndoStack) { this.parent.addUndoableEditor(this); this.#hasBeenAddedInUndoStack = true; } // There are multiple ways to add an image to the page, so here we just // count the number of times an image is added to the page whatever the way // is. this._reportTelemetry({ action: "inserted_image", }); if (this.#bitmapFileName) { this.div.setAttribute("aria-description", this.#bitmapFileName); } if (!this.annotationElementId) { this._uiManager.a11yAlert("pdfjs-editor-stamp-added-alert"); } } copyCanvas(maxDataDimension, maxPreviewDimension, createImageData = false) { if (!maxDataDimension) { // TODO: get this value from Firefox // (https://bugzilla.mozilla.org/show_bug.cgi?id=1908184) // It's the maximum dimension that the AI can handle. maxDataDimension = 224; } const { width: bitmapWidth, height: bitmapHeight } = this.#bitmap; const outputScale = new OutputScale(); let bitmap = this.#bitmap; let width = bitmapWidth, height = bitmapHeight; let canvas = null; if (maxPreviewDimension) { if ( bitmapWidth > maxPreviewDimension || bitmapHeight > maxPreviewDimension ) { const ratio = Math.min( maxPreviewDimension / bitmapWidth, maxPreviewDimension / bitmapHeight ); width = Math.floor(bitmapWidth * ratio); height = Math.floor(bitmapHeight * ratio); } canvas = document.createElement("canvas"); const scaledWidth = (canvas.width = Math.ceil(width * outputScale.sx)); const scaledHeight = (canvas.height = Math.ceil(height * outputScale.sy)); if (!this.#isSvg) { bitmap = this.#scaleBitmap(scaledWidth, scaledHeight); } const ctx = canvas.getContext("2d"); ctx.filter = this._uiManager.hcmFilter; // Add a checkerboard pattern as a background in case the image has some // transparency. let white = "white", black = "#cfcfd8"; if (this._uiManager.hcmFilter !== "none") { black = "black"; } else if (ColorScheme.isDarkMode) { white = "#8f8f9d"; black = "#42414d"; } const boxDim = 15; const boxDimWidth = boxDim * outputScale.sx; const boxDimHeight = boxDim * outputScale.sy; const pattern = new OffscreenCanvas(boxDimWidth * 2, boxDimHeight * 2); const patternCtx = pattern.getContext("2d"); patternCtx.fillStyle = white; patternCtx.fillRect(0, 0, boxDimWidth * 2, boxDimHeight * 2); patternCtx.fillStyle = black; patternCtx.fillRect(0, 0, boxDimWidth, boxDimHeight); patternCtx.fillRect(boxDimWidth, boxDimHeight, boxDimWidth, boxDimHeight); ctx.fillStyle = ctx.createPattern(pattern, "repeat"); ctx.fillRect(0, 0, scaledWidth, scaledHeight); ctx.drawImage( bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, scaledWidth, scaledHeight ); } let imageData = null; if (createImageData) { let dataWidth, dataHeight; if ( outputScale.symmetric && bitmap.width < maxDataDimension && bitmap.height < maxDataDimension ) { dataWidth = bitmap.width; dataHeight = bitmap.height; } else { bitmap = this.#bitmap; if (bitmapWidth > maxDataDimension || bitmapHeight > maxDataDimension) { const ratio = Math.min( maxDataDimension / bitmapWidth, maxDataDimension / bitmapHeight ); dataWidth = Math.floor(bitmapWidth * ratio); dataHeight = Math.floor(bitmapHeight * ratio); if (!this.#isSvg) { bitmap = this.#scaleBitmap(dataWidth, dataHeight); } } } const offscreen = new OffscreenCanvas(dataWidth, dataHeight); const offscreenCtx = offscreen.getContext("2d", { willReadFrequently: true, }); offscreenCtx.drawImage( bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, dataWidth, dataHeight ); imageData = { width: dataWidth, height: dataHeight, data: offscreenCtx.getImageData(0, 0, dataWidth, dataHeight).data, }; } return { canvas, width, height, imageData }; } #scaleBitmap(width, height) { const { width: bitmapWidth, height: bitmapHeight } = this.#bitmap; let newWidth = bitmapWidth; let newHeight = bitmapHeight; let bitmap = this.#bitmap; while (newWidth > 2 * width || newHeight > 2 * height) { const prevWidth = newWidth; const prevHeight = newHeight; if (newWidth > 2 * width) { // See bug 1820511 (Windows specific bug). // TODO: once the above bug is fixed we could revert to: // newWidth = Math.ceil(newWidth / 2); newWidth = newWidth >= 16384 ? Math.floor(newWidth / 2) - 1 : Math.ceil(newWidth / 2); } if (newHeight > 2 * height) { newHeight = newHeight >= 16384 ? Math.floor(newHeight / 2) - 1 : 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 ); bitmap = offscreen.transferToImageBitmap(); } return bitmap; } #drawBitmap() { const [parentWidth, parentHeight] = this.parentDimensions; const { width, height } = this; const outputScale = new OutputScale(); const scaledWidth = Math.ceil(width * parentWidth * outputScale.sx); const scaledHeight = Math.ceil(height * parentHeight * outputScale.sy); const canvas = this.#canvas; if ( !canvas || (canvas.width === scaledWidth && canvas.height === scaledHeight) ) { return; } canvas.width = scaledWidth; canvas.height = scaledHeight; const bitmap = this.#isSvg ? this.#bitmap : this.#scaleBitmap(scaledWidth, scaledHeight); const ctx = canvas.getContext("2d"); ctx.filter = this._uiManager.hcmFilter; ctx.drawImage( bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, scaledWidth, scaledHeight ); } #serializeBitmap(toUrl) { if (toUrl) { if (this.#isSvg) { const url = this._uiManager.imageManager.getSvgUrl(this.#bitmapId); if (url) { return url; } } // We convert to a data url because it's sync and the url can live in the // clipboard. const canvas = document.createElement("canvas"); ({ width: canvas.width, height: canvas.height } = this.#bitmap); const ctx = canvas.getContext("2d"); ctx.drawImage(this.#bitmap, 0, 0); return canvas.toDataURL(); } if (this.#isSvg) { const [pageWidth, pageHeight] = this.pageDimensions; // Multiply by PixelsPerInch.PDF_TO_CSS_UNITS in order to increase the // image resolution when rasterizing it. const width = Math.round( this.width * pageWidth * PixelsPerInch.PDF_TO_CSS_UNITS ); const height = Math.round( this.height * pageHeight * PixelsPerInch.PDF_TO_CSS_UNITS ); const offscreen = new OffscreenCanvas(width, height); const ctx = offscreen.getContext("2d"); ctx.drawImage( this.#bitmap, 0, 0, this.#bitmap.width, this.#bitmap.height, 0, 0, width, height ); return offscreen.transferToImageBitmap(); } return structuredClone(this.#bitmap); } /** @inheritdoc */ static async deserialize(data, parent, uiManager) { let initialData = null; let missingCanvas = false; if (data instanceof StampAnnotationElement) { const { data: { rect, rotation, id, structParent, popupRef, richText, contentsObj, creationDate, modificationDate, }, container, parent: { page: { pageNumber }, }, canvas, } = data; let bitmapId, bitmap; if (canvas) { delete data.canvas; ({ id: bitmapId, bitmap } = uiManager.imageManager.getFromCanvas( container.id, canvas )); canvas.remove(); } else { missingCanvas = true; data._hasNoCanvas = true; } // When switching to edit mode, we wait for the structure tree to be // ready (see pdf_viewer.js), so it's fine to use getAriaAttributesSync. const altText = ( await parent._structTree.getAriaAttributes(`${AnnotationPrefix}${id}`) )?.get("aria-label") || ""; initialData = data = { annotationType: AnnotationEditorType.STAMP, bitmapId, bitmap, pageIndex: pageNumber - 1, rect: rect.slice(0), rotation, annotationElementId: id, id, deleted: false, accessibilityData: { decorative: false, altText, }, isSvg: false, structParent, popupRef, richText, comment: contentsObj?.str || null, creationDate, modificationDate, }; } const editor = await super.deserialize(data, parent, uiManager); const { rect, bitmap, bitmapUrl, bitmapId, isSvg, accessibilityData } = data; if (missingCanvas) { uiManager.addMissingCanvas(data.id, editor); editor.#missingCanvas = true; } else if (bitmapId && uiManager.imageManager.isValidId(bitmapId)) { editor.#bitmapId = bitmapId; if (bitmap) { editor.#bitmap = bitmap; } } else { editor.#bitmapUrl = bitmapUrl; } editor.#isSvg = isSvg; const [parentWidth, parentHeight] = editor.pageDimensions; editor.width = (rect[2] - rect[0]) / parentWidth; editor.height = (rect[3] - rect[1]) / parentHeight; if (accessibilityData) { editor.altTextData = accessibilityData; } editor._initialData = initialData; if (data.comment) { editor.setCommentData(data); } // No need to be add in the undo stack if the editor is created from an // existing one. editor.#hasBeenAddedInUndoStack = !!initialData; return editor; } /** @inheritdoc */ serialize(isForCopying = false, context = null) { if (this.isEmpty()) { return null; } if (this.deleted) { return this.serializeDeleted(); } const serialized = { annotationType: AnnotationEditorType.STAMP, bitmapId: this.#bitmapId, pageIndex: this.pageIndex, rect: this.getPDFRect(), rotation: this.rotation, isSvg: this.#isSvg, structTreeParentId: this._structTreeParentId, }; this.addComment(serialized); if (isForCopying) { // We don't know what's the final destination (this pdf or another one) // of this annotation and the clipboard doesn't support ImageBitmaps, // hence we serialize the bitmap to a data url. serialized.bitmapUrl = this.#serializeBitmap(/* toUrl = */ true); serialized.accessibilityData = this.serializeAltText(true); serialized.isCopy = true; return serialized; } const { decorative, altText } = this.serializeAltText(false); if (!decorative && altText) { serialized.accessibilityData = { type: "Figure", alt: altText }; } if (this.annotationElementId) { const changes = this.#hasElementChanged(serialized); if (changes.isSame) { // Nothing has been changed. return null; } if (changes.isSameAltText) { delete serialized.accessibilityData; } else { serialized.accessibilityData.structParent = this._initialData.structParent ?? -1; } serialized.id = this.annotationElementId; delete serialized.bitmapId; return serialized; } if (context === null) { return serialized; } context.stamps ||= new Map(); const area = this.#isSvg ? (serialized.rect[2] - serialized.rect[0]) * (serialized.rect[3] - serialized.rect[1]) : null; if (!context.stamps.has(this.#bitmapId)) { // We don't want to have multiple copies of the same bitmap in the // annotationMap, hence we only add the bitmap the first time we meet it. context.stamps.set(this.#bitmapId, { area, serialized }); serialized.bitmap = this.#serializeBitmap(/* toUrl = */ false); } else if (this.#isSvg) { // If we have multiple copies of the same svg but with different sizes, // then we want to keep the biggest one. const prevData = context.stamps.get(this.#bitmapId); if (area > prevData.area) { prevData.area = area; prevData.serialized.bitmap.close(); prevData.serialized.bitmap = this.#serializeBitmap(/* toUrl = */ false); } } return serialized; } #hasElementChanged(serialized) { const { pageIndex, accessibilityData: { altText }, } = this._initialData; const isSamePageIndex = serialized.pageIndex === pageIndex; const isSameAltText = (serialized.accessibilityData?.alt || "") === altText; return { isSame: !this.hasEditedComment && !this._hasBeenMoved && !this._hasBeenResized && isSamePageIndex && isSameAltText, isSameAltText, }; } /** @inheritdoc */ renderAnnotationElement(annotation) { if (this.deleted) { annotation.hide(); return null; } const params = { rect: this.getPDFRect(), }; if (this.hasEditedComment) { params.popup = this.comment; } annotation.updateEdited(params); return null; } } export { StampEditor };