From 1a6816ba98fd437024f6f0ddfa78e6d4dbf11371 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 3 Aug 2020 19:44:04 +0200 Subject: [PATCH] Add support for saving forms --- src/core/annotation.js | 189 ++++++++++++++++++++++++++++- src/core/crypto.js | 41 +++++++ src/core/document.js | 37 ++++++ src/core/obj.js | 12 ++ src/core/worker.js | 64 ++++++++++ src/core/writer.js | 221 ++++++++++++++++++++++++++++++++++ src/display/api.js | 19 +++ src/shared/util.js | 14 +++ test/unit/annotation_spec.js | 223 +++++++++++++++++++++++++++++++++++ test/unit/clitests.json | 3 +- test/unit/crypto_spec.js | 75 +++++++++++- test/unit/jasmine-boot.js | 1 + test/unit/test_utils.js | 14 ++- test/unit/util_spec.js | 8 ++ test/unit/writer_spec.js | 99 ++++++++++++++++ web/app.js | 48 +++++++- 16 files changed, 1060 insertions(+), 8 deletions(-) create mode 100644 src/core/writer.js create mode 100644 test/unit/writer_spec.js diff --git a/src/core/annotation.js b/src/core/annotation.js index 715d8c98cbceb..1da94633861e6 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -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; } } diff --git a/src/core/crypto.js b/src/core/crypto.js index 12d2d2e92eb53..b8f81a0c6561b 100644 --- a/src/core/crypto.js +++ b/src/core/crypto.js @@ -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; })(); diff --git a/src/core/document.js b/src/core/document.js index 7e93ba73a8078..404b046da76f8 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -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. diff --git a/src/core/obj.js b/src/core/obj.js index 9139a7bf61040..3eb437fbeb188 100644 --- a/src/core/obj.js +++ b/src/core/obj.js @@ -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 diff --git a/src/core/worker.js b/src/core/worker.js index 2eb282c33d72f..d2c680c53bb36 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -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) { diff --git a/src/core/writer.js b/src/core/writer.js new file mode 100644 index 0000000000000..c24c203eef313 --- /dev/null +++ b/src/core/writer.js @@ -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 }; diff --git a/src/display/api.js b/src/display/api.js index b8d66dd95c421..c94bf977fe307 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -867,6 +867,16 @@ class PDFDocumentProxy { get loadingTask() { return this._transport.loadingTask; } + + /** + * @param {AnnotationStorage} annotationStorage - Storage for annotation + * data in forms. + * @returns {Promise} 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); } diff --git a/src/shared/util.js b/src/shared/util.js index 68a8921bc58ef..fade01b861f37 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -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, diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 5789a20d3c1f3..134b946c0bbf2 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -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 () { diff --git a/test/unit/clitests.json b/test/unit/clitests.json index 20f1ba9614b88..f68cc5a75915e 100644 --- a/test/unit/clitests.json +++ b/test/unit/clitests.json @@ -36,6 +36,7 @@ "type1_parser_spec.js", "ui_utils_spec.js", "unicode_spec.js", - "util_spec.js" + "util_spec.js", + "writer_spec.js" ] } diff --git a/test/unit/crypto_spec.js b/test/unit/crypto_spec.js index cf4533ab91384..8dcf575c606be 100644 --- a/test/unit/crypto_spec.js +++ b/test/unit/crypto_spec.js @@ -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(); + }); + }); }); diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index d8170434807cf..0693b72727c53 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -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); diff --git a/test/unit/test_utils.js b/test/unit/test_utils.js index 3b73f64fe9627..9171dd5c6afcf 100644 --- a/test/unit/test_utils.js +++ b/test/unit/test_utils.js @@ -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()]; } diff --git a/test/unit/util_spec.js b/test/unit/util_spec.js index 5b8346589d9d3..96f9772ba5943 100644 --- a/test/unit/util_spec.js +++ b/test/unit/util_spec.js @@ -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"); + }); + }); }); diff --git a/test/unit/writer_spec.js b/test/unit/writer_spec.js new file mode 100644 index 0000000000000..1491e53cd2f7a --- /dev/null +++ b/test/unit/writer_spec.js @@ -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(); + }); + }); +}); diff --git a/web/app.js b/web/app.js index b8b1a0a1e21de..d568e049bf3cf 100644 --- a/web/app.js +++ b/web/app.js @@ -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) {