475 lines
13 KiB
JavaScript
475 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;
|
|
this.width = width / parentWidth;
|
|
this.height = height / parentHeight;
|
|
this.setDims(width, height);
|
|
this.fixAndSetPosition();
|
|
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 };
|