diff --git a/src/display/annotation_storage.js b/src/display/annotation_storage.js index 0d1b4a7bf9fe09..05d19d8049b072 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 907becbe4b063a..69a2d48a06508e 100644 --- a/src/display/editor/stamp.js +++ b/src/display/editor/stamp.js @@ -35,6 +35,8 @@ class StampEditor extends AnnotationEditor { #resizeTimeoutId = null; + #isSvg = false; + static _type = "stamp"; constructor(params) { @@ -66,7 +68,11 @@ 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; @@ -88,7 +94,11 @@ 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(); } resolve(); @@ -142,7 +152,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 +316,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 +336,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 +352,26 @@ class StampEditor extends AnnotationEditor { return canvas.toDataURL(); } + if (this.#isSvg) { + const [pageWidth, pageHeight] = this.pageDimensions; + const width = Math.round(this.width * pageWidth); + const height = Math.round(this.height * pageHeight); + 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 +394,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 +421,7 @@ class StampEditor extends AnnotationEditor { pageIndex: this.pageIndex, rect: this.getRect(0, 0), rotation: this.rotation, + isSvg: this.#isSvg, }; if (isForCopying) { @@ -392,12 +436,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 fe4a69fbbd3344..988608afafc78c 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);