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.
481 lines
13 KiB
JavaScript
481 lines
13 KiB
JavaScript
/* 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.
|
|
*/
|
|
|
|
import { AnnotationEditor } from "./editor.js";
|
|
import { AnnotationEditorType } from "../../shared/util.js";
|
|
import { PixelsPerInch } from "../display_utils.js";
|
|
import { StampAnnotationElement } from "../annotation_layer.js";
|
|
|
|
/**
|
|
* Basic text editor in order to create a FreeTex annotation.
|
|
*/
|
|
class StampEditor extends AnnotationEditor {
|
|
#bitmap = null;
|
|
|
|
#bitmapId = null;
|
|
|
|
#bitmapPromise = null;
|
|
|
|
#bitmapUrl = null;
|
|
|
|
#canvas = null;
|
|
|
|
#observer = null;
|
|
|
|
#resizeTimeoutId = null;
|
|
|
|
#isSvg = false;
|
|
|
|
static _type = "stamp";
|
|
|
|
constructor(params) {
|
|
super({ ...params, name: "stampEditor" });
|
|
this.#bitmapUrl = params.bitmapUrl;
|
|
}
|
|
|
|
#getBitmap() {
|
|
if (this.#bitmapId) {
|
|
this._uiManager.imageManager.getFromId(this.#bitmapId).then(data => {
|
|
if (!data) {
|
|
this.remove();
|
|
return;
|
|
}
|
|
this.#bitmap = data.bitmap;
|
|
this.#createCanvas();
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (this.#bitmapUrl) {
|
|
const url = this.#bitmapUrl;
|
|
this.#bitmapUrl = null;
|
|
this.#bitmapPromise = this._uiManager.imageManager
|
|
.getFromUrl(url)
|
|
.then(data => {
|
|
this.#bitmapPromise = null;
|
|
if (!data) {
|
|
this.remove();
|
|
return;
|
|
}
|
|
({
|
|
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 => {
|
|
input.addEventListener("change", async () => {
|
|
this.#bitmapPromise = null;
|
|
if (!input.files || input.files.length === 0) {
|
|
this.remove();
|
|
} else {
|
|
const data = await this._uiManager.imageManager.getFromFile(
|
|
input.files[0]
|
|
);
|
|
if (!data) {
|
|
this.remove();
|
|
return;
|
|
}
|
|
({
|
|
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", () => {
|
|
this.#bitmapPromise = null;
|
|
this.remove();
|
|
resolve();
|
|
});
|
|
});
|
|
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("TESTING")) {
|
|
input.click();
|
|
}
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
remove() {
|
|
if (this.#bitmapId) {
|
|
this.#bitmap = null;
|
|
this._uiManager.imageManager.deleteId(this.#bitmapId);
|
|
this.#canvas?.remove();
|
|
this.#canvas = null;
|
|
this.#observer?.disconnect();
|
|
this.#observer = null;
|
|
}
|
|
super.remove();
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
rebuild() {
|
|
super.rebuild();
|
|
if (this.div === null) {
|
|
return;
|
|
}
|
|
|
|
if (this.#bitmapId) {
|
|
this.#getBitmap();
|
|
}
|
|
|
|
if (!this.isAttachedToDOM) {
|
|
// At some point this editor was removed and we're rebuilting it,
|
|
// hence we must add it to its parent.
|
|
this.parent.add(this);
|
|
}
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
onceAdded() {
|
|
this.div.draggable = true;
|
|
this.parent.addUndoableEditor(this);
|
|
this.div.focus();
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
isEmpty() {
|
|
return (
|
|
this.#bitmapPromise === null &&
|
|
this.#bitmap === null &&
|
|
this.#bitmapUrl === null
|
|
);
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
render() {
|
|
if (this.div) {
|
|
return this.div;
|
|
}
|
|
|
|
let baseX, baseY;
|
|
if (this.width) {
|
|
baseX = this.x;
|
|
baseY = this.y;
|
|
}
|
|
|
|
super.render();
|
|
|
|
if (this.#bitmap) {
|
|
this.#createCanvas();
|
|
} else {
|
|
this.div.classList.add("loading");
|
|
this.#getBitmap();
|
|
}
|
|
|
|
if (this.width) {
|
|
// This editor was created in using copy (ctrl+c).
|
|
const [parentWidth, parentHeight] = this.parentDimensions;
|
|
this.setAspectRatio(this.width * parentWidth, this.height * parentHeight);
|
|
this.setAt(
|
|
baseX * parentWidth,
|
|
baseY * parentHeight,
|
|
this.width * parentWidth,
|
|
this.height * parentHeight
|
|
);
|
|
}
|
|
|
|
return this.div;
|
|
}
|
|
|
|
#createCanvas() {
|
|
const { div } = this;
|
|
let { width, height } = this.#bitmap;
|
|
const [pageWidth, pageHeight] = this.pageDimensions;
|
|
const MAX_RATIO = 0.75;
|
|
if (this.width) {
|
|
width = this.width * pageWidth;
|
|
height = this.height * pageHeight;
|
|
} else if (
|
|
width > MAX_RATIO * pageWidth ||
|
|
height > MAX_RATIO * pageHeight
|
|
) {
|
|
// If the the image is too big compared to the page dimensions
|
|
// (more than MAX_RATIO) then we scale it down.
|
|
const factor = Math.min(
|
|
(MAX_RATIO * pageWidth) / width,
|
|
(MAX_RATIO * pageHeight) / height
|
|
);
|
|
width *= factor;
|
|
height *= factor;
|
|
}
|
|
const [parentWidth, parentHeight] = this.parentDimensions;
|
|
this.setDims(
|
|
(width * parentWidth) / pageWidth,
|
|
(height * parentHeight) / pageHeight
|
|
);
|
|
|
|
this.setAspectRatio(width, height);
|
|
|
|
const canvas = (this.#canvas = document.createElement("canvas"));
|
|
div.append(canvas);
|
|
this.#drawBitmap(width, height);
|
|
this.#createObserver();
|
|
div.classList.remove("loading");
|
|
}
|
|
|
|
/**
|
|
* When the dimensions of the div change the inner canvas must
|
|
* renew its dimensions, hence it must redraw its own contents.
|
|
* @param {number} width - the new width of the div
|
|
* @param {number} height - the new height of the div
|
|
* @returns
|
|
*/
|
|
#setDimensions(width, height) {
|
|
const [parentWidth, parentHeight] = this.parentDimensions;
|
|
if (
|
|
Math.abs(width - this.width * parentWidth) < 1 &&
|
|
Math.abs(height - this.height * parentHeight) < 1
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.width = width / parentWidth;
|
|
this.height = height / parentHeight;
|
|
this.setDims(width, height);
|
|
if (this.#resizeTimeoutId !== null) {
|
|
clearTimeout(this.#resizeTimeoutId);
|
|
}
|
|
// When the user is resizing the editor we just use CSS to scale the image
|
|
// to avoid redrawing it too often.
|
|
// And once the user stops resizing the editor we redraw the image in
|
|
// rescaling it correctly (see this.#scaleBitmap).
|
|
const TIME_TO_WAIT = 200;
|
|
this.#resizeTimeoutId = setTimeout(() => {
|
|
this.#resizeTimeoutId = null;
|
|
this.#drawBitmap(width, height);
|
|
}, TIME_TO_WAIT);
|
|
}
|
|
|
|
#scaleBitmap(width, height) {
|
|
const { width: bitmapWidth, height: bitmapHeight } = this.#bitmap;
|
|
|
|
let newWidth = bitmapWidth;
|
|
let newHeight = bitmapHeight;
|
|
let bitmap = this.#bitmap;
|
|
while (newWidth > 2 * width || newHeight > 2 * height) {
|
|
const prevWidth = newWidth;
|
|
const prevHeight = newHeight;
|
|
|
|
if (newWidth > 2 * width) {
|
|
// See bug 1820511 (Windows specific bug).
|
|
// TODO: once the above bug is fixed we could revert to:
|
|
// newWidth = Math.ceil(newWidth / 2);
|
|
newWidth =
|
|
newWidth >= 16384
|
|
? Math.floor(newWidth / 2) - 1
|
|
: Math.ceil(newWidth / 2);
|
|
}
|
|
if (newHeight > 2 * height) {
|
|
newHeight =
|
|
newHeight >= 16384
|
|
? Math.floor(newHeight / 2) - 1
|
|
: Math.ceil(newHeight / 2);
|
|
}
|
|
|
|
const offscreen = new OffscreenCanvas(newWidth, newHeight);
|
|
const ctx = offscreen.getContext("2d");
|
|
ctx.drawImage(
|
|
bitmap,
|
|
0,
|
|
0,
|
|
prevWidth,
|
|
prevHeight,
|
|
0,
|
|
0,
|
|
newWidth,
|
|
newHeight
|
|
);
|
|
bitmap = offscreen.transferToImageBitmap();
|
|
}
|
|
|
|
return bitmap;
|
|
}
|
|
|
|
#drawBitmap(width, height) {
|
|
const canvas = this.#canvas;
|
|
if (!canvas || (canvas.width === width && canvas.height === height)) {
|
|
return;
|
|
}
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const bitmap = this.#isSvg
|
|
? this.#bitmap
|
|
: this.#scaleBitmap(width, height);
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.filter = this._uiManager.hcmFilter;
|
|
ctx.drawImage(
|
|
bitmap,
|
|
0,
|
|
0,
|
|
bitmap.width,
|
|
bitmap.height,
|
|
0,
|
|
0,
|
|
width,
|
|
height
|
|
);
|
|
}
|
|
|
|
#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");
|
|
({ width: canvas.width, height: canvas.height } = this.#bitmap);
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.drawImage(this.#bitmap, 0, 0);
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Create the resize observer.
|
|
*/
|
|
#createObserver() {
|
|
this.#observer = new ResizeObserver(entries => {
|
|
const rect = entries[0].contentRect;
|
|
if (rect.width && rect.height) {
|
|
this.#setDimensions(rect.width, rect.height);
|
|
}
|
|
});
|
|
this.#observer.observe(this.div);
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
static deserialize(data, parent, uiManager) {
|
|
if (data instanceof StampAnnotationElement) {
|
|
return null;
|
|
}
|
|
const editor = super.deserialize(data, parent, uiManager);
|
|
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;
|
|
editor.height = (rect[3] - rect[1]) / parentHeight;
|
|
|
|
return editor;
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
serialize(isForCopying = false, context = null) {
|
|
if (this.isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
const serialized = {
|
|
annotationType: AnnotationEditorType.STAMP,
|
|
bitmapId: this.#bitmapId,
|
|
pageIndex: this.pageIndex,
|
|
rect: this.getRect(0, 0),
|
|
rotation: this.rotation,
|
|
isSvg: this.#isSvg,
|
|
};
|
|
|
|
if (isForCopying) {
|
|
// We don't know what's the final destination (this pdf or another one)
|
|
// of this annotation and the clipboard doesn't support ImageBitmaps,
|
|
// hence we serialize the bitmap to a data url.
|
|
serialized.bitmapUrl = this.#serializeBitmap(/* toUrl = */ true);
|
|
return serialized;
|
|
}
|
|
|
|
if (context === null) {
|
|
return serialized;
|
|
}
|
|
|
|
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.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;
|
|
}
|
|
}
|
|
|
|
export { StampEditor };
|