From 21396c0079e8c58f9fecdba9fb7cc93c8bf14ad1 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 28 Jul 2020 19:49:25 +0200 Subject: [PATCH] Add support for saving forms --- l10n/en-US/viewer.properties | 2 + src/core/annotation.js | 87 +++++++++++++ src/core/crypto.js | 7 ++ src/core/document.js | 40 ++++++ src/core/obj.js | 12 ++ src/core/worker.js | 55 +++++++++ src/core/writer.js | 175 +++++++++++++++++++++++++++ src/display/api.js | 33 +++++ web/app.js | 56 +++++++++ web/images/toolbarButton-save.png | Bin 0 -> 709 bytes web/images/toolbarButton-save@2x.png | Bin 0 -> 1501 bytes web/pdf_save_service.js | 97 +++++++++++++++ web/toolbar.js | 2 + web/viewer.css | 10 ++ web/viewer.html | 41 ++++--- web/viewer.js | 11 +- 16 files changed, 611 insertions(+), 17 deletions(-) create mode 100644 src/core/writer.js create mode 100644 web/images/toolbarButton-save.png create mode 100644 web/images/toolbarButton-save@2x.png create mode 100644 web/pdf_save_service.js diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index 6f7598e3dac62f..1753881df6ac8b 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -41,6 +41,8 @@ print.title=Print print_label=Print download.title=Download download_label=Download +save.title=Save +save_label=Save bookmark.title=Current view (copy or open in new window) bookmark_label=Current View diff --git a/src/core/annotation.js b/src/core/annotation.js index beb7887c34ddbe..afecdb8d2e514f 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -34,6 +34,7 @@ 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 +69,7 @@ class AnnotationFactory { if (!isDict(dict)) { return undefined; } + const id = isRef(ref) ? ref.toString() : `annot_${idFactory.createObjId()}`; // Determine the annotation's subtype. @@ -77,6 +79,7 @@ class AnnotationFactory { // Return the right annotation object based on the subtype and field type. const parameters = { xref, + ref, dict, subtype, id, @@ -792,6 +795,8 @@ class WidgetAnnotation extends Annotation { const dict = params.dict; const data = this.data; + this.dict = dict; + this.ref = params.ref; data.annotationType = AnnotationType.WIDGET; data.fieldName = this._constructFieldName(dict); @@ -954,6 +959,70 @@ 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) { + 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(null); + AP.set("N", newRef); + + let annotationString = annotationStorage[this.data.id]; + const encrypt = evaluator.xref.encrypt; + if (encrypt) { + const transf = encrypt.createCipherTransform( + this.ref.num, + this.ref.gen + ); + annotationString = transf.encryptString(annotationString); + const da = this.dict.get("DA") || null; + if (da !== null) { + this.dict.set("DA", transf.encryptString(da)); + } + appearance = encrypt + .createCipherTransform(newRef.num, newRef.gen) + .encryptString(appearance); + } + + this.dict.set("V", annotationString); + this.dict.set("AP", AP); + + const appearanceDict = new Dict(null); + appearanceDict.set("Length", appearance.length); + appearanceDict.set("Subtype", { name: "Form" }); + appearanceDict.set("Resources", this.fieldResources); + appearanceDict.set("BBox", bbox); + + let bufferOriginal = `${this.ref.num} 0 obj\n`; + bufferOriginal = writeDict(this.dict, bufferOriginal); + bufferOriginal += "\nendobj\n"; + + let bufferNew = `${newRef.num} ${newRef.gen} obj\n`; + bufferNew = writeDict(appearanceDict, bufferNew); + bufferNew += ` stream\n${appearance}`; + bufferNew += "\nendstream\nendobj\n"; + + return [ + { ref: this.ref.num, data: bufferOriginal }, + { ref: newRef.num, data: bufferNew }, + ]; + } + return null; + } + async getAppearance(evaluator, task, annotationStorage) { // If it's a password textfield then no rendering to avoid to leak it. // see 12.7.4.3, table 228 @@ -1288,6 +1357,24 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { ); } + async save(evaluator, task, annotationStorage) { + const defaultValue = this.data.fieldValue && this.data.fieldValue !== "Off"; + const isChecked = annotationStorage[this.data.id]; + if (defaultValue === isChecked) { + return null; + } + + const value = isChecked ? this.data.exportValue : "Off"; + this.dict.set("V", { name: value }); + this.dict.set("AS", { name: value }); + + let buffer = `${this.ref.num} 0 obj\n`; + buffer = writeDict(this.dict, buffer); + buffer += "\nendobj\n"; + + return [{ ref: this.ref.num, data: buffer }]; + } + _processCheckBox(params) { if (isName(this.data.fieldValue)) { this.data.fieldValue = this.data.fieldValue.name; diff --git a/src/core/crypto.js b/src/core/crypto.js index 12d2d2e92eb53f..f0f95b20efec98 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; })(); @@ -1474,6 +1475,12 @@ var CipherTransform = (function CipherTransformClosure() { data = cipher.decryptBlock(data, true); return bytesToString(data); }, + encryptString: function CipherTransform_encryptString(s) { + var cipher = new this.StringCipherConstructor(); + var 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 7e93ba73a8078c..1f66922b1a94c9 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -227,6 +227,46 @@ 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 add their operator lists to the + // page's operator list to render them. + return this._parsedAnnotations.then(function (annotations) { + if (annotations.length === 0) { + return null; + } + + const newRefsPromises = []; + for (const annotation of annotations) { + if (isAnnotationRenderable(annotation, "print")) { + 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 fd1986e9d07713..bc270e6783caae 100644 --- a/src/core/obj.js +++ b/src/core/obj.js @@ -1135,9 +1135,21 @@ var XRef = (function XRefClosure() { streamTypes: Object.create(null), fontTypes: Object.create(null), }; + this.newRef = null; } XRef.prototype = { + getNewRef: function XRef_getNewRef() { + if (this.newRef === null) { + this.newRef = this.entries.length; + } + return Ref.get(this.newRef++, 0); + }, + + resetNewRef: function XRef_resetNewRef() { + this.newRef = 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 6f90cee8ee8586..24dcd216730204 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, @@ -35,6 +37,7 @@ import { LocalPdfManager, NetworkPdfManager } from "./pdf_manager.js"; import { isNodeJS } from "../shared/is_node.js"; import { MessageHandler } from "../shared/message_handler.js"; import { PDFWorkerStream } from "./worker_stream.js"; +import { writeXRef } from "./writer.js"; import { XRefParseException } from "./core_utils.js"; class WorkerTask { @@ -513,6 +516,58 @@ class WorkerMessageHandler { }); }); + handler.on("SavePage", function (data) { + var pageIndex = data.pageIndex; + return pdfManager.getPage(pageIndex).then(function (page) { + const task = new WorkerTask(`Save: page ${pageIndex}`); + return page.save(handler, task, data.annotationStorage); + }); + }); + + handler.on("SaveDocument", function (data) { + pdfManager.requestLoadedStream(); + + const newRefs = data.newRefs; + if (newRefs.length === 0) { + // No new refs so just return the initial bytes + return pdfManager.onLoadedStream().then(function (stream) { + return stream.bytes; + }); + } + + const xref = pdfManager.pdfDocument.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(value)) { + _info[key] = + typeof value !== "string" ? value : 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: pdfManager.pdfDocument.startXRef, + filename: data.filename, + }; + } + xref.resetNewRef(); + + return pdfManager.onLoadedStream().then(function (stream) { + return writeXRef(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 00000000000000..a0e6325b968f3e --- /dev/null +++ b/src/core/writer.js @@ -0,0 +1,175 @@ +/* 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, stringToBytes } from "../shared/util.js"; +import { Dict, isDict } from "./primitives.js"; +import { calculateMD5 } from "./crypto.js"; + +function writeDict(dict, buffer) { + buffer += "<<"; + for (const key of dict.getKeys()) { + buffer += ` /${key} `; + buffer = writeValue(dict.getRaw(key), buffer); + } + buffer += ">>"; + return buffer; +} + +function writeArray(array, buffer) { + buffer += "["; + let first = true; + for (const val of array) { + if (!first) { + buffer += " "; + } else { + first = false; + } + buffer = writeValue(val, buffer); + } + buffer += "]"; + return buffer; +} + +function writeValue(value, buffer) { + if (value.hasOwnProperty("name")) { + buffer += `/${value.name}`; + } else if (value.hasOwnProperty("num")) { + buffer += `${value.num} ${value.gen} R`; + } else if (Array.isArray(value)) { + buffer = writeArray(value, buffer); + } else if (typeof value === "string" || value instanceof String) { + buffer += `(${escapeString(value)})`; + } else if (Number.isInteger(value)) { + buffer += `${value}`; + } else if (typeof value === "number") { + buffer += `${value.toFixed(2)}`; + } else if (isDict(value)) { + buffer = writeDict(value, buffer); + } + + return buffer; +} + +function writeInt(number, size, offset, buffer) { + for (let i = size; i > 0; i--) { + buffer[i - 1 + offset] = 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 || ""; + let md5Buffer = `${time}${filename}${filesize}`; + for (const [, value] of Object.entries(xrefInfo.info)) { + md5Buffer += value; + } + return bytesToString( + calculateMD5(stringToBytes(md5Buffer, 0, md5Buffer.length)) + ); +} + +function writeXRef(originalData, xrefInfo, newRefs) { + const newXref = new Dict(null); + let buffer = ""; + const refXref = xrefInfo.newRef; + + newXref.set("Size", refXref.num + 1); + newXref.set("Prev", xrefInfo.startXRef); + newXref.set("Type", { name: "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 + newRefs.push({ ref: refXref.num, data: "" }); + newRefs = newRefs.sort((a, b) => { + // compare the refs + return a.ref - b.ref; + }); + + const xrefTableData = [[0, 1, 0xffff]]; + const indexes = [0, 1]; + let maxOffset = 0; + let baseOffset = originalData.length; + for (const { ref, data } of newRefs) { + maxOffset = Math.max(maxOffset, baseOffset); + xrefTableData.push([1, baseOffset, 0]); + baseOffset += data.length; + indexes.push(ref); + indexes.push(1); + buffer += data; + } + + newXref.set("Index", indexes); + + if (xrefInfo.fileIds.length !== 0) { + const md5 = computeMD5(originalData.length + buffer.length, xrefInfo); + newXref.set("ID", [xrefInfo.fileIds[0], md5]); + } + + const offsetSize = Math.ceil(Math.log2(maxOffset) / 8); + const sizes = [1, offsetSize, 1]; + const structSize = sizes[0] + sizes[1] + sizes[2]; + const tableLength = structSize * xrefTableData.length; + newXref.set("W", sizes); + newXref.set("Length", tableLength); + + buffer += `${refXref.num} 0 obj\n`; + buffer = writeDict(newXref, buffer); + buffer += " stream\n"; + + const footer = `\nendstream\nendobj\nstartxref\n${baseOffset}\n%%EOF`; + const array = new Uint8Array( + originalData.length + buffer.length + tableLength + footer.length + ); + + // Original data + array.set(originalData); + let offset = originalData.length; + + // New data + writeString(buffer, offset, array); + offset += buffer.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, writeXRef }; diff --git a/src/display/api.js b/src/display/api.js index b1fb4ba6128075..c79ec1da897f47 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -798,6 +798,16 @@ class PDFDocumentProxy { get loadingTask() { return this._transport.loadingTask; } + + /** + * @param {Array} newRefs - The new refs and their data to append + * to the PDF bytes. + * @returns {Promise} A promise that is resolved with a {Uint8Array} + * object. + */ + saveDocument(newRefs) { + return this._transport.saveDocument(newRefs); + } } /** @@ -997,6 +1007,15 @@ class PDFPageProxy { return this.annotationsPromise; } + /** + * @param {SaveParameters} params - Save parameters. + * @returns {Promise} A promise that is resolved with an {Array} of the + * new references objects. + */ + savePage({ annotationStorage = null } = {}) { + return this._transport.savePage(this._pageIndex, annotationStorage); + } + /** * Begins the process of rendering a page to the desired context. * @param {RenderParameters} params Page render parameters. @@ -2413,6 +2432,20 @@ class WorkerTransport { }); } + savePage(pageIndex, annotationStorage) { + return this.messageHandler.sendWithPromise("SavePage", { + pageIndex, + annotationStorage, + }); + } + + saveDocument(newRefs) { + return this.messageHandler.sendWithPromise("SaveDocument", { + newRefs, + filename: this._fullReader.filename, + }); + } + getDestinations() { return this.messageHandler.sendWithPromise("GetDestinations", null); } diff --git a/web/app.js b/web/app.js index 774fbf7d05ea23..b689994dbe0f66 100644 --- a/web/app.js +++ b/web/app.js @@ -245,6 +245,11 @@ const PDFViewerApplication = { await this._parseHashParameters(); await this._initializeL10n(); + if (AppOptions.get("renderInteractiveForms")) { + appConfig.toolbar.save.hidden = false; + appConfig.secondaryToolbar.saveButton.hidden = false; + } + if ( this.isViewerEmbedded && AppOptions.get("externalLinkTarget") === LinkTarget.NONE @@ -909,6 +914,42 @@ const PDFViewerApplication = { .catch(downloadByUrl); // Error occurred, try downloading with the URL. }, + save() { + 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}`); + }; + + // If the pdf is not ready then save is equivalent to download + if (!this.pdfDocument || !this.downloadComplete) { + this.download(); + return; + } + + const pagesOverview = this.pdfViewer.getPagesOverview(); + const saveService = PDFSaveServiceFactory.instance.createSaveService( + this.pdfDocument, + pagesOverview + ); + + saveService + .saveDocument() + .then(data => { + const blob = new Blob([data], { type: "application/pdf" }); + downloadManager.download(blob, url, filename); + }) + .finally(() => { + saveService.destroy(); + }); + }, + /** * 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. @@ -1676,6 +1717,7 @@ const PDFViewerApplication = { eventBus._on("presentationmode", webViewerPresentationMode); eventBus._on("print", webViewerPrint); eventBus._on("download", webViewerDownload); + eventBus._on("save", webViewerSave); eventBus._on("firstpage", webViewerFirstPage); eventBus._on("lastpage", webViewerLastPage); eventBus._on("nextpage", webViewerNextPage); @@ -1751,6 +1793,7 @@ const PDFViewerApplication = { eventBus._off("presentationmode", webViewerPresentationMode); eventBus._off("print", webViewerPrint); eventBus._off("download", webViewerDownload); + eventBus._off("save", webViewerSave); eventBus._off("firstpage", webViewerFirstPage); eventBus._off("lastpage", webViewerLastPage); eventBus._off("nextpage", webViewerNextPage); @@ -2273,6 +2316,9 @@ function webViewerPrint() { function webViewerDownload() { PDFViewerApplication.download(); } +function webViewerSave() { + PDFViewerApplication.save(); +} function webViewerFirstPage() { if (PDFViewerApplication.pdfDocument) { PDFViewerApplication.page = 1; @@ -2888,8 +2934,18 @@ const PDFPrintServiceFactory = { }, }; +/* Abstract factory for the save service. */ +const PDFSaveServiceFactory = { + instance: { + createSaveService() { + throw new Error("Not implemented: createSaveService"); + }, + }, +}; + export { PDFViewerApplication, DefaultExternalServices, PDFPrintServiceFactory, + PDFSaveServiceFactory, }; diff --git a/web/images/toolbarButton-save.png b/web/images/toolbarButton-save.png new file mode 100644 index 0000000000000000000000000000000000000000..ad258392f4a9a7edc8e37ded915df795dc4502c0 GIT binary patch literal 709 zcmV;$0y_PPP)B~`U5b!m_x%OkbkT*2=*lRxyt1gwe$ehT$_flBbaoPK_+xI%{W|Z?>)XX#ES&bh zbMf;1en0R$&nuurT_0)bXlvcN%uJyH0EonY&V8<$x*1%YE9L)NeSN1ZCdO(f_4%@7 zaqqk)Q8`}& z&#Z|2#e=GEqoeK4eSk-|J+BO1$cGPULl^ke9rw#ZjsQULhHv29nT>0jVoHb32!Iek zapvk8>*|-Ezm0zp0l@1{4{J`-`oD0YRK>LV>Qlo2AO->f0uHa;!2keH-?$G97PN9Z z1LuMlfFL>??=3djw1DSLEzOAo0Vj{|X&kWGOu470NfyV)^{hdq?My}lB28!zJO={2 z@Bon}q*5Y`oD`jzSvDj9$fU@V?QL?RwsHdikTnQ4qcU6?MLJ6mjalLKq*12i^;nc3 znI?~cy(?^{+uj}XB?jy2;||GgfDn#YLdNPZJ7`XXOQleqIlyATUpJahuURlDuGF{;(INw?l7r_+7S)v$4tsvV*);Islfsr(lnb}+E-V3nP(0&Q z(0#tzm7QpA{H-Y}gS7W6OcO}N3BHD%v1rUTaOrAfwvhP;006zco!a2V@E9TNtXe@U z03g02?=U93zW1zoWO_OjFZS;P6l>kx9U1`4&d$a1$17nfe)#?Vazcnzrc|Ud6|JmH r004tzxJ>0;D$Bc!F(VWTr3%15FZ~%MhFa1w00000NkvXXu0mjf%nm{y literal 0 HcmV?d00001 diff --git a/web/images/toolbarButton-save@2x.png b/web/images/toolbarButton-save@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..70cbdc1c444eb7ff6022cbdeffba95f58b3a057a GIT binary patch literal 1501 zcmV<31tR*1P))+rbG7*!kYH=M_cyY|Toq zV6~{%43raKX8^DnRR91j03ZOw&H`G0oEi)<*!8npw3tZM+Se}Z8n=KqU(R2dof+P} zZgm$&sL>>j54r~Itj?9M^qHR8_F?-kBkc*{kM6nKOrTa*K7H%S0f11zi>ohkibi(p z9pJ|l(0p8Ietemir|DZyJ`9=$Rxb6LKK*X${+kMTsj_fIMq0G6U{3h}d+p zQe(MG7B0+75RjcFm&~3TFf-g8%MXSH=1lXOGczI&CJ1n)4?bWPRNRU34}%cE%&SPB zGPoc?0LN;1DHbKcY5kVD*zi^n!VzXd$<}X~i;YSAQY;DtPKzHw+-l-glZj6h@Z|DK z$jgqxnnc{q@I-|dx$a1UpTMi&O`0h|Ky;892!>ebx_rYiEGoG=p~S;jT-uwkrt5$| z#6pbH1H)_aBM>F3GaO~0$ir*`gdE1uCvh=!0)*3yG32krfUw9QJjiqm)8Z94+huL& z_nS7$Qh=~L0PjqoyTCB!oC4hK6BBTG7SNS%%nKwRB`I`mDZcgN%U0xTT1*w_NnyMP#L%w65kbwC;f+Robol4?3KOp71E z-sWZ>faczQ6T~n8J__Wv7=w?-h+96K?kl4G??FFe-C@n+)@8HrWPz38}L#Sb-E2!@OuS_Bo- zbHiBu=%u8^P*p$-1A7|X(vkL*wb$DcZrA7U=6sUj5I2;~?}goIJg1{U6b*%fFnase zHNf8oG#qlFFJRhH^+xxm>+K19tf>q9c3<&b&t^KJ4vta4@uQ1iSsg5^kD6Rlf#3k( z{ZGKr)9L3nZoN=3l9M#_zek)^maOwPl*}i9-R>k8FGnsa)(P{i)Hs(l?dwfB@zU7RgdS89h;qk`2mDitCl$P3o zcoUmkkkJetkB5;YiM4AQD+&T-Q52$BR3b?{C8#QqIho>BfdGI+i#jKBnn=?+&r2E; zq4fyE=z=I}eu|3mJRfUpY)tx|C;iLtcswQnn8H|SPLWxf)ilTy1&9<_Xix|N72^?u zaSteshe7~W)1ZkgWXhN;JP!+}sjLDSfOoQ0x}*3X#u_CN--p5@00000NkvXXu0mjf DO0CFe literal 0 HcmV?d00001 diff --git a/web/pdf_save_service.js b/web/pdf_save_service.js new file mode 100644 index 00000000000000..ee0cba2696b360 --- /dev/null +++ b/web/pdf_save_service.js @@ -0,0 +1,97 @@ +/* Copyright 2016 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 { PDFSaveServiceFactory } from "./app.js"; + +let activeSaveService = null; + +// Renders the page to the canvas of the given print service, and returns +// the suggested dimensions of the output page. +function savePage(pdfDocument, pageNumber) { + return pdfDocument.getPage(pageNumber).then(function (pdfPage) { + const params = { + annotationStorage: pdfDocument.annotationStorage.getAll(), + }; + return pdfPage.savePage(params); + }); +} + +function PDFSaveService(pdfDocument, pagesOverview) { + this.pdfDocument = pdfDocument; + this.pagesOverview = pagesOverview; + this.currentPage = -1; + this.newRefs = []; +} + +PDFSaveService.prototype = { + savePages() { + const pageCount = this.pagesOverview.length; + const saveNextPage = (resolve, reject) => { + this.throwIfInactive(); + if (++this.currentPage >= pageCount) { + resolve(); + return; + } + const index = this.currentPage; + savePage(this.pdfDocument, index + 1).then(data => { + for (const newRef of data) { + if (newRef !== null) { + this.newRefs = this.newRefs.concat(newRef); + } + } + saveNextPage(resolve, reject); + }, reject); + }; + return new Promise(saveNextPage); + }, + + saveDocument() { + return this.savePages().then(() => { + return this.pdfDocument.saveDocument(this.newRefs); + }); + }, + + get active() { + return this === activeSaveService; + }, + + destroy() { + if (activeSaveService !== this) { + // |activeService| cannot be replaced without calling destroy() first, + // so if it differs then an external consumer has a stale reference to + // us. + return; + } + activeSaveService = null; + }, + + throwIfInactive() { + if (!this.active) { + throw new Error("This save request was cancelled or completed."); + } + }, +}; + +PDFSaveServiceFactory.instance = { + createSaveService(pdfDocument, pagesOverview) { + if (activeSaveService) { + throw new Error("The save service is created and active."); + } + activeSaveService = new PDFSaveService(pdfDocument, pagesOverview); + return activeSaveService; + }, +}; + +export { PDFSaveService }; diff --git a/web/toolbar.js b/web/toolbar.js index 77b729f3570dbe..0c7e3770c16198 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -48,6 +48,7 @@ const SCALE_SELECT_WIDTH = 162; // px * @property {HTMLButtonElement} presentationModeButton - Button to switch to * presentation mode. * @property {HTMLButtonElement} download - Button to download the document. + * @property {HTMLButtonElement} save - Button to save the document. * @property {HTMLAElement} viewBookmark - Element to link current url of * the page view. */ @@ -74,6 +75,7 @@ class Toolbar { eventName: "presentationmode", }, { element: options.download, eventName: "download" }, + { element: options.save, eventName: "save" }, { element: options.viewBookmark, eventName: null }, ]; this.items = { diff --git a/web/viewer.css b/web/viewer.css index d49e0f6f5871bb..98ebbbaad6a0b4 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -848,6 +848,11 @@ html[dir='rtl'] .toolbarButton.pageDown::before { content: url(images/toolbarButton-download.png); } +.toolbarButton.save::before, +.secondaryToolbarButton.save::before { + content: url(images/toolbarButton-save.png); +} + .toolbarButton.bookmark, .secondaryToolbarButton.bookmark { box-sizing: border-box; @@ -1669,6 +1674,11 @@ html[dir='rtl'] #documentPropertiesOverlay .row > * { content: url(images/toolbarButton-download@2x.png); } + .toolbarButton.save::before, + .secondaryToolbarButton.save::before { + content: url(images/toolbarButton-download@2x.png); + } + .toolbarButton.bookmark::before, .secondaryToolbarButton.bookmark::before { content: url(images/toolbarButton-bookmark@2x.png); diff --git a/web/viewer.html b/web/viewer.html index 75beacd30b87dc..1f18ecbb9e1f5c 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -149,64 +149,68 @@ Download - + + + Current View
- -
- -
- -
- - -
- - -
- @@ -251,13 +255,18 @@ - + + + + Current View
- diff --git a/web/viewer.js b/web/viewer.js index f51cdd44e1d7dc..4b50a7c6b11d5a 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -77,6 +77,7 @@ function getViewerConfiguration() { print: document.getElementById("print"), presentationModeButton: document.getElementById("presentationMode"), download: document.getElementById("download"), + save: document.getElementById("save"), viewBookmark: document.getElementById("viewBookmark"), }, secondaryToolbar: { @@ -91,6 +92,7 @@ function getViewerConfiguration() { openFileButton: document.getElementById("secondaryOpenFile"), printButton: document.getElementById("secondaryPrint"), downloadButton: document.getElementById("secondaryDownload"), + saveButton: document.getElementById("secondarySave"), viewBookmarkButton: document.getElementById("secondaryViewBookmark"), firstPageButton: document.getElementById("firstPage"), lastPageButton: document.getElementById("lastPage"), @@ -193,7 +195,14 @@ function webViewerLoad() { import("pdfjs-web/app_options.js"), import("pdfjs-web/genericcom.js"), import("pdfjs-web/pdf_print_service.js"), - ]).then(function ([app, appOptions, genericCom, pdfPrintService]) { + import("pdfjs-web/pdf_save_service.js"), + ]).then(function ([ + app, + appOptions, + genericCom, + pdfPrintService, + pdfSaveService, + ]) { window.PDFViewerApplication = app.PDFViewerApplication; window.PDFViewerApplicationOptions = appOptions.AppOptions; app.PDFViewerApplication.run(config);