[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.
This commit is contained in:
parent
eb2527e9d7
commit
4fcc2ef23f
@ -183,6 +183,7 @@ class AnnotationStorage {
|
|||||||
hash = new MurmurHash3_64(),
|
hash = new MurmurHash3_64(),
|
||||||
transfers = [];
|
transfers = [];
|
||||||
const context = Object.create(null);
|
const context = Object.create(null);
|
||||||
|
let hasBitmap = false;
|
||||||
|
|
||||||
for (const [key, val] of this.#storage) {
|
for (const [key, val] of this.#storage) {
|
||||||
const serialized =
|
const serialized =
|
||||||
@ -193,12 +194,20 @@ class AnnotationStorage {
|
|||||||
map.set(key, serialized);
|
map.set(key, serialized);
|
||||||
|
|
||||||
hash.update(`${key}:${JSON.stringify(serialized)}`);
|
hash.update(`${key}:${JSON.stringify(serialized)}`);
|
||||||
|
hasBitmap ||= !!serialized.bitmap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (serialized.bitmap) {
|
if (hasBitmap) {
|
||||||
transfers.push(serialized.bitmap);
|
// 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
|
return map.size > 0
|
||||||
? { map, hash: hash.hexdigest(), transfers }
|
? { map, hash: hash.hexdigest(), transfers }
|
||||||
: SerializableEmpty;
|
: SerializableEmpty;
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import { AnnotationEditor } from "./editor.js";
|
import { AnnotationEditor } from "./editor.js";
|
||||||
import { AnnotationEditorType } from "../../shared/util.js";
|
import { AnnotationEditorType } from "../../shared/util.js";
|
||||||
|
import { PixelsPerInch } from "../display_utils.js";
|
||||||
import { StampAnnotationElement } from "../annotation_layer.js";
|
import { StampAnnotationElement } from "../annotation_layer.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -35,6 +36,8 @@ class StampEditor extends AnnotationEditor {
|
|||||||
|
|
||||||
#resizeTimeoutId = null;
|
#resizeTimeoutId = null;
|
||||||
|
|
||||||
|
#isSvg = false;
|
||||||
|
|
||||||
static _type = "stamp";
|
static _type = "stamp";
|
||||||
|
|
||||||
constructor(params) {
|
constructor(params) {
|
||||||
@ -66,13 +69,22 @@ class StampEditor extends AnnotationEditor {
|
|||||||
this.remove();
|
this.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
({ bitmap: this.#bitmap, id: this.#bitmapId } = data);
|
({
|
||||||
|
bitmap: this.#bitmap,
|
||||||
|
id: this.#bitmapId,
|
||||||
|
isSvg: this.#isSvg,
|
||||||
|
} = data);
|
||||||
this.#createCanvas();
|
this.#createCanvas();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = document.createElement("input");
|
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.type = "file";
|
||||||
input.accept = "image/*";
|
input.accept = "image/*";
|
||||||
this.#bitmapPromise = new Promise(resolve => {
|
this.#bitmapPromise = new Promise(resolve => {
|
||||||
@ -88,9 +100,16 @@ class StampEditor extends AnnotationEditor {
|
|||||||
this.remove();
|
this.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
({ bitmap: this.#bitmap, id: this.#bitmapId } = data);
|
({
|
||||||
|
bitmap: this.#bitmap,
|
||||||
|
id: this.#bitmapId,
|
||||||
|
isSvg: this.#isSvg,
|
||||||
|
} = data);
|
||||||
this.#createCanvas();
|
this.#createCanvas();
|
||||||
}
|
}
|
||||||
|
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
|
||||||
|
input.remove();
|
||||||
|
}
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
input.addEventListener("cancel", () => {
|
input.addEventListener("cancel", () => {
|
||||||
@ -99,7 +118,9 @@ class StampEditor extends AnnotationEditor {
|
|||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
input.click();
|
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("TESTING")) {
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
@ -142,7 +163,11 @@ class StampEditor extends AnnotationEditor {
|
|||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
isEmpty() {
|
isEmpty() {
|
||||||
return this.#bitmapPromise === null && this.#bitmap === null;
|
return (
|
||||||
|
this.#bitmapPromise === null &&
|
||||||
|
this.#bitmap === null &&
|
||||||
|
this.#bitmapUrl === null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
@ -302,7 +327,9 @@ class StampEditor extends AnnotationEditor {
|
|||||||
}
|
}
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const bitmap = this.#scaleBitmap(width, height);
|
const bitmap = this.#isSvg
|
||||||
|
? this.#bitmap
|
||||||
|
: this.#scaleBitmap(width, height);
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
ctx.filter = this._uiManager.hcmFilter;
|
ctx.filter = this._uiManager.hcmFilter;
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
@ -320,6 +347,12 @@ class StampEditor extends AnnotationEditor {
|
|||||||
|
|
||||||
#serializeBitmap(toUrl) {
|
#serializeBitmap(toUrl) {
|
||||||
if (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
|
// We convert to a data url because it's sync and the url can live in the
|
||||||
// clipboard.
|
// clipboard.
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
@ -330,6 +363,32 @@ class StampEditor extends AnnotationEditor {
|
|||||||
return canvas.toDataURL();
|
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);
|
return structuredClone(this.#bitmap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,12 +411,13 @@ class StampEditor extends AnnotationEditor {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const editor = super.deserialize(data, parent, uiManager);
|
const editor = super.deserialize(data, parent, uiManager);
|
||||||
const { rect, bitmapUrl, bitmapId } = data;
|
const { rect, bitmapUrl, bitmapId, isSvg } = data;
|
||||||
if (bitmapId && uiManager.imageManager.isValidId(bitmapId)) {
|
if (bitmapId && uiManager.imageManager.isValidId(bitmapId)) {
|
||||||
editor.#bitmapId = bitmapId;
|
editor.#bitmapId = bitmapId;
|
||||||
} else {
|
} else {
|
||||||
editor.#bitmapUrl = bitmapUrl;
|
editor.#bitmapUrl = bitmapUrl;
|
||||||
}
|
}
|
||||||
|
editor.#isSvg = isSvg;
|
||||||
|
|
||||||
const [parentWidth, parentHeight] = editor.pageDimensions;
|
const [parentWidth, parentHeight] = editor.pageDimensions;
|
||||||
editor.width = (rect[2] - rect[0]) / parentWidth;
|
editor.width = (rect[2] - rect[0]) / parentWidth;
|
||||||
@ -378,6 +438,7 @@ class StampEditor extends AnnotationEditor {
|
|||||||
pageIndex: this.pageIndex,
|
pageIndex: this.pageIndex,
|
||||||
rect: this.getRect(0, 0),
|
rect: this.getRect(0, 0),
|
||||||
rotation: this.rotation,
|
rotation: this.rotation,
|
||||||
|
isSvg: this.#isSvg,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isForCopying) {
|
if (isForCopying) {
|
||||||
@ -392,12 +453,25 @@ class StampEditor extends AnnotationEditor {
|
|||||||
return serialized;
|
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)) {
|
if (!context.stamps.has(this.#bitmapId)) {
|
||||||
// We don't want to have multiple copies of the same bitmap in the
|
// 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.
|
// 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);
|
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;
|
return serialized;
|
||||||
}
|
}
|
||||||
|
@ -91,6 +91,7 @@ class ImageManager {
|
|||||||
bitmap: null,
|
bitmap: null,
|
||||||
id: `image_${this.#baseId}_${this.#id++}`,
|
id: `image_${this.#baseId}_${this.#id++}`,
|
||||||
refCounter: 0,
|
refCounter: 0,
|
||||||
|
isSvg: false,
|
||||||
};
|
};
|
||||||
let image;
|
let image;
|
||||||
if (typeof rawData === "string") {
|
if (typeof rawData === "string") {
|
||||||
@ -102,11 +103,35 @@ class ImageManager {
|
|||||||
}
|
}
|
||||||
image = await response.blob();
|
image = await response.blob();
|
||||||
} else {
|
} 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;
|
data.refCounter = 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@ -145,6 +170,14 @@ class ImageManager {
|
|||||||
return this.getFromUrl(data.url);
|
return this.getFromUrl(data.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSvgUrl(id) {
|
||||||
|
const data = this.#cache.get(id);
|
||||||
|
if (!data?.isSvg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.svgUrl;
|
||||||
|
}
|
||||||
|
|
||||||
deleteId(id) {
|
deleteId(id) {
|
||||||
this.#cache ||= new Map();
|
this.#cache ||= new Map();
|
||||||
const data = this.#cache.get(id);
|
const data = this.#cache.get(id);
|
||||||
|
1
test/images/firefox_logo.svg
Executable file
1
test/images/firefox_logo.svg
Executable file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 10 KiB |
@ -33,6 +33,7 @@ async function runTests(results) {
|
|||||||
"freetext_editor_spec.js",
|
"freetext_editor_spec.js",
|
||||||
"ink_editor_spec.js",
|
"ink_editor_spec.js",
|
||||||
"scripting_spec.js",
|
"scripting_spec.js",
|
||||||
|
"stamp_editor_spec.js",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
131
test/integration/stamp_editor_spec.js
Normal file
131
test/integration/stamp_editor_spec.js
Normal file
@ -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");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -154,3 +154,25 @@ function getEditors(page, kind) {
|
|||||||
}, kind);
|
}, kind);
|
||||||
}
|
}
|
||||||
exports.getEditors = getEditors;
|
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;
|
||||||
|
Loading…
Reference in New Issue
Block a user