From 1a6816ba98fd437024f6f0ddfa78e6d4dbf11371 Mon Sep 17 00:00:00 2001
From: Calixte Denizet <calixte.denizet@gmail.com>
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 715d8c98c..1da946338 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 12d2d2e92..b8f81a0c6 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 7e93ba73a..404b046da 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 9139a7bf6..3eb437fbe 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 2eb282c33..d2c680c53 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 000000000..c24c203ee
--- /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 b8d66dd95..c94bf977f 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<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);
   }
diff --git a/src/shared/util.js b/src/shared/util.js
index 68a8921bc..fade01b86 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 5789a20d3..134b946c0 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 20f1ba961..f68cc5a75 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 cf4533ab9..8dcf575c6 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 d81704348..0693b7272 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 3b73f64fe..9171dd5c6 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 5b8346589..96f9772ba 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 000000000..1491e53cd
--- /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 b8b1a0a1e..d568e049b 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) {