Skip to content

Commit

Permalink
[Editor] Support svg images in the stamp annotation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
calixteman committed Jul 7, 2023
1 parent eb2527e commit be49090
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 13 deletions.
13 changes: 11 additions & 2 deletions src/display/annotation_storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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;
Expand Down
90 changes: 82 additions & 8 deletions src/display/editor/stamp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -35,6 +36,8 @@ class StampEditor extends AnnotationEditor {

#resizeTimeoutId = null;

#isSvg = false;

static _type = "stamp";

constructor(params) {
Expand Down Expand Up @@ -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 => {
Expand All @@ -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", () => {
Expand All @@ -99,7 +118,9 @@ class StampEditor extends AnnotationEditor {
resolve();
});
});
input.click();
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("TESTING")) {
input.click();
}
}

/** @inheritdoc */
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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(
Expand All @@ -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");
Expand All @@ -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);
}

Expand All @@ -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;
Expand All @@ -378,6 +438,7 @@ class StampEditor extends AnnotationEditor {
pageIndex: this.pageIndex,
rect: this.getRect(0, 0),
rotation: this.rotation,
isSvg: this.#isSvg,
};

if (isForCopying) {
Expand All @@ -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;
}
Expand Down
39 changes: 36 additions & 3 deletions src/display/editor/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class ImageManager {
bitmap: null,
id: `image_${this.#baseId}_${this.#id++}`,
refCounter: 0,
isSvg: false,
};
let image;
if (typeof rawData === "string") {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions test/images/firefox_logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions test/integration-boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ async function runTests(results) {
"freetext_editor_spec.js",
"ink_editor_spec.js",
"scripting_spec.js",
"stamp_editor_spec.js",
],
});

Expand Down
Loading

0 comments on commit be49090

Please sign in to comment.