1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-22 16:18:08 +02:00

Add support for saving forms

This commit is contained in:
Calixte Denizet 2020-08-03 19:44:04 +02:00 committed by calixteman
parent 3380f2a7fc
commit 1a6816ba98
16 changed files with 1060 additions and 8 deletions

View file

@ -22,6 +22,7 @@ import {
AnnotationType,
assert,
escapeString,
getModificationDate,
isString,
OPS,
stringToPDFString,
@ -29,11 +30,12 @@ import {
warn,
} from "../shared/util.js";
import { Catalog, FileSpec, ObjectLoader } from "./obj.js";
import { Dict, isDict, isName, isRef, isStream } from "./primitives.js";
import { Dict, isDict, isName, isRef, isStream, Name } from "./primitives.js";
import { ColorSpace } from "./colorspace.js";
import { getInheritableProperty } from "./core_utils.js";
import { OperatorList } from "./operator_list.js";
import { StringStream } from "./stream.js";
import { writeDict } from "./writer.js";
class AnnotationFactory {
/**
@ -68,6 +70,7 @@ class AnnotationFactory {
if (!isDict(dict)) {
return undefined;
}
const id = isRef(ref) ? ref.toString() : `annot_${idFactory.createObjId()}`;
// Determine the annotation's subtype.
@ -77,6 +80,7 @@ class AnnotationFactory {
// Return the right annotation object based on the subtype and field type.
const parameters = {
xref,
ref,
dict,
subtype,
id,
@ -550,6 +554,10 @@ class Annotation {
});
});
}
async save(evaluator, task, annotationStorage) {
return null;
}
}
/**
@ -791,6 +799,7 @@ class WidgetAnnotation extends Annotation {
const dict = params.dict;
const data = this.data;
this.ref = params.ref;
data.annotationType = AnnotationType.WIDGET;
data.fieldName = this._constructFieldName(dict);
@ -953,6 +962,78 @@ class WidgetAnnotation extends Annotation {
);
}
async save(evaluator, task, annotationStorage) {
if (this.data.fieldValue === annotationStorage[this.data.id]) {
return null;
}
let appearance = await this._getAppearance(
evaluator,
task,
annotationStorage
);
if (appearance === null) {
return null;
}
const dict = evaluator.xref.fetchIfRef(this.ref);
if (!isDict(dict)) {
return null;
}
const bbox = [
0,
0,
this.data.rect[2] - this.data.rect[0],
this.data.rect[3] - this.data.rect[1],
];
const newRef = evaluator.xref.getNewRef();
const AP = new Dict(evaluator.xref);
AP.set("N", newRef);
const value = annotationStorage[this.data.id];
const encrypt = evaluator.xref.encrypt;
let originalTransform = null;
let newTransform = null;
if (encrypt) {
originalTransform = encrypt.createCipherTransform(
this.ref.num,
this.ref.gen
);
newTransform = encrypt.createCipherTransform(newRef.num, newRef.gen);
appearance = newTransform.encryptString(appearance);
}
dict.set("V", value);
dict.set("AP", AP);
dict.set("M", `D:${getModificationDate()}`);
const appearanceDict = new Dict(evaluator.xref);
appearanceDict.set("Length", appearance.length);
appearanceDict.set("Subtype", Name.get("Form"));
appearanceDict.set("Resources", this.fieldResources);
appearanceDict.set("BBox", bbox);
const bufferOriginal = [`${this.ref.num} ${this.ref.gen} obj\n`];
writeDict(dict, bufferOriginal, originalTransform);
bufferOriginal.push("\nendobj\n");
const bufferNew = [`${newRef.num} ${newRef.gen} obj\n`];
writeDict(appearanceDict, bufferNew, newTransform);
bufferNew.push(" stream\n");
bufferNew.push(appearance);
bufferNew.push("\nendstream\nendobj\n");
return [
// data for the original object
// V field changed + reference for new AP
{ ref: this.ref, data: bufferOriginal.join("") },
// data for the new AP
{ ref: newRef, data: bufferNew.join("") },
];
}
async _getAppearance(evaluator, task, annotationStorage) {
const isPassword = this.hasFieldFlag(AnnotationFieldFlag.PASSWORD);
if (!annotationStorage || isPassword) {
@ -1312,6 +1393,111 @@ class ButtonWidgetAnnotation extends WidgetAnnotation {
);
}
async save(evaluator, task, annotationStorage) {
if (this.data.checkBox) {
return this._saveCheckbox(evaluator, task, annotationStorage);
}
if (this.data.radioButton) {
return this._saveRadioButton(evaluator, task, annotationStorage);
}
return super.save(evaluator, task, annotationStorage);
}
async _saveCheckbox(evaluator, task, annotationStorage) {
const defaultValue = this.data.fieldValue && this.data.fieldValue !== "Off";
const value = annotationStorage[this.data.id];
if (defaultValue === value) {
return null;
}
const dict = evaluator.xref.fetchIfRef(this.ref);
if (!isDict(dict)) {
return null;
}
const name = Name.get(value ? this.data.exportValue : "Off");
dict.set("V", name);
dict.set("AS", name);
dict.set("M", `D:${getModificationDate()}`);
const encrypt = evaluator.xref.encrypt;
let originalTransform = null;
if (encrypt) {
originalTransform = encrypt.createCipherTransform(
this.ref.num,
this.ref.gen
);
}
const buffer = [`${this.ref.num} ${this.ref.gen} obj\n`];
writeDict(dict, buffer, originalTransform);
buffer.push("\nendobj\n");
return [{ ref: this.ref, data: buffer.join("") }];
}
async _saveRadioButton(evaluator, task, annotationStorage) {
const defaultValue = this.data.fieldValue === this.data.buttonValue;
const value = annotationStorage[this.data.id];
if (defaultValue === value) {
return null;
}
const dict = evaluator.xref.fetchIfRef(this.ref);
if (!isDict(dict)) {
return null;
}
const name = Name.get(value ? this.data.buttonValue : "Off");
let parentBuffer = null;
const encrypt = evaluator.xref.encrypt;
if (value) {
if (isRef(this.parent)) {
const parent = evaluator.xref.fetch(this.parent);
let parentTransform = null;
if (encrypt) {
parentTransform = encrypt.createCipherTransform(
this.parent.num,
this.parent.gen
);
}
parent.set("V", name);
parentBuffer = [`${this.parent.num} ${this.parent.gen} obj\n`];
writeDict(parent, parentBuffer, parentTransform);
parentBuffer.push("\nendobj\n");
} else if (isDict(this.parent)) {
this.parent.set("V", name);
}
}
dict.set("AS", name);
dict.set("M", `D:${getModificationDate()}`);
let originalTransform = null;
if (encrypt) {
originalTransform = encrypt.createCipherTransform(
this.ref.num,
this.ref.gen
);
}
const buffer = [`${this.ref.num} ${this.ref.gen} obj\n`];
writeDict(dict, buffer, originalTransform);
buffer.push("\nendobj\n");
const newRefs = [{ ref: this.ref, data: buffer.join("") }];
if (parentBuffer !== null) {
newRefs.push({ ref: this.parent, data: parentBuffer.join("") });
}
return newRefs;
}
_processCheckBox(params) {
if (isName(this.data.fieldValue)) {
this.data.fieldValue = this.data.fieldValue.name;
@ -1354,6 +1540,7 @@ class ButtonWidgetAnnotation extends WidgetAnnotation {
if (isDict(fieldParent) && fieldParent.has("V")) {
const fieldParentValue = fieldParent.get("V");
if (isName(fieldParentValue)) {
this.parent = params.dict.getRaw("Parent");
this.data.fieldValue = fieldParentValue.name;
}
}

View file

@ -73,6 +73,7 @@ var ARCFourCipher = (function ARCFourCipherClosure() {
},
};
ARCFourCipher.prototype.decryptBlock = ARCFourCipher.prototype.encryptBlock;
ARCFourCipher.prototype.encrypt = ARCFourCipher.prototype.encryptBlock;
return ARCFourCipher;
})();
@ -699,6 +700,9 @@ var NullCipher = (function NullCipherClosure() {
decryptBlock: function NullCipher_decryptBlock(data) {
return data;
},
encrypt: function NullCipher_encrypt(data) {
return data;
},
};
return NullCipher;
@ -1097,6 +1101,7 @@ class AESBaseCipher {
if (bufferLength < 16) {
continue;
}
for (let j = 0; j < 16; ++j) {
buffer[j] ^= iv[j];
}
@ -1474,6 +1479,42 @@ var CipherTransform = (function CipherTransformClosure() {
data = cipher.decryptBlock(data, true);
return bytesToString(data);
},
encryptString: function CipherTransform_encryptString(s) {
const cipher = new this.StringCipherConstructor();
if (cipher instanceof AESBaseCipher) {
// Append some chars equal to "16 - (M mod 16)"
// where M is the string length (see section 7.6.2 in PDF specification)
// to have a final string where the length is a multiple of 16.
const strLen = s.length;
const pad = 16 - (strLen % 16);
if (pad !== 16) {
s = s.padEnd(16 * Math.ceil(strLen / 16), String.fromCharCode(pad));
}
// Generate an initialization vector
const iv = new Uint8Array(16);
if (typeof crypto !== "undefined") {
crypto.getRandomValues(iv);
} else {
for (let i = 0; i < 16; i++) {
iv[i] = Math.floor(256 * Math.random());
}
}
let data = stringToBytes(s);
data = cipher.encrypt(data, iv);
const buf = new Uint8Array(16 + data.length);
buf.set(iv);
buf.set(data, 16);
return bytesToString(buf);
}
let data = stringToBytes(s);
data = cipher.encrypt(data);
return bytesToString(data);
},
};
return CipherTransform;
})();

View file

@ -227,6 +227,43 @@ class Page {
return stream;
}
save(handler, task, annotationStorage) {
const partialEvaluator = new PartialEvaluator({
xref: this.xref,
handler,
pageIndex: this.pageIndex,
idFactory: this._localIdFactory,
fontCache: this.fontCache,
builtInCMapCache: this.builtInCMapCache,
globalImageCache: this.globalImageCache,
options: this.evaluatorOptions,
});
// Fetch the page's annotations and save the content
// in case of interactive form fields.
return this._parsedAnnotations.then(function (annotations) {
const newRefsPromises = [];
for (const annotation of annotations) {
if (!isAnnotationRenderable(annotation, "print")) {
continue;
}
newRefsPromises.push(
annotation
.save(partialEvaluator, task, annotationStorage)
.catch(function (reason) {
warn(
"save - ignoring annotation data during " +
`"${task.name}" task: "${reason}".`
);
return null;
})
);
}
return Promise.all(newRefsPromises);
});
}
loadResources(keys) {
if (!this.resourcesPromise) {
// TODO: add async `_getInheritableProperty` and remove this.

View file

@ -1211,9 +1211,21 @@ var XRef = (function XRefClosure() {
streamTypes: Object.create(null),
fontTypes: Object.create(null),
};
this._newRefNum = null;
}
XRef.prototype = {
getNewRef: function XRef_getNewRef() {
if (this._newRefNum === null) {
this._newRefNum = this.entries.length;
}
return Ref.get(this._newRefNum++, 0);
},
resetNewRef: function XRef_resetNewRef() {
this._newRefNum = null;
},
setStartXRef: function XRef_setStartXRef(startXRef) {
// Store the starting positions of xref tables as we process them
// so we can recover from missing data errors

View file

@ -21,9 +21,11 @@ import {
getVerbosityLevel,
info,
InvalidPDFException,
isString,
MissingPDFException,
PasswordException,
setVerbosityLevel,
stringToPDFString,
UnexpectedResponseException,
UnknownErrorException,
UNSUPPORTED_FEATURES,
@ -32,6 +34,7 @@ import {
} from "../shared/util.js";
import { clearPrimitiveCaches, Ref } from "./primitives.js";
import { LocalPdfManager, NetworkPdfManager } from "./pdf_manager.js";
import { incrementalUpdate } from "./writer.js";
import { isNodeJS } from "../shared/is_node.js";
import { MessageHandler } from "../shared/message_handler.js";
import { PDFWorkerStream } from "./worker_stream.js";
@ -513,6 +516,67 @@ class WorkerMessageHandler {
});
});
handler.on("SaveDocument", function ({
numPages,
annotationStorage,
filename,
}) {
pdfManager.requestLoadedStream();
const promises = [pdfManager.onLoadedStream()];
const document = pdfManager.pdfDocument;
for (let pageIndex = 0; pageIndex < numPages; pageIndex++) {
promises.push(
pdfManager.getPage(pageIndex).then(function (page) {
const task = new WorkerTask(`Save: page ${pageIndex}`);
return page.save(handler, task, annotationStorage);
})
);
}
return Promise.all(promises).then(([stream, ...refs]) => {
let newRefs = [];
for (const ref of refs) {
newRefs = ref
.filter(x => x !== null)
.reduce((a, b) => a.concat(b), newRefs);
}
if (newRefs.length === 0) {
// No new refs so just return the initial bytes
return stream.bytes;
}
const xref = document.xref;
let newXrefInfo = Object.create(null);
if (xref.trailer) {
// Get string info from Info in order to compute fileId
const _info = Object.create(null);
const xrefInfo = xref.trailer.get("Info") || null;
if (xrefInfo) {
xrefInfo.forEach((key, value) => {
if (isString(key) && isString(value)) {
_info[key] = stringToPDFString(value);
}
});
}
newXrefInfo = {
rootRef: xref.trailer.getRaw("Root") || null,
encrypt: xref.trailer.getRaw("Encrypt") || null,
newRef: xref.getNewRef(),
infoRef: xref.trailer.getRaw("Info") || null,
info: _info,
fileIds: xref.trailer.getRaw("ID") || null,
startXRef: document.startXRef,
filename,
};
}
xref.resetNewRef();
return incrementalUpdate(stream.bytes, newXrefInfo, newRefs);
});
});
handler.on(
"GetOperatorList",
function wphSetupRenderPage(data, sink) {

221
src/core/writer.js Normal file
View file

@ -0,0 +1,221 @@
/* Copyright 2020 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.
*/
/* eslint no-var: error */
import { bytesToString, escapeString } from "../shared/util.js";
import { Dict, isDict, isName, isRef, isStream, Name } from "./primitives.js";
import { calculateMD5 } from "./crypto.js";
function writeDict(dict, buffer, transform) {
buffer.push("<<");
for (const key of dict.getKeys()) {
buffer.push(` /${key} `);
writeValue(dict.getRaw(key), buffer, transform);
}
buffer.push(">>");
}
function writeStream(stream, buffer, transform) {
writeDict(stream.dict, buffer, transform);
buffer.push(" stream\n");
let string = bytesToString(stream.getBytes());
if (transform !== null) {
string = transform.encryptString(string);
}
buffer.push(string);
buffer.push("\nendstream\n");
}
function writeArray(array, buffer, transform) {
buffer.push("[");
let first = true;
for (const val of array) {
if (!first) {
buffer.push(" ");
} else {
first = false;
}
writeValue(val, buffer, transform);
}
buffer.push("]");
}
function numberToString(value) {
if (Number.isInteger(value)) {
return value.toString();
}
const roundedValue = Math.round(value * 100);
if (roundedValue % 100 === 0) {
return (roundedValue / 100).toString();
}
if (roundedValue % 10 === 0) {
return value.toFixed(1);
}
return value.toFixed(2);
}
function writeValue(value, buffer, transform) {
if (isName(value)) {
buffer.push(`/${value.name}`);
} else if (isRef(value)) {
buffer.push(`${value.num} ${value.gen} R`);
} else if (Array.isArray(value)) {
writeArray(value, buffer, transform);
} else if (typeof value === "string") {
if (transform !== null) {
value = transform.encryptString(value);
}
buffer.push(`(${escapeString(value)})`);
} else if (typeof value === "number") {
buffer.push(numberToString(value));
} else if (isDict(value)) {
writeDict(value, buffer, transform);
} else if (isStream(value)) {
writeStream(value, buffer, transform);
}
}
function writeInt(number, size, offset, buffer) {
for (let i = size + offset - 1; i > offset - 1; i--) {
buffer[i] = number & 0xff;
number >>= 8;
}
return offset + size;
}
function writeString(string, offset, buffer) {
for (let i = 0, len = string.length; i < len; i++) {
buffer[offset + i] = string.charCodeAt(i) & 0xff;
}
}
function computeMD5(filesize, xrefInfo) {
const time = Math.floor(Date.now() / 1000);
const filename = xrefInfo.filename || "";
const md5Buffer = [time.toString(), filename, filesize.toString()];
let md5BufferLen = md5Buffer.reduce((a, str) => a + str.length, 0);
for (const value of Object.values(xrefInfo.info)) {
md5Buffer.push(value);
md5BufferLen += value.length;
}
const array = new Uint8Array(md5BufferLen);
let offset = 0;
for (const str of md5Buffer) {
writeString(str, offset, array);
offset += str.length;
}
return bytesToString(calculateMD5(array));
}
function incrementalUpdate(originalData, xrefInfo, newRefs) {
const newXref = new Dict(null);
const refForXrefTable = xrefInfo.newRef;
let buffer, baseOffset;
const lastByte = originalData[originalData.length - 1];
if (lastByte === /* \n */ 0x0a || lastByte === /* \r */ 0x0d) {
buffer = [];
baseOffset = originalData.length;
} else {
// Avoid to concatenate %%EOF with an object definition
buffer = ["\n"];
baseOffset = originalData.length + 1;
}
newXref.set("Size", refForXrefTable.num + 1);
newXref.set("Prev", xrefInfo.startXRef);
newXref.set("Type", Name.get("XRef"));
if (xrefInfo.rootRef !== null) {
newXref.set("Root", xrefInfo.rootRef);
}
if (xrefInfo.infoRef !== null) {
newXref.set("Info", xrefInfo.infoRef);
}
if (xrefInfo.encrypt !== null) {
newXref.set("Encrypt", xrefInfo.encrypt);
}
// Add a ref for the new xref and sort them
newRefs.push({ ref: refForXrefTable, data: "" });
newRefs = newRefs.sort((a, b) => {
// compare the refs
return a.ref.num - b.ref.num;
});
const xrefTableData = [[0, 1, 0xffff]];
const indexes = [0, 1];
let maxOffset = 0;
for (const { ref, data } of newRefs) {
maxOffset = Math.max(maxOffset, baseOffset);
xrefTableData.push([1, baseOffset, Math.min(ref.gen, 0xffff)]);
baseOffset += data.length;
indexes.push(ref.num);
indexes.push(1);
buffer.push(data);
}
newXref.set("Index", indexes);
if (xrefInfo.fileIds.length !== 0) {
const md5 = computeMD5(baseOffset, xrefInfo);
newXref.set("ID", [xrefInfo.fileIds[0], md5]);
}
const offsetSize = Math.ceil(Math.log2(maxOffset) / 8);
const sizes = [1, offsetSize, 2];
const structSize = sizes[0] + sizes[1] + sizes[2];
const tableLength = structSize * xrefTableData.length;
newXref.set("W", sizes);
newXref.set("Length", tableLength);
buffer.push(`${refForXrefTable.num} ${refForXrefTable.gen} obj\n`);
writeDict(newXref, buffer, null);
buffer.push(" stream\n");
const bufferLen = buffer.reduce((a, str) => a + str.length, 0);
const footer = `\nendstream\nendobj\nstartxref\n${baseOffset}\n%%EOF\n`;
const array = new Uint8Array(
originalData.length + bufferLen + tableLength + footer.length
);
// Original data
array.set(originalData);
let offset = originalData.length;
// New data
for (const str of buffer) {
writeString(str, offset, array);
offset += str.length;
}
// New xref table
for (const [type, objOffset, gen] of xrefTableData) {
offset = writeInt(type, sizes[0], offset, array);
offset = writeInt(objOffset, sizes[1], offset, array);
offset = writeInt(gen, sizes[2], offset, array);
}
// Add the footer
writeString(footer, offset, array);
return array;
}
export { writeDict, incrementalUpdate };

View file

@ -867,6 +867,16 @@ class PDFDocumentProxy {
get loadingTask() {
return this._transport.loadingTask;
}
/**
* @param {AnnotationStorage} annotationStorage - Storage for annotation
* data in forms.
* @returns {Promise<Uint8Array>} A promise that is resolved with a
* {Uint8Array} containing the full data of the saved document.
*/
saveDocument(annotationStorage) {
return this._transport.saveDocument(annotationStorage);
}
}
/**
@ -2520,6 +2530,15 @@ class WorkerTransport {
});
}
saveDocument(annotationStorage) {
return this.messageHandler.sendWithPromise("SaveDocument", {
numPages: this._numPages,
annotationStorage:
(annotationStorage && annotationStorage.getAll()) || null,
filename: this._fullReader.filename,
});
}
getDestinations() {
return this.messageHandler.sendWithPromise("GetDestinations", null);
}

View file

@ -832,6 +832,19 @@ function isArrayEqual(arr1, arr2) {
});
}
function getModificationDate(date = new Date(Date.now())) {
const buffer = [
date.getUTCFullYear().toString(),
(date.getUTCMonth() + 1).toString().padStart(2, "0"),
(date.getUTCDate() + 1).toString().padStart(2, "0"),
date.getUTCHours().toString().padStart(2, "0"),
date.getUTCMinutes().toString().padStart(2, "0"),
date.getUTCSeconds().toString().padStart(2, "0"),
];
return buffer.join("");
}
/**
* Promise Capability object.
*
@ -934,6 +947,7 @@ export {
createPromiseCapability,
createObjectURL,
escapeString,
getModificationDate,
getVerbosityLevel,
info,
isArrayBuffer,