diff --git a/src/core/annotation.js b/src/core/annotation.js index 790c0936f..3b1ad9568 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -23,6 +23,7 @@ import { AnnotationType, assert, BASELINE_FACTOR, + FeatureTest, getModificationDate, IDENTITY_MATRIX, LINE_DESCENT_FACTOR, @@ -52,15 +53,16 @@ import { parseDefaultAppearance, } from "./default_appearance.js"; import { Dict, isName, Name, Ref, RefSet } from "./primitives.js"; +import { Stream, StringStream } from "./stream.js"; import { writeDict, writeObject } from "./writer.js"; import { BaseStream } from "./base_stream.js"; import { bidi } from "./bidi.js"; import { Catalog } from "./catalog.js"; import { ColorSpace } from "./colorspace.js"; import { FileSpec } from "./file_spec.js"; +import { JpegStream } from "./jpeg_stream.js"; import { ObjectLoader } from "./object_loader.js"; import { OperatorList } from "./operator_list.js"; -import { StringStream } from "./stream.js"; import { XFAFactory } from "./xfa/factory.js"; class AnnotationFactory { @@ -257,11 +259,31 @@ class AnnotationFactory { } } - static async saveNewAnnotations(evaluator, task, annotations) { + static generateImages(annotations, xref, isOffscreenCanvasSupported) { + if (!isOffscreenCanvasSupported) { + warn( + "generateImages: OffscreenCanvas is not supported, cannot save or print some annotations with images." + ); + return null; + } + let imagePromises; + for (const { bitmapId, bitmap } of annotations) { + if (!bitmap) { + continue; + } + imagePromises ||= new Map(); + imagePromises.set(bitmapId, StampAnnotation.createImage(bitmap, xref)); + } + + return imagePromises; + } + + static async saveNewAnnotations(evaluator, task, annotations, imagePromises) { const xref = evaluator.xref; let baseFontRef; const dependencies = []; const promises = []; + const { isOffscreenCanvasSupported } = evaluator.options; for (const annotation of annotations) { if (annotation.deleted) { @@ -293,6 +315,36 @@ class AnnotationFactory { promises.push( InkAnnotation.createNewAnnotation(xref, annotation, dependencies) ); + break; + case AnnotationEditorType.STAMP: + if (!isOffscreenCanvasSupported) { + 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, + annotation, + dependencies, + image + ) + ); + break; } } @@ -302,7 +354,12 @@ class AnnotationFactory { }; } - static async printNewAnnotations(evaluator, task, annotations) { + static async printNewAnnotations( + evaluator, + task, + annotations, + imagePromises + ) { if (!annotations) { return null; } @@ -331,6 +388,23 @@ class AnnotationFactory { }) ); break; + case AnnotationEditorType.STAMP: + if (!isOffscreenCanvasSupported) { + 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) + ); + break; } } @@ -4361,6 +4435,143 @@ class StampAnnotation extends MarkupAnnotation { this.data.annotationType = AnnotationType.STAMP; this.data.hasOwnCanvas = this.data.noRotate; } + + static async createImage(bitmap, 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...) + + const { width, height } = bitmap; + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext("2d", { alpha: true }); + + // Draw the image and get the data in order to extract the transparency. + 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 + ); + + 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); + } + + const jpegBufferPromise = canvas + .convertToBlob({ type: "image/jpeg", quality: 1 }) + .then(blob => { + return blob.arrayBuffer(); + }); + + const xobjectName = Name.get("XObject"); + const imageName = Name.get("Image"); + const image = new Dict(xref); + image.set("Type", xobjectName); + image.set("Subtype", imageName); + image.set("BitsPerComponent", 8); + image.set("ColorSpace", Name.get("DeviceRGB")); + image.set("Filter", Name.get("DCTDecode")); + image.set("BBox", [0, 0, width, height]); + image.set("Width", width); + image.set("Height", height); + + let smaskStream = null; + if (hasAlpha) { + const alphaBuffer = new Uint8Array(buf32.length); + if (FeatureTest.isLittleEndian) { + for (let i = 0, ii = buf32.length; i < ii; i++) { + alphaBuffer[i] = buf32[i] >>> 24; + } + } else { + for (let i = 0, ii = buf32.length; i < ii; i++) { + alphaBuffer[i] = buf32[i] & 0xff; + } + } + + const smask = new Dict(xref); + smask.set("Type", xobjectName); + smask.set("Subtype", imageName); + smask.set("BitsPerComponent", 8); + smask.set("ColorSpace", Name.get("DeviceGray")); + smask.set("Width", width); + smask.set("Height", height); + + smaskStream = new Stream(alphaBuffer, 0, 0, smask); + } + const imageStream = new Stream(await jpegBufferPromise, 0, 0, image); + + return { + imageStream, + smaskStream, + width, + height, + }; + } + + static createNewDict(annotation, xref, { apRef, ap }) { + const { rect, rotation, user } = annotation; + const stamp = new Dict(xref); + stamp.set("Type", Name.get("Annot")); + stamp.set("Subtype", Name.get("Stamp")); + stamp.set("CreationDate", `D:${getModificationDate()}`); + stamp.set("Rect", rect); + stamp.set("F", 4); + stamp.set("Border", [0, 0, 0]); + stamp.set("Rotate", rotation); + + if (user) { + stamp.set( + "T", + isAscii(user) ? user : stringToUTF16String(user, /* bigEndian = */ true) + ); + } + + if (apRef || ap) { + const n = new Dict(xref); + stamp.set("AP", n); + + if (apRef) { + n.set("N", apRef); + } else { + n.set("N", ap); + } + } + + return stamp; + } + + static async createNewAppearanceStream(annotation, xref, params) { + const { rotation } = annotation; + const { imageRef, width, height } = params; + const resources = new Dict(xref); + const xobject = new Dict(xref); + resources.set("XObject", xobject); + xobject.set("Im0", imageRef); + const appearance = `q ${width} 0 0 ${height} 0 0 cm /Im0 Do Q`; + + const appearanceStreamDict = new Dict(xref); + appearanceStreamDict.set("FormType", 1); + appearanceStreamDict.set("Subtype", Name.get("Form")); + appearanceStreamDict.set("Type", Name.get("XObject")); + appearanceStreamDict.set("BBox", [0, 0, width, height]); + appearanceStreamDict.set("Resources", resources); + + if (rotation) { + const matrix = getRotationMatrix(rotation, width, height); + appearanceStreamDict.set("Matrix", matrix); + } + + const ap = new StringStream(appearance); + ap.dict = appearanceStreamDict; + + return ap; + } } class FileAttachmentAnnotation extends MarkupAnnotation { diff --git a/src/core/document.js b/src/core/document.js index 6b0b1e8c1..63064ffd9 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -13,8 +13,8 @@ * limitations under the License. */ -import { AnnotationFactory, PopupAnnotation } from "./annotation.js"; import { + AnnotationEditorPrefix, assert, FormatError, info, @@ -30,6 +30,7 @@ import { Util, warn, } from "../shared/util.js"; +import { AnnotationFactory, PopupAnnotation } from "./annotation.js"; import { collectActions, getInheritableProperty, @@ -277,7 +278,7 @@ class Page { } } - async saveNewAnnotations(handler, task, annotations) { + async saveNewAnnotations(handler, task, annotations, imagePromises) { if (this.xfaFactory) { throw new Error("XFA: Cannot save new annotations."); } @@ -306,7 +307,8 @@ class Page { const newData = await AnnotationFactory.saveNewAnnotations( partialEvaluator, task, - annotations + annotations, + imagePromises ); for (const { ref } of newData.annotations) { @@ -433,14 +435,52 @@ class Page { let newAnnotationsPromise = Promise.resolve(null); if (newAnnotationsByPage) { + let imagePromises; const newAnnotations = newAnnotationsByPage.get(this.pageIndex); if (newAnnotations) { + // An annotation can contain a reference to a bitmap, but this bitmap + // is defined in another annotation. So we need to find this annotation + // and generate the bitmap. + const missingBitmaps = new Set(); + for (const { bitmapId, bitmap } of newAnnotations) { + if (bitmapId && !bitmap && !missingBitmaps.has(bitmapId)) { + missingBitmaps.add(bitmapId); + } + } + + const { isOffscreenCanvasSupported } = this.evaluatorOptions; + if (missingBitmaps.size > 0) { + const annotationWithBitmaps = []; + for (const [key, annotation] of annotationStorage) { + if (!key.startsWith(AnnotationEditorPrefix)) { + continue; + } + if (annotation.bitmap && missingBitmaps.has(annotation.bitmapId)) { + annotationWithBitmaps.push(annotation); + } + } + // The array annotationWithBitmaps cannot be empty: the check above + // makes sure to have at least one annotation containing the bitmap. + imagePromises = AnnotationFactory.generateImages( + annotationWithBitmaps, + this.xref, + isOffscreenCanvasSupported + ); + } else { + imagePromises = AnnotationFactory.generateImages( + newAnnotations, + this.xref, + isOffscreenCanvasSupported + ); + } + deletedAnnotations = new RefSet(); this.#replaceIdByRef(newAnnotations, deletedAnnotations, null); newAnnotationsPromise = AnnotationFactory.printNewAnnotations( partialEvaluator, task, - newAnnotations + newAnnotations, + imagePromises ); } } diff --git a/src/core/worker.js b/src/core/worker.js index 00e96e92a..52479345d 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -36,6 +36,7 @@ import { } from "./core_utils.js"; import { Dict, Ref } from "./primitives.js"; import { LocalPdfManager, NetworkPdfManager } from "./pdf_manager.js"; +import { AnnotationFactory } from "./annotation.js"; import { clearGlobalCaches } from "./cleanup_helper.js"; import { incrementalUpdate } from "./writer.js"; import { isNodeJS } from "../shared/is_node.js"; @@ -537,12 +538,11 @@ class WorkerMessageHandler { handler.on( "SaveDocument", - function ({ isPureXfa, numPages, annotationStorage, filename }) { + async function ({ isPureXfa, numPages, annotationStorage, filename }) { const promises = [ pdfManager.requestLoadedStream(), pdfManager.ensureCatalog("acroForm"), pdfManager.ensureCatalog("acroFormRef"), - pdfManager.ensureDoc("xref"), pdfManager.ensureDoc("startXRef"), ]; @@ -550,13 +550,21 @@ class WorkerMessageHandler { ? getNewAnnotationsMap(annotationStorage) : null; + const xref = await pdfManager.ensureDoc("xref"); + if (newAnnotationsByPage) { + const imagePromises = AnnotationFactory.generateImages( + annotationStorage.values(), + xref, + pdfManager.evaluatorOptions.isOffscreenCanvasSupported + ); + for (const [pageIndex, annotations] of newAnnotationsByPage) { promises.push( pdfManager.getPage(pageIndex).then(page => { const task = new WorkerTask(`Save (editor): page ${pageIndex}`); return page - .saveNewAnnotations(handler, task, annotations) + .saveNewAnnotations(handler, task, annotations, imagePromises) .finally(function () { finishWorkerTask(task); }); @@ -586,7 +594,6 @@ class WorkerMessageHandler { stream, acroForm, acroFormRef, - xref, startXRef, ...refs ]) { diff --git a/src/display/api.js b/src/display/api.js index f4589953c..fd0a27e04 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1812,6 +1812,15 @@ class PDFPageProxy { ); } + const transfers = []; + if (annotationStorageMap) { + for (const annotation of annotationStorageMap.values()) { + if (annotation.bitmap) { + transfers.push(annotation.bitmap); + } + } + } + const readableStream = this._transport.messageHandler.sendWithStream( "GetOperatorList", { @@ -1819,7 +1828,8 @@ class PDFPageProxy { intent: renderingIntent, cacheKey, annotationStorage: annotationStorageMap, - } + }, + transfers ); const reader = readableStream.getReader(); @@ -2898,13 +2908,26 @@ class WorkerTransport { "please use the getData-method instead." ); } + const annotationStorage = this.annotationStorage.serializable; + const transfers = []; + if (annotationStorage) { + for (const annotation of annotationStorage.values()) { + if (annotation.bitmap) { + transfers.push(annotation.bitmap); + } + } + } return this.messageHandler - .sendWithPromise("SaveDocument", { - isPureXfa: !!this._htmlForXfa, - numPages: this._numPages, - annotationStorage: this.annotationStorage.serializable, - filename: this._fullReader?.filename ?? null, - }) + .sendWithPromise( + "SaveDocument", + { + isPureXfa: !!this._htmlForXfa, + numPages: this._numPages, + annotationStorage, + filename: this._fullReader?.filename ?? null, + }, + transfers + ) .finally(() => { this.annotationStorage.resetModified(); }); diff --git a/src/shared/util.js b/src/shared/util.js index 05e7935f4..6935556d8 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -70,6 +70,7 @@ const AnnotationEditorType = { DISABLE: -1, NONE: 0, FREETEXT: 3, + STAMP: 13, INK: 15, }; diff --git a/test/driver.js b/test/driver.js index c42a8bf7b..aff134802 100644 --- a/test/driver.js +++ b/test/driver.js @@ -490,8 +490,24 @@ class Driver { }); let promise = loadingTask.promise; + if (task.annotationStorage) { + for (const annotation of Object.values(task.annotationStorage)) { + if (annotation.bitmapName) { + promise = promise.then(async doc => { + const response = await fetch( + new URL(`./images/${annotation.bitmapName}`, window.location) + ); + const blob = await response.blob(); + annotation.bitmap = await createImageBitmap(blob); + + return doc; + }); + } + } + } + if (task.save) { - promise = loadingTask.promise.then(async doc => { + promise = promise.then(async doc => { if (!task.annotationStorage) { throw new Error("Missing `annotationStorage` entry."); } diff --git a/test/images/firefox_logo.png b/test/images/firefox_logo.png new file mode 100755 index 000000000..7fea79656 Binary files /dev/null and b/test/images/firefox_logo.png differ diff --git a/test/test_manifest.json b/test/test_manifest.json index 0847c6d33..dde91f41f 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -7814,5 +7814,155 @@ "rounds": 1, "type": "eq", "annotations": true + }, + { + "id": "tracemonkey-stamp-editor-print", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "lastPage": 1, + "type": "eq", + "print": true, + "annotationStorage": { + "pdfjs_internal_editor_0": { + "annotationType": 13, + "pageIndex": 0, + "bitmapName": "firefox_logo.png", + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "rect": [37.5, 32.406246185302734, 496.5000057220459, 519.1875], + "rotation": 0 + }, + "pdfjs_internal_editor_1": { + "annotationType": 13, + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "pageIndex": 0, + "rect": [ + 458.06250572204584, + 473.04686737060547, + 571.5000057220459, + 582.7187461853027 + ], + "rotation": 0 + } + } + }, + { + "id": "tracemonkey-stamp-editor-save-print", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "lastPage": 1, + "type": "eq", + "save": true, + "print": true, + "annotationStorage": { + "pdfjs_internal_editor_0": { + "annotationType": 13, + "pageIndex": 0, + "bitmapName": "firefox_logo.png", + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "rect": [37.5, 32.406246185302734, 496.5000057220459, 519.1875], + "rotation": 0 + }, + "pdfjs_internal_editor_1": { + "annotationType": 13, + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "pageIndex": 0, + "rect": [ + 458.06250572204584, + 473.04686737060547, + 571.5000057220459, + 582.7187461853027 + ], + "rotation": 0 + } + } + }, + { + "id": "tracemonkey-stamp-editor-print-2-pages", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "lastPage": 2, + "type": "eq", + "print": true, + "annotationStorage": { + "pdfjs_internal_editor_0": { + "annotationType": 13, + "pageIndex": 0, + "bitmapName": "firefox_logo.png", + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "rect": [37.5, 32.406246185302734, 496.5000057220459, 519.1875], + "rotation": 0 + }, + "pdfjs_internal_editor_1": { + "annotationType": 13, + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "pageIndex": 0, + "rect": [ + 458.06250572204584, + 473.04686737060547, + 571.5000057220459, + 582.7187461853027 + ], + "rotation": 0 + }, + "pdfjs_internal_editor_2": { + "annotationType": 13, + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "pageIndex": 1, + "rect": [ + 458.06250572204584, + 473.04686737060547, + 571.5000057220459, + 582.7187461853027 + ], + "rotation": 0 + } + } + }, + { + "id": "tracemonkey-stamp-editor-save-print-2-pages", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "lastPage": 2, + "type": "eq", + "save": true, + "print": true, + "annotationStorage": { + "pdfjs_internal_editor_0": { + "annotationType": 13, + "pageIndex": 0, + "bitmapName": "firefox_logo.png", + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "rect": [37.5, 32.406246185302734, 496.5000057220459, 519.1875], + "rotation": 0 + }, + "pdfjs_internal_editor_1": { + "annotationType": 13, + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "pageIndex": 0, + "rect": [ + 458.06250572204584, + 473.04686737060547, + 571.5000057220459, + 582.7187461853027 + ], + "rotation": 0 + }, + "pdfjs_internal_editor_2": { + "annotationType": 13, + "bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0", + "pageIndex": 1, + "rect": [ + 458.06250572204584, + 473.04686737060547, + 571.5000057220459, + 582.7187461853027 + ], + "rotation": 0 + } + } } ] diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 8da40a71d..b9e027ad3 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -2134,6 +2134,58 @@ describe("api", function () { await loadingTask.destroy(); }); + it("write a new stamp annotation, save the pdf and check that the same image has the same ref", async function () { + if (isNodeJS) { + pending("Cannot create a bitmap from Node.js."); + } + + const TEST_IMAGES_PATH = "../images/"; + const filename = "firefox_logo.png"; + const path = new URL(TEST_IMAGES_PATH + filename, window.location).href; + + const response = await fetch(path); + const blob = await response.blob(); + const bitmap = await createImageBitmap(blob); + + let loadingTask = getDocument(buildGetDocumentParams("empty.pdf")); + let pdfDoc = await loadingTask.promise; + pdfDoc.annotationStorage.setValue("pdfjs_internal_editor_0", { + annotationType: AnnotationEditorType.STAMP, + rect: [12, 34, 56, 78], + rotation: 0, + bitmap, + bitmapId: "im1", + pageIndex: 0, + }); + pdfDoc.annotationStorage.setValue("pdfjs_internal_editor_1", { + annotationType: AnnotationEditorType.STAMP, + rect: [112, 134, 156, 178], + rotation: 0, + bitmapId: "im1", + pageIndex: 0, + }); + + const data = await pdfDoc.saveDocument(); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + const page = await pdfDoc.getPage(1); + const opList = await page.getOperatorList(); + + // The pdf contains two stamp annotations with the same image. + // The image should be stored only once in the pdf and referenced twice. + // So we can verify that the image is referenced twice in the opList. + + for (let i = 0; i < opList.fnArray.length; i++) { + if (opList.fnArray[i] === OPS.paintImageXObject) { + expect(opList.argsArray[i][0]).toEqual("img_p0_1"); + } + } + + await loadingTask.destroy(); + }); + describe("Cross-origin", function () { let loadingTask; function _checkCanLoad(expectSuccess, filename, options) {