[Editor] Add support for printing/saving newly added Stamp annotations

In order to minimize the size the of a saved pdf, we generate only one
image and use a reference in each annotation using it.
When printing, it's slightly different since we have to render each page
independantly but we use the same image within a page.
This commit is contained in:
Calixte Denizet 2023-06-22 19:48:40 +02:00
parent ccb72073b0
commit 599b9498f2
9 changed files with 519 additions and 19 deletions

View File

@ -23,6 +23,7 @@ import {
AnnotationType,
assert,
BASELINE_FACTOR,
FeatureTest,
getModificationDate,
IDENTITY_MATRIX,
LINE_DESCENT_FACTOR,
@ -52,15 +53,16 @@ import {
parseDefaultAppearance,
} from "./default_appearance.js";
import { Dict, isName, Name, Ref, RefSet } from "./primitives.js";
import { Stream, StringStream } from "./stream.js";
import { writeDict, writeObject } from "./writer.js";
import { BaseStream } from "./base_stream.js";
import { bidi } from "./bidi.js";
import { Catalog } from "./catalog.js";
import { ColorSpace } from "./colorspace.js";
import { FileSpec } from "./file_spec.js";
import { JpegStream } from "./jpeg_stream.js";
import { ObjectLoader } from "./object_loader.js";
import { OperatorList } from "./operator_list.js";
import { StringStream } from "./stream.js";
import { XFAFactory } from "./xfa/factory.js";
class AnnotationFactory {
@ -257,11 +259,31 @@ class AnnotationFactory {
}
}
static async saveNewAnnotations(evaluator, task, annotations) {
static generateImages(annotations, xref, isOffscreenCanvasSupported) {
if (!isOffscreenCanvasSupported) {
warn(
"generateImages: OffscreenCanvas is not supported, cannot save or print some annotations with images."
);
return null;
}
let imagePromises;
for (const { bitmapId, bitmap } of annotations) {
if (!bitmap) {
continue;
}
imagePromises ||= new Map();
imagePromises.set(bitmapId, StampAnnotation.createImage(bitmap, xref));
}
return imagePromises;
}
static async saveNewAnnotations(evaluator, task, annotations, imagePromises) {
const xref = evaluator.xref;
let baseFontRef;
const dependencies = [];
const promises = [];
const { isOffscreenCanvasSupported } = evaluator.options;
for (const annotation of annotations) {
if (annotation.deleted) {
@ -293,6 +315,36 @@ class AnnotationFactory {
promises.push(
InkAnnotation.createNewAnnotation(xref, annotation, dependencies)
);
break;
case AnnotationEditorType.STAMP:
if (!isOffscreenCanvasSupported) {
break;
}
const image = await imagePromises.get(annotation.bitmapId);
if (image.imageStream) {
const { imageStream, smaskStream } = image;
const buffer = [];
if (smaskStream) {
const smaskRef = xref.getNewTemporaryRef();
await writeObject(smaskRef, smaskStream, buffer, null);
dependencies.push({ ref: smaskRef, data: buffer.join("") });
imageStream.dict.set("SMask", smaskRef);
buffer.length = 0;
}
const imageRef = (image.imageRef = xref.getNewTemporaryRef());
await writeObject(imageRef, imageStream, buffer, null);
dependencies.push({ ref: imageRef, data: buffer.join("") });
image.imageStream = image.smaskStream = null;
}
promises.push(
StampAnnotation.createNewAnnotation(
xref,
annotation,
dependencies,
image
)
);
break;
}
}
@ -302,7 +354,12 @@ class AnnotationFactory {
};
}
static async printNewAnnotations(evaluator, task, annotations) {
static async printNewAnnotations(
evaluator,
task,
annotations,
imagePromises
) {
if (!annotations) {
return null;
}
@ -331,6 +388,23 @@ class AnnotationFactory {
})
);
break;
case AnnotationEditorType.STAMP:
if (!isOffscreenCanvasSupported) {
break;
}
const image = await imagePromises.get(annotation.bitmapId);
if (image.imageStream) {
const { imageStream, smaskStream } = image;
if (smaskStream) {
imageStream.dict.set("SMask", smaskStream);
}
image.imageRef = new JpegStream(imageStream, imageStream.length);
image.imageStream = image.smaskStream = null;
}
promises.push(
StampAnnotation.createNewPrintAnnotation(xref, annotation, image)
);
break;
}
}
@ -4361,6 +4435,143 @@ class StampAnnotation extends MarkupAnnotation {
this.data.annotationType = AnnotationType.STAMP;
this.data.hasOwnCanvas = this.data.noRotate;
}
static async createImage(bitmap, xref) {
// TODO: when printing, we could have a specific internal colorspace
// (e.g. something like DeviceRGBA) in order avoid any conversion (i.e. no
// jpeg, no rgba to rgb conversion, etc...)
const { width, height } = bitmap;
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d", { alpha: true });
// Draw the image and get the data in order to extract the transparency.
ctx.drawImage(bitmap, 0, 0);
const data = ctx.getImageData(0, 0, width, height).data;
const buf32 = new Uint32Array(data.buffer);
const hasAlpha = buf32.some(
FeatureTest.isLittleEndian
? x => x >>> 24 !== 0xff
: x => (x & 0xff) !== 0xff
);
if (hasAlpha) {
// Redraw the image on a white background in order to remove the thin gray
// line which can appear when exporting to jpeg.
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(bitmap, 0, 0);
}
const jpegBufferPromise = canvas
.convertToBlob({ type: "image/jpeg", quality: 1 })
.then(blob => {
return blob.arrayBuffer();
});
const xobjectName = Name.get("XObject");
const imageName = Name.get("Image");
const image = new Dict(xref);
image.set("Type", xobjectName);
image.set("Subtype", imageName);
image.set("BitsPerComponent", 8);
image.set("ColorSpace", Name.get("DeviceRGB"));
image.set("Filter", Name.get("DCTDecode"));
image.set("BBox", [0, 0, width, height]);
image.set("Width", width);
image.set("Height", height);
let smaskStream = null;
if (hasAlpha) {
const alphaBuffer = new Uint8Array(buf32.length);
if (FeatureTest.isLittleEndian) {
for (let i = 0, ii = buf32.length; i < ii; i++) {
alphaBuffer[i] = buf32[i] >>> 24;
}
} else {
for (let i = 0, ii = buf32.length; i < ii; i++) {
alphaBuffer[i] = buf32[i] & 0xff;
}
}
const smask = new Dict(xref);
smask.set("Type", xobjectName);
smask.set("Subtype", imageName);
smask.set("BitsPerComponent", 8);
smask.set("ColorSpace", Name.get("DeviceGray"));
smask.set("Width", width);
smask.set("Height", height);
smaskStream = new Stream(alphaBuffer, 0, 0, smask);
}
const imageStream = new Stream(await jpegBufferPromise, 0, 0, image);
return {
imageStream,
smaskStream,
width,
height,
};
}
static createNewDict(annotation, xref, { apRef, ap }) {
const { rect, rotation, user } = annotation;
const stamp = new Dict(xref);
stamp.set("Type", Name.get("Annot"));
stamp.set("Subtype", Name.get("Stamp"));
stamp.set("CreationDate", `D:${getModificationDate()}`);
stamp.set("Rect", rect);
stamp.set("F", 4);
stamp.set("Border", [0, 0, 0]);
stamp.set("Rotate", rotation);
if (user) {
stamp.set(
"T",
isAscii(user) ? user : stringToUTF16String(user, /* bigEndian = */ true)
);
}
if (apRef || ap) {
const n = new Dict(xref);
stamp.set("AP", n);
if (apRef) {
n.set("N", apRef);
} else {
n.set("N", ap);
}
}
return stamp;
}
static async createNewAppearanceStream(annotation, xref, params) {
const { rotation } = annotation;
const { imageRef, width, height } = params;
const resources = new Dict(xref);
const xobject = new Dict(xref);
resources.set("XObject", xobject);
xobject.set("Im0", imageRef);
const appearance = `q ${width} 0 0 ${height} 0 0 cm /Im0 Do Q`;
const appearanceStreamDict = new Dict(xref);
appearanceStreamDict.set("FormType", 1);
appearanceStreamDict.set("Subtype", Name.get("Form"));
appearanceStreamDict.set("Type", Name.get("XObject"));
appearanceStreamDict.set("BBox", [0, 0, width, height]);
appearanceStreamDict.set("Resources", resources);
if (rotation) {
const matrix = getRotationMatrix(rotation, width, height);
appearanceStreamDict.set("Matrix", matrix);
}
const ap = new StringStream(appearance);
ap.dict = appearanceStreamDict;
return ap;
}
}
class FileAttachmentAnnotation extends MarkupAnnotation {

View File

@ -13,8 +13,8 @@
* limitations under the License.
*/
import { AnnotationFactory, PopupAnnotation } from "./annotation.js";
import {
AnnotationEditorPrefix,
assert,
FormatError,
info,
@ -30,6 +30,7 @@ import {
Util,
warn,
} from "../shared/util.js";
import { AnnotationFactory, PopupAnnotation } from "./annotation.js";
import {
collectActions,
getInheritableProperty,
@ -277,7 +278,7 @@ class Page {
}
}
async saveNewAnnotations(handler, task, annotations) {
async saveNewAnnotations(handler, task, annotations, imagePromises) {
if (this.xfaFactory) {
throw new Error("XFA: Cannot save new annotations.");
}
@ -306,7 +307,8 @@ class Page {
const newData = await AnnotationFactory.saveNewAnnotations(
partialEvaluator,
task,
annotations
annotations,
imagePromises
);
for (const { ref } of newData.annotations) {
@ -433,14 +435,52 @@ class Page {
let newAnnotationsPromise = Promise.resolve(null);
if (newAnnotationsByPage) {
let imagePromises;
const newAnnotations = newAnnotationsByPage.get(this.pageIndex);
if (newAnnotations) {
// An annotation can contain a reference to a bitmap, but this bitmap
// is defined in another annotation. So we need to find this annotation
// and generate the bitmap.
const missingBitmaps = new Set();
for (const { bitmapId, bitmap } of newAnnotations) {
if (bitmapId && !bitmap && !missingBitmaps.has(bitmapId)) {
missingBitmaps.add(bitmapId);
}
}
const { isOffscreenCanvasSupported } = this.evaluatorOptions;
if (missingBitmaps.size > 0) {
const annotationWithBitmaps = [];
for (const [key, annotation] of annotationStorage) {
if (!key.startsWith(AnnotationEditorPrefix)) {
continue;
}
if (annotation.bitmap && missingBitmaps.has(annotation.bitmapId)) {
annotationWithBitmaps.push(annotation);
}
}
// The array annotationWithBitmaps cannot be empty: the check above
// makes sure to have at least one annotation containing the bitmap.
imagePromises = AnnotationFactory.generateImages(
annotationWithBitmaps,
this.xref,
isOffscreenCanvasSupported
);
} else {
imagePromises = AnnotationFactory.generateImages(
newAnnotations,
this.xref,
isOffscreenCanvasSupported
);
}
deletedAnnotations = new RefSet();
this.#replaceIdByRef(newAnnotations, deletedAnnotations, null);
newAnnotationsPromise = AnnotationFactory.printNewAnnotations(
partialEvaluator,
task,
newAnnotations
newAnnotations,
imagePromises
);
}
}

View File

@ -36,6 +36,7 @@ import {
} from "./core_utils.js";
import { Dict, Ref } from "./primitives.js";
import { LocalPdfManager, NetworkPdfManager } from "./pdf_manager.js";
import { AnnotationFactory } from "./annotation.js";
import { clearGlobalCaches } from "./cleanup_helper.js";
import { incrementalUpdate } from "./writer.js";
import { isNodeJS } from "../shared/is_node.js";
@ -537,12 +538,11 @@ class WorkerMessageHandler {
handler.on(
"SaveDocument",
function ({ isPureXfa, numPages, annotationStorage, filename }) {
async function ({ isPureXfa, numPages, annotationStorage, filename }) {
const promises = [
pdfManager.requestLoadedStream(),
pdfManager.ensureCatalog("acroForm"),
pdfManager.ensureCatalog("acroFormRef"),
pdfManager.ensureDoc("xref"),
pdfManager.ensureDoc("startXRef"),
];
@ -550,13 +550,21 @@ class WorkerMessageHandler {
? getNewAnnotationsMap(annotationStorage)
: null;
const xref = await pdfManager.ensureDoc("xref");
if (newAnnotationsByPage) {
const imagePromises = AnnotationFactory.generateImages(
annotationStorage.values(),
xref,
pdfManager.evaluatorOptions.isOffscreenCanvasSupported
);
for (const [pageIndex, annotations] of newAnnotationsByPage) {
promises.push(
pdfManager.getPage(pageIndex).then(page => {
const task = new WorkerTask(`Save (editor): page ${pageIndex}`);
return page
.saveNewAnnotations(handler, task, annotations)
.saveNewAnnotations(handler, task, annotations, imagePromises)
.finally(function () {
finishWorkerTask(task);
});
@ -586,7 +594,6 @@ class WorkerMessageHandler {
stream,
acroForm,
acroFormRef,
xref,
startXRef,
...refs
]) {

View File

@ -1812,6 +1812,15 @@ class PDFPageProxy {
);
}
const transfers = [];
if (annotationStorageMap) {
for (const annotation of annotationStorageMap.values()) {
if (annotation.bitmap) {
transfers.push(annotation.bitmap);
}
}
}
const readableStream = this._transport.messageHandler.sendWithStream(
"GetOperatorList",
{
@ -1819,7 +1828,8 @@ class PDFPageProxy {
intent: renderingIntent,
cacheKey,
annotationStorage: annotationStorageMap,
}
},
transfers
);
const reader = readableStream.getReader();
@ -2898,13 +2908,26 @@ class WorkerTransport {
"please use the getData-method instead."
);
}
const annotationStorage = this.annotationStorage.serializable;
const transfers = [];
if (annotationStorage) {
for (const annotation of annotationStorage.values()) {
if (annotation.bitmap) {
transfers.push(annotation.bitmap);
}
}
}
return this.messageHandler
.sendWithPromise("SaveDocument", {
isPureXfa: !!this._htmlForXfa,
numPages: this._numPages,
annotationStorage: this.annotationStorage.serializable,
filename: this._fullReader?.filename ?? null,
})
.sendWithPromise(
"SaveDocument",
{
isPureXfa: !!this._htmlForXfa,
numPages: this._numPages,
annotationStorage,
filename: this._fullReader?.filename ?? null,
},
transfers
)
.finally(() => {
this.annotationStorage.resetModified();
});

View File

@ -70,6 +70,7 @@ const AnnotationEditorType = {
DISABLE: -1,
NONE: 0,
FREETEXT: 3,
STAMP: 13,
INK: 15,
};

View File

@ -490,8 +490,24 @@ class Driver {
});
let promise = loadingTask.promise;
if (task.annotationStorage) {
for (const annotation of Object.values(task.annotationStorage)) {
if (annotation.bitmapName) {
promise = promise.then(async doc => {
const response = await fetch(
new URL(`./images/${annotation.bitmapName}`, window.location)
);
const blob = await response.blob();
annotation.bitmap = await createImageBitmap(blob);
return doc;
});
}
}
}
if (task.save) {
promise = loadingTask.promise.then(async doc => {
promise = promise.then(async doc => {
if (!task.annotationStorage) {
throw new Error("Missing `annotationStorage` entry.");
}

BIN
test/images/firefox_logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

@ -7814,5 +7814,155 @@
"rounds": 1,
"type": "eq",
"annotations": true
},
{
"id": "tracemonkey-stamp-editor-print",
"file": "pdfs/tracemonkey.pdf",
"md5": "9a192d8b1a7dc652a19835f6f08098bd",
"rounds": 1,
"lastPage": 1,
"type": "eq",
"print": true,
"annotationStorage": {
"pdfjs_internal_editor_0": {
"annotationType": 13,
"pageIndex": 0,
"bitmapName": "firefox_logo.png",
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"rect": [37.5, 32.406246185302734, 496.5000057220459, 519.1875],
"rotation": 0
},
"pdfjs_internal_editor_1": {
"annotationType": 13,
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"pageIndex": 0,
"rect": [
458.06250572204584,
473.04686737060547,
571.5000057220459,
582.7187461853027
],
"rotation": 0
}
}
},
{
"id": "tracemonkey-stamp-editor-save-print",
"file": "pdfs/tracemonkey.pdf",
"md5": "9a192d8b1a7dc652a19835f6f08098bd",
"rounds": 1,
"lastPage": 1,
"type": "eq",
"save": true,
"print": true,
"annotationStorage": {
"pdfjs_internal_editor_0": {
"annotationType": 13,
"pageIndex": 0,
"bitmapName": "firefox_logo.png",
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"rect": [37.5, 32.406246185302734, 496.5000057220459, 519.1875],
"rotation": 0
},
"pdfjs_internal_editor_1": {
"annotationType": 13,
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"pageIndex": 0,
"rect": [
458.06250572204584,
473.04686737060547,
571.5000057220459,
582.7187461853027
],
"rotation": 0
}
}
},
{
"id": "tracemonkey-stamp-editor-print-2-pages",
"file": "pdfs/tracemonkey.pdf",
"md5": "9a192d8b1a7dc652a19835f6f08098bd",
"rounds": 1,
"lastPage": 2,
"type": "eq",
"print": true,
"annotationStorage": {
"pdfjs_internal_editor_0": {
"annotationType": 13,
"pageIndex": 0,
"bitmapName": "firefox_logo.png",
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"rect": [37.5, 32.406246185302734, 496.5000057220459, 519.1875],
"rotation": 0
},
"pdfjs_internal_editor_1": {
"annotationType": 13,
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"pageIndex": 0,
"rect": [
458.06250572204584,
473.04686737060547,
571.5000057220459,
582.7187461853027
],
"rotation": 0
},
"pdfjs_internal_editor_2": {
"annotationType": 13,
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"pageIndex": 1,
"rect": [
458.06250572204584,
473.04686737060547,
571.5000057220459,
582.7187461853027
],
"rotation": 0
}
}
},
{
"id": "tracemonkey-stamp-editor-save-print-2-pages",
"file": "pdfs/tracemonkey.pdf",
"md5": "9a192d8b1a7dc652a19835f6f08098bd",
"rounds": 1,
"lastPage": 2,
"type": "eq",
"save": true,
"print": true,
"annotationStorage": {
"pdfjs_internal_editor_0": {
"annotationType": 13,
"pageIndex": 0,
"bitmapName": "firefox_logo.png",
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"rect": [37.5, 32.406246185302734, 496.5000057220459, 519.1875],
"rotation": 0
},
"pdfjs_internal_editor_1": {
"annotationType": 13,
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"pageIndex": 0,
"rect": [
458.06250572204584,
473.04686737060547,
571.5000057220459,
582.7187461853027
],
"rotation": 0
},
"pdfjs_internal_editor_2": {
"annotationType": 13,
"bitmapId": "image_c29eaeb9-8839-4a09-bf7c-75a5785805cd_0",
"pageIndex": 1,
"rect": [
458.06250572204584,
473.04686737060547,
571.5000057220459,
582.7187461853027
],
"rotation": 0
}
}
}
]

View File

@ -2134,6 +2134,58 @@ describe("api", function () {
await loadingTask.destroy();
});
it("write a new stamp annotation, save the pdf and check that the same image has the same ref", async function () {
if (isNodeJS) {
pending("Cannot create a bitmap from Node.js.");
}
const TEST_IMAGES_PATH = "../images/";
const filename = "firefox_logo.png";
const path = new URL(TEST_IMAGES_PATH + filename, window.location).href;
const response = await fetch(path);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
let loadingTask = getDocument(buildGetDocumentParams("empty.pdf"));
let pdfDoc = await loadingTask.promise;
pdfDoc.annotationStorage.setValue("pdfjs_internal_editor_0", {
annotationType: AnnotationEditorType.STAMP,
rect: [12, 34, 56, 78],
rotation: 0,
bitmap,
bitmapId: "im1",
pageIndex: 0,
});
pdfDoc.annotationStorage.setValue("pdfjs_internal_editor_1", {
annotationType: AnnotationEditorType.STAMP,
rect: [112, 134, 156, 178],
rotation: 0,
bitmapId: "im1",
pageIndex: 0,
});
const data = await pdfDoc.saveDocument();
await loadingTask.destroy();
loadingTask = getDocument(data);
pdfDoc = await loadingTask.promise;
const page = await pdfDoc.getPage(1);
const opList = await page.getOperatorList();
// The pdf contains two stamp annotations with the same image.
// The image should be stored only once in the pdf and referenced twice.
// So we can verify that the image is referenced twice in the opList.
for (let i = 0; i < opList.fnArray.length; i++) {
if (opList.fnArray[i] === OPS.paintImageXObject) {
expect(opList.argsArray[i][0]).toEqual("img_p0_1");
}
}
await loadingTask.destroy();
});
describe("Cross-origin", function () {
let loadingTask;
function _checkCanLoad(expectSuccess, filename, options) {