From 4fcc2ef23f40c2077de542b896ab89058a35a911 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Thu, 6 Jul 2023 15:32:11 +0200 Subject: [PATCH] [Editor] Support svg images in the stamp annotation createImageBitmap doesn't work with svg files (see bug 1841972), so we need to workaround this in using an Image. When printing/saving we must rasterize the image, hence we get the biggest bitmap as image reference to avoid duplications or poor quality on rendering. --- src/display/annotation_storage.js | 13 ++- src/display/editor/stamp.js | 90 ++++++++++++++++-- src/display/editor/tools.js | 39 +++++++- test/images/firefox_logo.svg | 1 + test/integration-boot.js | 1 + test/integration/stamp_editor_spec.js | 131 ++++++++++++++++++++++++++ test/integration/test_utils.js | 22 +++++ 7 files changed, 284 insertions(+), 13 deletions(-) create mode 100755 test/images/firefox_logo.svg create mode 100644 test/integration/stamp_editor_spec.js diff --git a/src/display/annotation_storage.js b/src/display/annotation_storage.js index 0d1b4a7bf9fe0..05d19d8049b07 100644 --- a/src/display/annotation_storage.js +++ b/src/display/annotation_storage.js @@ -183,6 +183,7 @@ class AnnotationStorage { hash = new MurmurHash3_64(), transfers = []; const context = Object.create(null); + let hasBitmap = false; for (const [key, val] of this.#storage) { const serialized = @@ -193,12 +194,20 @@ class AnnotationStorage { map.set(key, serialized); hash.update(`${key}:${JSON.stringify(serialized)}`); + hasBitmap ||= !!serialized.bitmap; + } + } - if (serialized.bitmap) { - transfers.push(serialized.bitmap); + if (hasBitmap) { + // We must transfer the bitmap data separately, since it can be changed + // during serialization with SVG images. + for (const value of map.values()) { + if (value.bitmap) { + transfers.push(value.bitmap); } } } + return map.size > 0 ? { map, hash: hash.hexdigest(), transfers } : SerializableEmpty; diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js index 907becbe4b063..c1ef08c5e4a2d 100644 --- a/src/display/editor/stamp.js +++ b/src/display/editor/stamp.js @@ -15,6 +15,7 @@ import { AnnotationEditor } from "./editor.js"; import { AnnotationEditorType } from "../../shared/util.js"; +import { PixelsPerInch } from "../display_utils.js"; import { StampAnnotationElement } from "../annotation_layer.js"; /** @@ -35,6 +36,8 @@ class StampEditor extends AnnotationEditor { #resizeTimeoutId = null; + #isSvg = false; + static _type = "stamp"; constructor(params) { @@ -66,13 +69,22 @@ class StampEditor extends AnnotationEditor { this.remove(); return; } - ({ bitmap: this.#bitmap, id: this.#bitmapId } = data); + ({ + bitmap: this.#bitmap, + id: this.#bitmapId, + isSvg: this.#isSvg, + } = data); this.#createCanvas(); }); 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 = "image/*"; this.#bitmapPromise = new Promise(resolve => { @@ -88,9 +100,16 @@ class StampEditor extends AnnotationEditor { this.remove(); return; } - ({ bitmap: this.#bitmap, id: this.#bitmapId } = data); + ({ + bitmap: this.#bitmap, + id: this.#bitmapId, + isSvg: this.#isSvg, + } = data); this.#createCanvas(); } + if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { + input.remove(); + } resolve(); }); input.addEventListener("cancel", () => { @@ -99,7 +118,9 @@ class StampEditor extends AnnotationEditor { resolve(); }); }); - input.click(); + if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("TESTING")) { + input.click(); + } } /** @inheritdoc */ @@ -142,7 +163,11 @@ class StampEditor extends AnnotationEditor { /** @inheritdoc */ isEmpty() { - return this.#bitmapPromise === null && this.#bitmap === null; + return ( + this.#bitmapPromise === null && + this.#bitmap === null && + this.#bitmapUrl === null + ); } /** @inheritdoc */ @@ -302,7 +327,9 @@ class StampEditor extends AnnotationEditor { } canvas.width = width; canvas.height = height; - const bitmap = this.#scaleBitmap(width, height); + const bitmap = this.#isSvg + ? this.#bitmap + : this.#scaleBitmap(width, height); const ctx = canvas.getContext("2d"); ctx.filter = this._uiManager.hcmFilter; ctx.drawImage( @@ -320,6 +347,12 @@ class StampEditor extends AnnotationEditor { #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"); @@ -330,6 +363,32 @@ class StampEditor extends AnnotationEditor { 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); } @@ -352,12 +411,13 @@ class StampEditor extends AnnotationEditor { return null; } const editor = super.deserialize(data, parent, uiManager); - const { rect, bitmapUrl, bitmapId } = data; + const { rect, bitmapUrl, bitmapId, isSvg } = data; if (bitmapId && uiManager.imageManager.isValidId(bitmapId)) { editor.#bitmapId = bitmapId; } else { editor.#bitmapUrl = bitmapUrl; } + editor.#isSvg = isSvg; const [parentWidth, parentHeight] = editor.pageDimensions; editor.width = (rect[2] - rect[0]) / parentWidth; @@ -378,6 +438,7 @@ class StampEditor extends AnnotationEditor { pageIndex: this.pageIndex, rect: this.getRect(0, 0), rotation: this.rotation, + isSvg: this.#isSvg, }; if (isForCopying) { @@ -392,12 +453,25 @@ class StampEditor extends AnnotationEditor { return serialized; } - context.stamps ||= new Set(); + 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.add(this.#bitmapId); + 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; } diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index fe4a69fbbd334..988608afafc78 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -91,6 +91,7 @@ class ImageManager { bitmap: null, id: `image_${this.#baseId}_${this.#id++}`, refCounter: 0, + isSvg: false, }; let image; if (typeof rawData === "string") { @@ -102,11 +103,35 @@ class ImageManager { } image = await response.blob(); } else { - data.file = rawData; + image = data.file = rawData; + } - image = rawData; + if (image.type === "image/svg+xml") { + // Unfortunately, createImageBitmap doesn't work with SVG images. + // (see https://bugzilla.mozilla.org/1841972). + const fileReader = new FileReader(); + const dataUrlPromise = new Promise(resolve => { + fileReader.onload = () => { + data.svgUrl = fileReader.result; + resolve(); + }; + }); + fileReader.readAsDataURL(image); + const url = URL.createObjectURL(image); + image = new Image(); + const imagePromise = new Promise(resolve => { + image.onload = () => { + URL.revokeObjectURL(url); + data.bitmap = image; + data.isSvg = true; + resolve(); + }; + }); + image.src = url; + await Promise.all([imagePromise, dataUrlPromise]); + } else { + data.bitmap = await createImageBitmap(image); } - data.bitmap = await createImageBitmap(image); data.refCounter = 1; } catch (e) { console.error(e); @@ -145,6 +170,14 @@ class ImageManager { return this.getFromUrl(data.url); } + getSvgUrl(id) { + const data = this.#cache.get(id); + if (!data?.isSvg) { + return null; + } + return data.svgUrl; + } + deleteId(id) { this.#cache ||= new Map(); const data = this.#cache.get(id); diff --git a/test/images/firefox_logo.svg b/test/images/firefox_logo.svg new file mode 100755 index 0000000000000..a07635fcc8a37 --- /dev/null +++ b/test/images/firefox_logo.svg @@ -0,0 +1 @@ + diff --git a/test/integration-boot.js b/test/integration-boot.js index dfc26066b5e3d..5712814f276fe 100644 --- a/test/integration-boot.js +++ b/test/integration-boot.js @@ -33,6 +33,7 @@ async function runTests(results) { "freetext_editor_spec.js", "ink_editor_spec.js", "scripting_spec.js", + "stamp_editor_spec.js", ], }); diff --git a/test/integration/stamp_editor_spec.js b/test/integration/stamp_editor_spec.js new file mode 100644 index 0000000000000..a16c770a9ae83 --- /dev/null +++ b/test/integration/stamp_editor_spec.js @@ -0,0 +1,131 @@ +/* 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. + */ + +const { + closePages, + getEditorDimensions, + loadAndWait, + serializeBitmapDimensions, +} = require("./test_utils.js"); +const path = require("path"); + +describe("Stamp Editor", () => { + describe("Basic operations", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must load a PNG which is bigger than a page", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + if (browserName === "firefox") { + pending( + "Disabled in Firefox, because of https://bugzilla.mozilla.org/1553847." + ); + } + + await page.click("#editorStamp"); + + const rect = await page.$eval(".annotationEditorLayer", el => { + // With Chrome something is wrong when serializing a DomRect, + // hence we extract the values and just return them. + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }); + + await page.mouse.click(rect.x + 100, rect.y + 100); + const input = await page.$("#stampEditorFileInput"); + await input.uploadFile( + `${path.join(__dirname, "../images/firefox_logo.png")}` + ); + + await page.waitForTimeout(300); + + const { width, height } = await getEditorDimensions(page, 0); + + // The image is bigger than the page, so it has been scaled down to + // 75% of the page width. + expect(width).toEqual("75%"); + expect(height).toEqual("auto"); + + const [bitmap] = await serializeBitmapDimensions(page); + expect(bitmap.width).toEqual(512); + expect(bitmap.height).toEqual(543); + + await page.keyboard.down("Control"); + await page.keyboard.press("a"); + await page.keyboard.up("Control"); + await page.waitForTimeout(10); + + await page.keyboard.press("Backspace"); + }) + ); + }); + + it("must load a SVG", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + if (browserName === "firefox") { + pending( + "Disabled in Firefox, because of https://bugzilla.mozilla.org/1553847." + ); + } + + const rect = await page.$eval(".annotationEditorLayer", el => { + // With Chrome something is wrong when serializing a DomRect, + // hence we extract the values and just return them. + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }); + + await page.mouse.click(rect.x + 100, rect.y + 100); + const input = await page.$("#stampEditorFileInput"); + await input.uploadFile( + `${path.join(__dirname, "../images/firefox_logo.svg")}` + ); + + await page.waitForTimeout(300); + + const { width, height } = await getEditorDimensions(page, 1); + + expect(Math.round(parseFloat(width))).toEqual(40); + expect(height).toEqual("auto"); + + const [bitmap] = await serializeBitmapDimensions(page); + // The original size is 80x242 but to increase the resolution when it + // is rasterized we scale it up by 96 / 72 + const ratio = await page.evaluate( + () => window.pdfjsLib.PixelsPerInch.PDF_TO_CSS_UNITS + ); + expect(bitmap.width).toEqual(Math.round(242 * ratio)); + expect(bitmap.height).toEqual(Math.round(80 * ratio)); + + await page.keyboard.down("Control"); + await page.keyboard.press("a"); + await page.keyboard.up("Control"); + await page.waitForTimeout(10); + + await page.keyboard.press("Backspace"); + }) + ); + }); + }); +}); diff --git a/test/integration/test_utils.js b/test/integration/test_utils.js index 696aaf5f90ae6..454103f3f0e6f 100644 --- a/test/integration/test_utils.js +++ b/test/integration/test_utils.js @@ -154,3 +154,25 @@ function getEditors(page, kind) { }, kind); } exports.getEditors = getEditors; + +function getEditorDimensions(page, id) { + return page.evaluate(n => { + const element = document.getElementById(`pdfjs_internal_editor_${n}`); + const { style } = element; + return { width: style.width, height: style.height }; + }, id); +} +exports.getEditorDimensions = getEditorDimensions; + +function serializeBitmapDimensions(page) { + return page.evaluate(() => { + const { map } = + window.PDFViewerApplication.pdfDocument.annotationStorage.serializable; + return map + ? Array.from(map.values(), x => { + return { width: x.bitmap.width, height: x.bitmap.height }; + }) + : []; + }); +} +exports.serializeBitmapDimensions = serializeBitmapDimensions;