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,

View File

@ -1821,6 +1821,46 @@ describe("annotation", function () {
done();
}, done.fail);
});
it("should save text", function (done) {
const textWidgetRef = Ref.get(123, 0);
const xref = new XRefMock([{ ref: textWidgetRef, data: textWidgetDict }]);
partialEvaluator.xref = xref;
const task = new WorkerTask("test save");
AnnotationFactory.create(
xref,
textWidgetRef,
pdfManagerMock,
idFactoryMock
)
.then(annotation => {
const annotationStorage = {};
annotationStorage[annotation.data.id] = "hello world";
return annotation.save(partialEvaluator, task, annotationStorage);
}, done.fail)
.then(data => {
expect(data.length).toEqual(2);
const [oldData, newData] = data;
expect(oldData.ref).toEqual(Ref.get(123, 0));
expect(newData.ref).toEqual(Ref.get(1, 0));
oldData.data = oldData.data.replace(/\(D:[0-9]+\)/, "(date)");
expect(oldData.data).toEqual(
"123 0 obj\n" +
"<< /Type /Annot /Subtype /Widget /FT /Tx /DA (/Helv 5 Tf) /DR " +
"<< /Font << /Helv 314 0 R>>>> /Rect [0 0 32 10] " +
"/V (hello world) /AP << /N 1 0 R>> /M (date)>>\nendobj\n"
);
expect(newData.data).toEqual(
"1 0 obj\n<< /Length 77 /Subtype /Form /Resources " +
"<< /Font << /Helv 314 0 R>>>> /BBox [0 0 32 10]>> stream\n" +
"/Tx BMC q BT /Helv 5 Tf 1 0 0 1 0 0 Tm 2.00 2.00 Td (hello world) Tj " +
"ET Q EMC\nendstream\nendobj\n"
);
done();
}, done.fail);
});
});
describe("ButtonWidgetAnnotation", function () {
@ -1977,6 +2017,65 @@ describe("annotation", function () {
}, done.fail);
});
it("should save checkboxes", function (done) {
const appearanceStatesDict = new Dict();
const exportValueOptionsDict = new Dict();
const normalAppearanceDict = new Dict();
normalAppearanceDict.set("Checked", Ref.get(314, 0));
normalAppearanceDict.set("Off", Ref.get(271, 0));
exportValueOptionsDict.set("Off", 0);
exportValueOptionsDict.set("Checked", 1);
appearanceStatesDict.set("D", exportValueOptionsDict);
appearanceStatesDict.set("N", normalAppearanceDict);
buttonWidgetDict.set("AP", appearanceStatesDict);
buttonWidgetDict.set("V", Name.get("Off"));
const buttonWidgetRef = Ref.get(123, 0);
const xref = new XRefMock([
{ ref: buttonWidgetRef, data: buttonWidgetDict },
]);
partialEvaluator.xref = xref;
const task = new WorkerTask("test save");
AnnotationFactory.create(
xref,
buttonWidgetRef,
pdfManagerMock,
idFactoryMock
)
.then(annotation => {
const annotationStorage = {};
annotationStorage[annotation.data.id] = true;
return Promise.all([
annotation,
annotation.save(partialEvaluator, task, annotationStorage),
]);
}, done.fail)
.then(([annotation, [oldData]]) => {
oldData.data = oldData.data.replace(/\(D:[0-9]+\)/, "(date)");
expect(oldData.ref).toEqual(Ref.get(123, 0));
expect(oldData.data).toEqual(
"123 0 obj\n" +
"<< /Type /Annot /Subtype /Widget /FT /Btn " +
"/AP << /D << /Off 0 /Checked 1>> " +
"/N << /Checked 314 0 R /Off 271 0 R>>>> " +
"/V /Checked /AS /Checked /M (date)>>\nendobj\n"
);
return annotation;
}, done.fail)
.then(annotation => {
const annotationStorage = {};
annotationStorage[annotation.data.id] = false;
return annotation.save(partialEvaluator, task, annotationStorage);
}, done.fail)
.then(data => {
expect(data).toEqual(null);
done();
}, done.fail);
});
it("should handle radio buttons with a field value", function (done) {
const parentDict = new Dict();
parentDict.set("V", Name.get("1"));
@ -2127,6 +2226,83 @@ describe("annotation", function () {
done();
}, done.fail);
});
it("should save radio buttons", function (done) {
const appearanceStatesDict = new Dict();
const exportValueOptionsDict = new Dict();
const normalAppearanceDict = new Dict();
normalAppearanceDict.set("Checked", Ref.get(314, 0));
normalAppearanceDict.set("Off", Ref.get(271, 0));
exportValueOptionsDict.set("Off", 0);
exportValueOptionsDict.set("Checked", 1);
appearanceStatesDict.set("D", exportValueOptionsDict);
appearanceStatesDict.set("N", normalAppearanceDict);
buttonWidgetDict.set("Ff", AnnotationFieldFlag.RADIO);
buttonWidgetDict.set("AP", appearanceStatesDict);
const buttonWidgetRef = Ref.get(123, 0);
const parentRef = Ref.get(456, 0);
const parentDict = new Dict();
parentDict.set("V", Name.get("Off"));
parentDict.set("Kids", [buttonWidgetRef]);
buttonWidgetDict.set("Parent", parentRef);
const xref = new XRefMock([
{ ref: buttonWidgetRef, data: buttonWidgetDict },
{ ref: parentRef, data: parentDict },
]);
parentDict.xref = xref;
buttonWidgetDict.xref = xref;
partialEvaluator.xref = xref;
const task = new WorkerTask("test save");
AnnotationFactory.create(
xref,
buttonWidgetRef,
pdfManagerMock,
idFactoryMock
)
.then(annotation => {
const annotationStorage = {};
annotationStorage[annotation.data.id] = true;
return Promise.all([
annotation,
annotation.save(partialEvaluator, task, annotationStorage),
]);
}, done.fail)
.then(([annotation, data]) => {
expect(data.length).toEqual(2);
const [radioData, parentData] = data;
radioData.data = radioData.data.replace(/\(D:[0-9]+\)/, "(date)");
expect(radioData.ref).toEqual(Ref.get(123, 0));
expect(radioData.data).toEqual(
"123 0 obj\n" +
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 " +
"/AP << /D << /Off 0 /Checked 1>> " +
"/N << /Checked 314 0 R /Off 271 0 R>>>> " +
"/Parent 456 0 R /AS /Checked /M (date)>>\nendobj\n"
);
expect(parentData.ref).toEqual(Ref.get(456, 0));
expect(parentData.data).toEqual(
"456 0 obj\n<< /V /Checked /Kids [123 0 R]>>\nendobj\n"
);
return annotation;
}, done.fail)
.then(annotation => {
const annotationStorage = {};
annotationStorage[annotation.data.id] = false;
return annotation.save(partialEvaluator, task, annotationStorage);
}, done.fail)
.then(data => {
expect(data).toEqual(null);
done();
}, done.fail);
});
});
describe("ChoiceWidgetAnnotation", function () {
@ -2448,6 +2624,53 @@ describe("annotation", function () {
done();
}, done.fail);
});
it("should save choice", function (done) {
choiceWidgetDict.set("Opt", ["A", "B", "C"]);
choiceWidgetDict.set("V", "A");
const choiceWidgetRef = Ref.get(123, 0);
const xref = new XRefMock([
{ ref: choiceWidgetRef, data: choiceWidgetDict },
]);
partialEvaluator.xref = xref;
const task = new WorkerTask("test save");
AnnotationFactory.create(
xref,
choiceWidgetRef,
pdfManagerMock,
idFactoryMock
)
.then(annotation => {
const annotationStorage = {};
annotationStorage[annotation.data.id] = "C";
return annotation.save(partialEvaluator, task, annotationStorage);
}, done.fail)
.then(data => {
expect(data.length).toEqual(2);
const [oldData, newData] = data;
expect(oldData.ref).toEqual(Ref.get(123, 0));
expect(newData.ref).toEqual(Ref.get(1, 0));
oldData.data = oldData.data.replace(/\(D:[0-9]+\)/, "(date)");
expect(oldData.data).toEqual(
"123 0 obj\n" +
"<< /Type /Annot /Subtype /Widget /FT /Ch /DA (/Helv 5 Tf) /DR " +
"<< /Font << /Helv 314 0 R>>>> " +
"/Rect [0 0 32 10] /Opt [(A) (B) (C)] /V (C) " +
"/AP << /N 1 0 R>> /M (date)>>\nendobj\n"
);
expect(newData.data).toEqual(
"1 0 obj\n" +
"<< /Length 67 /Subtype /Form /Resources << /Font << /Helv 314 0 R>>>> " +
"/BBox [0 0 32 10]>> stream\n" +
"/Tx BMC q BT /Helv 5 Tf 1 0 0 1 0 0 Tm 2.00 2.00 Td (C) Tj ET Q EMC\n" +
"endstream\nendobj\n"
);
done();
}, done.fail);
});
});
describe("LineAnnotation", function () {

View File

@ -36,6 +36,7 @@
"type1_parser_spec.js",
"ui_utils_spec.js",
"unicode_spec.js",
"util_spec.js"
"util_spec.js",
"writer_spec.js"
]
}

View File

@ -599,7 +599,16 @@ describe("CipherTransformFactory", function () {
done.fail("Password should be rejected.");
}
var fileId1, fileId2, dict1, dict2;
function ensureEncryptDecryptIsIdentity(dict, fileId, password, string) {
const factory = new CipherTransformFactory(dict, fileId, password);
const cipher = factory.createCipherTransform(123, 0);
const encrypted = cipher.encryptString(string);
const decrypted = cipher.decryptString(encrypted);
expect(string).toEqual(decrypted);
}
var fileId1, fileId2, dict1, dict2, dict3;
var aes256Dict, aes256IsoDict, aes256BlankDict, aes256IsoBlankDict;
beforeAll(function (done) {
@ -636,7 +645,7 @@ describe("CipherTransformFactory", function () {
P: -1084,
R: 4,
});
aes256Dict = buildDict({
dict3 = {
Filter: Name.get("Standard"),
V: 5,
Length: 256,
@ -661,7 +670,8 @@ describe("CipherTransformFactory", function () {
Perms: unescape("%D8%FC%844%E5e%0DB%5D%7Ff%FD%3COMM"),
P: -1084,
R: 5,
});
};
aes256Dict = buildDict(dict3);
aes256IsoDict = buildDict({
Filter: Name.get("Standard"),
V: 5,
@ -742,7 +752,7 @@ describe("CipherTransformFactory", function () {
});
afterAll(function () {
fileId1 = fileId2 = dict1 = dict2 = null;
fileId1 = fileId2 = dict1 = dict2 = dict3 = null;
aes256Dict = aes256IsoDict = aes256BlankDict = aes256IsoBlankDict = null;
});
@ -799,4 +809,61 @@ describe("CipherTransformFactory", function () {
ensurePasswordCorrect(done, dict2, fileId2);
});
});
describe("Encrypt and decrypt", function () {
it("should encrypt and decrypt using ARCFour", function (done) {
dict3.CF = buildDict({
Identity: buildDict({
CFM: Name.get("V2"),
}),
});
const dict = buildDict(dict3);
ensureEncryptDecryptIsIdentity(dict, fileId1, "user", "hello world");
done();
});
it("should encrypt and decrypt using AES128", function (done) {
dict3.CF = buildDict({
Identity: buildDict({
CFM: Name.get("AESV2"),
}),
});
const dict = buildDict(dict3);
// 1 char
ensureEncryptDecryptIsIdentity(dict, fileId1, "user", "a");
// 2 chars
ensureEncryptDecryptIsIdentity(dict, fileId1, "user", "aa");
// 16 chars
ensureEncryptDecryptIsIdentity(dict, fileId1, "user", "aaaaaaaaaaaaaaaa");
// 19 chars
ensureEncryptDecryptIsIdentity(
dict,
fileId1,
"user",
"aaaaaaaaaaaaaaaaaaa"
);
done();
});
it("should encrypt and decrypt using AES256", function (done) {
dict3.CF = buildDict({
Identity: buildDict({
CFM: Name.get("AESV3"),
}),
});
const dict = buildDict(dict3);
// 4 chars
ensureEncryptDecryptIsIdentity(dict, fileId1, "user", "aaaa");
// 5 chars
ensureEncryptDecryptIsIdentity(dict, fileId1, "user", "aaaaa");
// 16 chars
ensureEncryptDecryptIsIdentity(dict, fileId1, "user", "aaaaaaaaaaaaaaaa");
// 22 chars
ensureEncryptDecryptIsIdentity(
dict,
fileId1,
"user",
"aaaaaaaaaaaaaaaaaaaaaa"
);
done();
});
});
});

View File

@ -80,6 +80,7 @@ function initializePDFJS(callback) {
"pdfjs-test/unit/ui_utils_spec.js",
"pdfjs-test/unit/unicode_spec.js",
"pdfjs-test/unit/util_spec.js",
"pdfjs-test/unit/writer_spec.js",
].map(function (moduleName) {
// eslint-disable-next-line no-unsanitized/method
return SystemJS.import(moduleName);

View File

@ -13,10 +13,10 @@
* limitations under the License.
*/
import { isRef, Ref } from "../../src/core/primitives.js";
import { Page, PDFDocument } from "../../src/core/document.js";
import { assert } from "../../src/shared/util.js";
import { isNodeJS } from "../../src/shared/is_node.js";
import { isRef } from "../../src/core/primitives.js";
import { StringStream } from "../../src/core/stream.js";
class DOMFileReaderFactory {
@ -70,6 +70,7 @@ class XRefMock {
streamTypes: Object.create(null),
fontTypes: Object.create(null),
};
this._newRefNum = null;
for (const key in array) {
const obj = array[key];
@ -77,6 +78,17 @@ class XRefMock {
}
}
getNewRef() {
if (this._newRefNum === null) {
this._newRefNum = Object.keys(this._map).length;
}
return Ref.get(this._newRefNum++, 0);
}
resetNewRef() {
this.newRef = null;
}
fetch(ref) {
return this._map[ref.toString()];
}

View File

@ -18,6 +18,7 @@ import {
createPromiseCapability,
createValidAbsoluteUrl,
escapeString,
getModificationDate,
isArrayBuffer,
isBool,
isNum,
@ -323,4 +324,11 @@ describe("util", function () {
);
});
});
describe("getModificationDate", function () {
it("should get a correctly formatted date", function () {
const date = new Date(Date.UTC(3141, 5, 9, 2, 6, 53));
expect(getModificationDate(date)).toEqual("31410610020653");
});
});
});

99
test/unit/writer_spec.js Normal file
View File

@ -0,0 +1,99 @@
/* 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.
*/
import { Dict, Name, Ref } from "../../src/core/primitives.js";
import { incrementalUpdate, writeDict } from "../../src/core/writer.js";
import { bytesToString } from "../../src/shared/util.js";
import { StringStream } from "../../src/core/stream.js";
describe("Writer", function () {
describe("Incremental update", function () {
it("should update a file with new objects", function (done) {
const originalData = new Uint8Array();
const newRefs = [
{ ref: Ref.get(123, 0x2d), data: "abc\n" },
{ ref: Ref.get(456, 0x4e), data: "defg\n" },
];
const xrefInfo = {
newRef: Ref.get(789, 0),
startXRef: 314,
fileIds: ["id", ""],
rootRef: null,
infoRef: null,
encrypt: null,
filename: "foo.pdf",
info: {},
};
let data = incrementalUpdate(originalData, xrefInfo, newRefs);
data = bytesToString(data);
const expected =
"\nabc\n" +
"defg\n" +
"789 0 obj\n" +
"<< /Size 790 /Prev 314 /Type /XRef /Index [0 1 123 1 456 1 789 1] " +
"/ID [(id) (\x01#Eg\x89\xab\xcd\xef\xfe\xdc\xba\x98vT2\x10)] " +
"/W [1 1 2] /Length 16>> stream\n" +
"\x00\x01\xff\xff" +
"\x01\x01\x00\x2d" +
"\x01\x05\x00\x4e" +
"\x01\x0a\x00\x00\n" +
"endstream\n" +
"endobj\n" +
"startxref\n" +
"10\n" +
"%%EOF\n";
expect(data).toEqual(expected);
done();
});
});
describe("writeDict", function () {
it("should write a Dict", function (done) {
const dict = new Dict(null);
dict.set("A", Name.get("B"));
dict.set("B", Ref.get(123, 456));
dict.set("C", 789);
dict.set("D", "hello world");
dict.set("E", "(hello\\world)");
dict.set("F", [1.23001, 4.50001, 6]);
const gdict = new Dict(null);
gdict.set("H", 123.00001);
const string = "a stream";
const stream = new StringStream(string);
stream.dict = new Dict(null);
stream.dict.set("Length", string.length);
gdict.set("I", stream);
dict.set("G", gdict);
const buffer = [];
writeDict(dict, buffer, null);
const expected =
"<< /A /B /B 123 456 R /C 789 /D (hello world) " +
"/E (\\(hello\\\\world\\)) /F [1.23 4.5 6] " +
"/G << /H 123 /I << /Length 8>> stream\n" +
"a stream\n" +
"endstream\n>>>>";
expect(buffer.join("")).toEqual(expected);
done();
});
});
});

View File

@ -236,6 +236,7 @@ const PDFViewerApplication = {
_boundEvents: {},
contentDispositionFilename: null,
triggerDelayedFallback: null,
_saveInProgress: false,
// Called once when the document is loaded.
async initialize(appConfig) {
@ -730,6 +731,7 @@ const PDFViewerApplication = {
this.baseUrl = "";
this.contentDispositionFilename = null;
this.triggerDelayedFallback = null;
this._saveInProgress = false;
this.pdfSidebar.reset();
this.pdfOutlineViewer.reset();
@ -904,6 +906,43 @@ const PDFViewerApplication = {
.catch(downloadByUrl); // Error occurred, try downloading with the URL.
},
save() {
if (this._saveInProgress) {
return;
}
const url = this.baseUrl;
// Use this.url instead of this.baseUrl to perform filename detection based
// on the reference fragment as ultimate fallback if needed.
const filename =
this.contentDispositionFilename || getPDFFileNameFromURL(this.url);
const downloadManager = this.downloadManager;
downloadManager.onerror = err => {
// This error won't really be helpful because it's likely the
// fallback won't work either (or is already open).
this.error(`PDF failed to be saved: ${err}`);
};
// When the PDF document isn't ready, or the PDF file is still downloading,
// simply download using the URL.
if (!this.pdfDocument || !this.downloadComplete) {
this.download();
return;
}
this._saveInProgress = true;
this.pdfDocument
.saveDocument(this.pdfDocument.annotationStorage)
.then(data => {
const blob = new Blob([data], { type: "application/pdf" });
downloadManager.download(blob, url, filename);
})
.catch(() => {
this.download();
}); // Error occurred, try downloading with the URL.
this._saveInProgress = false;
},
/**
* For PDF documents that contain e.g. forms and javaScript, we should only
* trigger the fallback bar once the user has interacted with the page.
@ -2265,7 +2304,14 @@ function webViewerPrint() {
window.print();
}
function webViewerDownload() {
PDFViewerApplication.download();
if (
PDFViewerApplication.pdfDocument &&
PDFViewerApplication.pdfDocument.annotationStorage.size > 0
) {
PDFViewerApplication.save();
} else {
PDFViewerApplication.download();
}
}
function webViewerFirstPage() {
if (PDFViewerApplication.pdfDocument) {