Skip to content

Conversation

@adnsistemas
Copy link

What?

Ability to make incremental updates to PDFs

Why?

Required for proper electronic signing of documents.

How?

Making an snapshot of the original PDF and marking changed Refs that need to be saved in the "increment"

Testing?

All tests and Node app.

New Dependencies?

No

Screenshots

N/A

Suggested Reading?

Yes

Anything Else?

Had to make a couple of small changes to package.json in order to be able to build, test, and commit the changes.

Checklist

  • I read CONTRIBUTING.md.
  • I read MAINTAINERSHIP.md#pull-requests.
  • I added/updated unit tests for my changes.
  • I added/updated integration tests for my changes.
  • I ran the integration tests.
  • I tested my changes in Node, Deno, and the browser.
  • I viewed documents produced with my changes in Adobe Acrobat, Foxit Reader, Firefox, and Chrome.
  • I added/updated doc comments for any new/modified public APIs.
  • My changes work for both new and existing PDF files.
  • I ran the linter on my changes.

@SamiSaves
Copy link

@Sharcoux Hey, is this PR in a clean enough state to be merged or worked on (the previous pr was #110) ? I am also interested in this as we need it for PAdes long term validation support.

I could probably try and help with this to make it merge ready

@benjinus
Copy link

benjinus commented Jul 1, 2025

I think this is a very important feature update, everyone needs this👏

@Sharcoux
Copy link
Collaborator

Sharcoux commented Jul 1, 2025

Ok, I'll try to have a look. It's quite complex. If one of you want to have a second look at the change, that would be great.

@SamiSaves
Copy link

SamiSaves commented Jul 2, 2025

I'll see if I have time to check the pr later this week and I also should be able to test it out and validate that it works for our scenario too 👍

@SamiSaves
Copy link

Here is some reading related to this PR:

PDF spec 7.5.6 Incremental Updates (pdf page 52)
https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=52

ETSI has a document "Part 1: Building blocks and PAdES baseline signatures" in which part 5.4 mentions incremental updates
https://www.etsi.org/deliver/etsi_en/319100_319199/31914201/01.02.01_60/en_31914201v010201p.pdf#page=10

@SamiSaves
Copy link

SamiSaves commented Jul 7, 2025

I was able to create an incrementally updated pdf with the branch:

async function addIncrementalUpdate(signedPdfBuffer: Buffer, update: string): Promise<Buffer> {
  // Load for incremental update
  const pdfDoc = await PDFDocument.load(signedPdfBuffer, {    forIncrementalUpdate: true  })

  // Add the update string as a text annotation to the first page to demonstrate any update
  const pages = pdfDoc.getPages()
  if (pages.length > 0) {
    pages[0].drawText(update, { x: 50, y: 700, size: 12 })
  }

  // Save incrementally (creates a new revision at the end)
  const newPdf = await pdfDoc.save({ useObjectStreams: false })
  return Buffer.from(newPdf)
}

I checked that increment didn't modify any of the old pdf bytes with this test:

const testPdf = await readFile(path.join(__dirname, './test.pdf'))
const incrementedPdf = addIncrementalUpdate(testPdf, 'testing incremental update')
assert(incrementalPdf.subarray(0, testPdf.length).equals(testPdf))

And I also observed from the pdf bytes that the new increment contains a new catalog, trailer and EOF.

@adnsistemas
Copy link
Author

I added some tests in the PR, unit and app tests. One tests that the original PDF is unchanged.
There is not one that checks the increment, maybe is worth adding it.

Copy link

@SamiSaves SamiSaves left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went through the PR and didn't find much to say as I am not familiar with the codebase.

To me it looks good, it doesn't seem to break existing functionality and this is confirmed by existing tests. It has test coverage for the new feature as well.

I think the only thing missing here is some documentation what the incremental update is, what does it do and how to use it.

Comment on lines 164 to 165
"prettier --check || (echo '\n⚠️ Linting issues detected. Please run \"yarn lint\" to fix them and stage the changes.' && exit 1)",
"eslint --fix || (echo '\n⚠️ Linting issues detected. Please run \"yarn lint\" to fix them and stage the changes.' && exit 1)"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably should revert these changes. I wonder what here caused issues 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to make changes in order to be able to run the tests. Don't know why, but in my environment the original failed with an error. Should be "reverted" to merge, but I can't do it in my branch.

Comment on lines +32 to +34
let tw = font.widthOfTextAtSize('Electronic Signature Test #20', tamLetra);
let lw = fontBold.widthOfTextAtSize(usarLibreOffice, tamAdvertencia);
if (lw > tw) tw = lw;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd personally prefer more descriptive names for variables. As I am new to this project and the world of pdfs, it takes quite a bit of effort to understand what these are for.

Comment on lines 715 to 718
expect(pdfIncrementalBytes.byteLength).toBeGreaterThan(0);
expect(finalPdfBytes.byteLength).toBeLessThanOrEqual(
pdfSaveBytes.byteLength,
);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are expecting same output in both cases, would it make sense to compare that the actual bytes are equal?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, then do it, no?

package.json Outdated
"description": "Create and modify PDF files with JavaScript",
"author": "Andrew Dillon <[email protected]>",
"packageManager": "[email protected]+",
"packageManager": "[email protected]",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original don't run in my environment. Should be equivalent, but in my case npm complains about it.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cantoo scribe master has

"packageManager": "[email protected]+",

This pr has

"packageManager": "[email protected]",

And your forks master has:

"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",

Have you tried with the cantoo master version? Perhaps the issue was the yarn with the full sha?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I try a lot of combinations that didn't work. But I'm not keen at all in configs.. so probably is something simple that I don't know about. Config and package changes shouldn't be necessaries, but I don't know how to exclude them from the PR.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you do somthing like this:

  1. Revert the changes (no commiting yet)
  2. Temporarily remove this from package.json
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  1. Stage and commit the reverted code (excluding the husky change above)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think is done..
Also added a fix in largestObjectNumber determination, when XREF is a Stream. For that I had to fix some PDFparser tests expectations, a couple of which I'm not totally sure about, but it seems that all the expectations in those tests are "taken" from the test result, and not from the PDF actual content, so I'm guessing is "all right".

package.json Outdated
"{src,tests,apps}/**/*.{ts,js,json,html,css}": [
"prettier --check || (echo '\n⚠️ Linting issues detected. Please run \"yarn lint\" to fix them and stage the changes.' && exit 1)",
"eslint --fix || (echo '\n⚠️ Linting issues detected. Please run \"yarn lint\" to fix them and stage the changes.' && exit 1)"
"prettier --check",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why those changes?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't run it without the change.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me, in your forks branch cantoo-scribe-master eslint gets stuck trying to lint /dist/pdf-lib.esm.min.js. So it seems to be trying to scan files should be ignored.

It seems to be due to this change: adnsistemas@417a8a6#diff-a32a0887ed9d1d707bbb3b845b7df7fd40e673c47e7b60a3ebd896b68d3b8839R10

Could this be the reason why it didn't work earlier with these changes?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It didn't got stuck for me, it always failed with the message after the ||
I have the same problem with both projects (the one I forked from, and this). I guess is the versions of node/npm/Linux I'm running in, but I really don't know (and to be honest I don't care much about it so I didn't dig into the actual problem).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when you run yarn lint?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In @cantoo/pdf-lib master branch (node 20.17.0, npm 10.8.2):
Invalid package manager specification in package.json ([email protected]+); expected a semver version
Then, changing that line to [email protected]:
yarn run v1.22.19 $ yarn lint:prettier && yarn lint:eslint $ prettier --write "./{src,tests,apps}/**/*.{ts,js,json,html,css}" --log-level error $ eslint .
And there it hungs, after running only yarn lint:eslint --verbose, and then again yarn lint it works.
Then I changed a file and staged it. Running yarn lint-staged gives:
`yarn run v1.22.19
$ lint-staged
✔ Backed up original state in git stash (d85c0fa)
⚠ Running tasks for staged files...
❯ package.json — 1 file
❯ {src,tests,apps}/**/*.{ts,js,json,html,css} — 1 file
✖ prettier --check || (echo '
⚠️ Linting issues detected. Please run "yarn lint" to fix them and stage the changes.' && exit 1) [FAILED]
◼ eslint --fix || (echo '
⚠️ Linting issues detected. Please run "yarn lint" to fix them and stage the changes.' && exit 1)
↓ Skipped because of errors from tasks.
✔ Reverting to original state because of errors...
✔ Cleaning up temporary files...

✖ prettier --check || (echo '
⚠️ Linting issues detected. Please run "yarn lint" to fix them and stage the changes.' && exit 1):
[error] No files matching the pattern were found: "||".
[error] No files matching the pattern were found: "(echo".
[error] No files matching the pattern were found: "
[error] ⚠️ Linting issues detected. Please run "yarn lint" to fix them and stage the changes.".
[error] No files matching the pattern were found: "&&".
[error] No files matching the pattern were found: "exit".
[error] No files matching the pattern were found: "1)".
Checking formatting...
All matched files use Prettier code style!
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.`

But yarn lint finds no problems.

import PDFContext from '../PDFContext';

class PDFObject {
registerChange() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing an error will probably break something PDFPageLeaf, will it not? Shouldn't we just do nothing?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every PDFObject should 'registerChange' (do noting is an option for specific descendants).
Otherwise you can't guarantee the increment will be correct.
If you just "forgot" to implement it, and the base implementation does nothing, will be difficult to trace why you have missing elements in the increment.

'Circular dependency: es/api/PDFPage.js -> es/api/PDFDocument.js -> es/api/form/PDFForm.js -> es/api/form/PDFOptionList.js -> es/api/PDFPage.js',
'Circular dependency: es/api/PDFPage.js -> es/api/PDFDocument.js -> es/api/form/PDFForm.js -> es/api/form/PDFRadioGroup.js -> es/api/PDFPage.js',
'Circular dependency: es/api/PDFPage.js -> es/api/PDFDocument.js -> es/api/form/PDFForm.js -> es/api/form/PDFTextField.js -> es/api/PDFPage.js',
'Circular dependency: es/core/index.js -> es/core/writers/PDFWriter.js -> es/api/snapshot/index.js -> es/api/snapshot/IncrementalDocumentSnapshot.js -> es/core/index.js',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IncrementalDocumentSnapshot should not depend on core/index.js.

'Circular dependency: es\\api\\PDFPage.js -> es\\api\\PDFDocument.js -> es\\api\\form\\PDFForm.js -> es\\api\\form\\PDFOptionList.js -> es\\api\\PDFPage.js',
'Circular dependency: es\\api\\PDFPage.js -> es\\api\\PDFDocument.js -> es\\api\\form\\PDFForm.js -> es\\api\\form\\PDFRadioGroup.js -> es\\api\\PDFPage.js',
'Circular dependency: es\\api\\PDFPage.js -> es\\api\\PDFDocument.js -> es\\api\\form\\PDFForm.js -> es\\api\\form\\PDFTextField.js -> es\\api\\PDFPage.js',
'Circular dependency: es\\core\\index.js -> es\\core\\writers\\PDFWriter.js -> es\\api\\snapshot\\index.js -> es\\api\\snapshot\\IncrementalDocumentSnapshot.js -> es\\core\\index.js',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same

@@ -0,0 +1,29 @@
import { PDFObject, PDFRef } from '../../core';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to import only the type? That might solve the cyclic dependency.

@@ -0,0 +1,14 @@
import { PDFObject, PDFRef } from '../../core';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to import only the type? That might solve the cyclic dependency.

@@ -0,0 +1,59 @@
import { PDFContext, PDFObject, PDFRef } from '../../core';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to import only the type? That might solve the cyclic dependency.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those imports are "inherited" from the project I took the incremental update base functionality. Not sure about the circular dependency issue, and solution.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this solution would work with DocumentSnapshot and DefaultDocumentSnapshot, in both all imports can be switched to import type I think. But in this file we have obj instanceof PDFRef ? obj : this.context.getObjectRef(obj) and that requires a normal import from core

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it help if we import PDFRef straight from the file itself like PDFWriter? 🤔

import PDFRef from '../../core/objects/PDFRef';

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe this.context.getObjectRef(obj) could handle the case of PDFRef and return the obj in that case?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented most of the suggested changes and all seems to be working as before, no more circular dependency message, and no more '!' after getObjectRef.
Had to add a method to context, to be able to import only the type for PDFRef.
Is there a way to update this PR with the changes?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To answer my own question: Yes, by pushing to the branch the PR comes from.
Updated.

@adnsistemas
Copy link
Author

I think the only thing missing here is some documentation what the incremental update is, what does it do and how to use it.
Didn't include it because makes the PR "messy". You can take the documentation from my project @adnsistemas/pdf-lib, the README.md has the documentation.

@@ -0,0 +1,59 @@
import { PDFContext, PDFObject, PDFRef } from '../../core';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe this.context.getObjectRef(obj) could handle the case of PDFRef and return the obj in that case?

markObjsForSave(objs: PDFObject[]): void {
this.markRefsForSave(
objs.map((obj) =>
obj instanceof PDFRef ? obj : this.context.getObjectRef(obj)!,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably not a good idea to assume this.context.getObjectRef(obj)!. It might be better to handle the case where it won't return what you expect. Does it make sense to accept filter the map? .filter((obj): result is PDFObject => !!obj)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really don't like that '!' there, but as I didn't wrote that, I supposed it was just for TS not complaining. I like the filter a lot more, and makes more sense: if you can't get a Ref, then there is nothing to update in the increment.

@SamiSaves
Copy link

I did another round of review for the PR. I did some tests and didn't immediately find any issues with the incremental update feature.

I also did a review with Copilot and it suggested few possible issues which I wasn't able to reproduce or didn't see them causing issues with validation. For example one of the issues was that if you set a large arbitrary number for /Size manually, the code would take largestObjectNumber from there. Copilot implied that this would cause performance issues, but that wasn't the case for me at least.

I also decided to check the binary files with VirusTotal and no issues found there.

I found that some of the tests might benefit from more strict data checks that we persist original data correctly. I left comment on the one I found, but I suspect some other tests might benefit from something like this as well.

@SamiSaves
Copy link

@Sharcoux Do you see any more blockers for merge? Or should we wait for another reviewer like @erano067?

@SamiSaves
Copy link

The task where we need the incremental update is coming up on my todo list, so I am planning on forking this fork with the this PR to our organization as an internal package to see if we can use it in production.

I can report how it's working once I have it up and running.

@Mikescops
Copy link

Hello, I'm also interested by this PR, for now I'm using a Go project (https://github.com/subnoto/pdfsign) that is able to do the incremental update (used then for electronic signature) bundled into wasm.

I will try to switch the logic with this PR and give some feedback if anything breaks. The ideal scenario is to take a document that is already electronically signed, make some incremental updates and see if the signature integrity is broken or not.

Also hope @Sharcoux can shine in at some point.

@Sharcoux
Copy link
Collaborator

Hello guys. Sorry, I got a lot on my plate recently, but I promise we didn't forget about you.

@Sharcoux
Copy link
Collaborator

@Mikescops @SamiSaves @adnsistemas I reviewed. I also would have the following comments:

  • Having a test checking that existing signatures are still valid would be interesting. Like:
const shouldNotCompress =
  ref === this.context.trailerInfo.Encrypt ||
  object instanceof PDFStream ||
  object instanceof PDFInvalidObject ||
  ref.generationNumber !== 0 ||
  (object instanceof PDFDict &&
    (object as PDFDict).lookup(PDFName.of('Type')) === PDFName.of('Sig'));
  • What happens for encrypted pdf and the /Encrypt dict when using the incremental update?

  • If an object from the original pdf is in an object stream and is being updated, what happens?

  • We should probably test those cases:

// 1. Array updates
describe('incremental update with array modifications', () => {
  it('tracks array changes', async () => {
    const pdfDoc = await PDFDocument.load(bytes);
    const snapshot = pdfDoc.takeSnapshot();
    const page = pdfDoc.getPage(0);
    const annots = page.node.get(PDFName.of('Annots'));
    annots.push(newAnnotRef); // ← Est-ce tracké?
    const increment = await pdfDoc.save();
    // Now check that the annotation is in the increment
  });
});

// 2. Multiple snapshots
describe('multiple snapshots', () => {
  it('handles multiple snapshots correctly', async () => {
    const snap1 = pdfDoc.takeSnapshot();
    const snap2 = pdfDoc.takeSnapshot();
    // Now check which snapshot is really being used
  });
});

// 3. Signature preservation
describe('signature validation', () => {
  it('preserves existing signatures', async () => {
    const signed = await PDFDocument.load(signedPdfBytes);
    const snapshot = signed.takeSnapshot();
    signed.getPage(0).drawText('Modified');
    const result = await signed.save();
    // Now check (at least once with Adobe/Foxit) that the signature is still valid
  });
});

// 4. Memory check
describe('memory handling', () => {
  it('handles 50MB+ PDFs', async () => {
    const largePdf = createLargePdf(50 * 1024 * 1024);
    const before = process.memoryUsage().heapUsed;
    const doc = await PDFDocument.load(largePdf);
    doc.takeSnapshot();
    const after = process.memoryUsage().heapUsed;
    expect(after - before).toBeLessThan(200 * 1024 * 1024);
  });
});

I'm also a bit worried with doc.save() that would return a completely different object if someone, somewhere, at any point, has called takeSnapshot() in the code. When the user was expecting a full pdf, they will suddenly receive an incremental pdf instead. That could come as a surprise. I'd recommand adding the option "incremental" to the SaveOptions instead.

}

set(key: PDFName, value: PDFObject): void {
this.registerChange();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we also intercept changes made to PDFArray, PDFStream, and others?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like:

  • PDFArray.push(item)
  • PDFArray.set(index, item)
  • PDFStream.updateContents(bytes)
  • PDFRawStream modifications

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have problems implementing these test cases. I don't know in what case you modify this kind of pdf objects that is not in the internals of the library.
As far as I can tell, all of this are handled internally when the "user" makes "visible" changes on the PDF.
If someone can provide complete examples of use cases where you manually modify any of these, I can test and add the change registration if required.
Thanks.

this.context.pdfFileDetails.prevStartXRef,
this.context,
);
if (!this.context.snapshot && this.context.pdfFileDetails.originalBytes) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With !this.context.snapshot, if takeSnapshot is being called twice, only the first one will be used

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can also result in a memory leak

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two ways of using incremental update. One is manual, calling takeSnapshot, the other is automatic, using the 'forIncrementalUpdate' on doc opening.
This is for the automatic path, and you only need one snapshot, taken on document opening.
If you take others snapshots you have to handle those snapshots yourself, those are not handled internally.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this a bit confusing for users?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe. Originally I though of hiding the takeSnapshot() stuff, and only be able to use incremental update by opening the document forIncrementalUpdate.
But there may be use cases (that I don't know of) where you only want the increment, and do as you please with it.
The "use path" of manually handling the snapshot does not preserves the pdf bytes. That's another reason for it existance.
My general way of thinking about this is: PDF is not ease as it seems, is impossible to make a powerful tool "bulletproof simple", so, if you are using a PDF library, you need some PDF knowledge, or you will fail no matter how simple it is.
I don't see it more complex that understanding that PDF coordinates are "reversed". Maybe better documentation is requiered, but I wonder "is there a 'incremental update functionality' user that doesn't knows why he/she needs it?"

pdfSize: number;
prevStartXRef: number;
useObjectStreams: boolean;
originalBytes?: Uint8Array;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure about this? This will keep a reference to the whole pdf bytes.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To have a valid incremental update you need to preserve the original PDF "as is".
This is only done when you open a PDF for incremental update (automatic incremental update handling).
What's the alternative?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should not accept FileHandle or Path, or provide a way for the user to keep the bytes on his side and provide them on save, but I'm not sure.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "problem" I see with that, is that you can't guarantee a valid output. Since you get the handle, or the path, until you use it to generate the original content, it may have changed.
Preserving the bytes you read as the original PDF is the only way to guarantee the output of save() is exactly what you read.
If I, as a user, want to handle the increment by myself, then I can take a snapshot, get the saveIncremental() and append it to whatever I want (that's why I haven't hidden that functionality, which was the original "incremental update" implementation).
The only reason I choose to preserve the bytes, as passed, is to make sure the final output is what you expect it to be.

@adnsistemas
Copy link
Author

I'm also a bit worried with doc.save() that would return a completely different object if someone, somewhere, at any point, has called takeSnapshot() in the code. When the user was expecting a full pdf, they will suddenly receive an incremental pdf instead. That could come as a surprise. I'd recommand adding the option "incremental" to the SaveOptions instead.

That's not the case. Calling takeSnapshot() doesn't changes what save() does. That's why there is a saveIncremental().
When you open a pdf not for incremental update (normal opening), save() returns a new PDF (a reasembled one), whether you use takeSnapshot() or not.
When you open a pdf for incremental update, save() returns the original PDF plus the increment, wheter you use takeSnapshot() or not.
Taking snapshots of a PDF document allows you to "build the increment", from the point where you take the snapshot, to the point where you use saveIncremental(snapshot). It doesn't (and shouldn't) interfere with save().
save() has the additional paramenter of 'rewrite', for the case when you open a PDF for incremental update and you want it reassembled, instead of the original plus the increment.
I'm adding some of your suggestions, and answering your comments as I do (just adding a test to verify that takeSnapshot() doesn't chage result of saving).

@adnsistemas
Copy link
Author

adnsistemas commented Nov 4, 2025

Having a test checking that existing signatures are still valid would be interesting.

This is absolutely out of scope of incremental update. Not changing the original PDF is what preserves signature validity, checking that by using incremental update, the first part of the resulting PDF is exactly as the original, guaranties signature validity preservation. That test already exists.

@Mikescops
Copy link

This is absolutely out of scope of incremental update. Not changing the original PDF is what preserves signature validity, checking that by using incremental update, the first part of the resulting PDF is exactly as the original, guaranties signature validity preservation. That test already exists.

Agree on that point, the signature validity is more of a use case rather than a test case. If the original part of the PDF is left untouched it shouldn't cause any issue with the signature.

@Sharcoux
Copy link
Collaborator

Sharcoux commented Nov 4, 2025

.
When you open a pdf for incremental update, save() returns the original PDF plus the increment, wheter you use takeSnapshot() or not.
Taking snapshots of a PDF document allows you to "build the increment", from the point where you take the snapshot, to the point where you use saveIncremental(snapshot). It doesn't (and shouldn't) interfere with save().
save() has the additional paramenter of 'rewrite', for the case when you open a PDF for incremental update and you want it reassembled, instead of the original plus the increment.

That's true, my bad, but still, having the result of save depend on the parameters load has been called with makes me feel uncomfortable because those 2 instructions might be quite distant from each other in the code...

@Sharcoux
Copy link
Collaborator

Sharcoux commented Nov 4, 2025

This is absolutely out of scope of incremental update. Not changing the original PDF is what preserves signature validity, checking that by using incremental update, the first part of the resulting PDF is exactly as the original, guaranties signature validity preservation. That test already exists.

I was worried of what the incremental update would do on a signed content, but apparently, the PDF spec seems to allow writing bytes after the /ByteRange. You can ignore that comment then.

@adnsistemas
Copy link
Author

.
When you open a pdf for incremental update, save() returns the original PDF plus the increment, wheter you use takeSnapshot() or not.
Taking snapshots of a PDF document allows you to "build the increment", from the point where you take the snapshot, to the point where you use saveIncremental(snapshot). It doesn't (and shouldn't) interfere with save().
save() has the additional paramenter of 'rewrite', for the case when you open a PDF for incremental update and you want it reassembled, instead of the original plus the increment.

That's true, my bad, but still, having the result of save depend on the parameters load has been called with makes me feel uncomfortable because those 2 instructions might be quite distant from each other in the code...

I don't know what you mean. save() always produces the whole PDF, the only "caveat" is that if it was open 'forIncrementalUpdate' the way of writing the PDF file is different, as the original is respected. But the output is an equivalent PDF to the save({rewrite:true}) one. Both resulting PDF documents are exactly the same, the file structure is the only difference.
What's the use case that makes you uncomfortable? That's what I don't understands (I think we are talking about different aspects of usage).

…e and incremental generation. Parameter adding for incremental update saving, to force useObjectStreams preference
@chr33s chr33s mentioned this pull request Nov 27, 2025
3 tasks
- Add commit() method to PDFDocument enabling chained incremental updates
- Fix font duplication: change 'modified' flag to 'alreadyEmbedded' in PDFFont
- Use PDFWriter instead of PDFStreamWriter for incremental saves
- Add comprehensive tests for commit() functionality (17 test cases)
@erano067
Copy link

Hey everyone 👋

Been using this incremental update feature in production for a signing workflow and it's been working great. Wanted to share some feedback and a small addition I made.


What I tested:

The byte preservation works - I verified this both with unit tests and manually opening the output in Chrome, Firefox, and Preview on Mac. PDFs with signature fields also work fine.

I added a couple tests to cover this explicitly:

  • preserves bytes exactly for signature validity - loops through and checks byte-by-byte
  • preserves signature field widgets after incremental updates - uses with_signature.pdf

One thing I added: commit() method

I needed to do multiple incremental saves in a row without reloading the PDF each time. The current flow requires you to reload after each saveIncremental() which is slow and loses your in-memory state.

So I added a commit() method that handles this:

const pdfDoc = await PDFDocument.load(bytes, { forIncrementalUpdate: true });

page.drawText('First edit');
await pdfDoc.commit();

page.drawText('Second edit');  
const finalPdf = await pdfDoc.commit();

It basically calls saveIncremental() internally, then updates originalBytes and prevStartXRef so you can keep going. Added 19 tests for it.

Also fixed a font duplication bug - fonts were getting re-embedded on each commit because of a modified flag that should've been alreadyEmbedded.


@Sharcoux - RE your earlier questions:

The change tracking via registerChange() seems to work for all the cases I tried (pages, fonts, images, text). The high-level API calls it correctly.

On the memory concern - yeah originalBytes does hold the full PDF, but that's required by the PDF spec for valid incremental updates. With commit() we replace it after each save so old allocations can be GC'd.


@adnsistemas - any chance you could pull in my changes? PR is here: adnsistemas#3

Would be nice to get this merged - seems like it's been ready for a while and a few people are waiting on it.

Branch: https://github.com/erano067/pdf-lib/tree/incremental-commit

@Sharcoux
Copy link
Collaborator

Great feedback. I'll try to merge your version.

Previously, inline objects (arrays/dicts embedded directly in other
objects, not registered as indirect objects) would not be tracked
for incremental saves. This caused changes to inline arrays like
MediaBox to be lost.

The fix enhances PDFContext.registerObjectChange() to:
1. First check if the object is directly registered (indirect object)
2. If not, search through all indirect objects to find which one
   contains the inline object, and mark that parent for save

Also fixed lint-staged config that had broken shell commands.

Added test: 'tracks inline array modifications for incremental saves'

All 673 tests pass.
@erano067
Copy link

Hey, I took a closer look at the change tracking for incremental updates and found a gap that I've now fixed.

When you modify an array or stream in a PDF, those changes need to be tracked so they're included in the incremental save. The original implementation tracked changes to PDFDict but missed PDFArray and PDFRawStream.

I added registerChange() calls to:

  • PDFArray — for push(), insert(), remove(), and set() operations
  • PDFRawStream — for updateContents()

But then I discovered a trickier issue: inline objects weren't being tracked at all.

For example, when you do page.node.lookup(PDFName.MediaBox) and modify that array directly, it wasn't getting saved. Why? Because inline arrays (ones embedded directly inside a dict rather than stored as separate indirect objects) don't have their own reference — so context.getRef(array) returns undefined and the change was silently ignored.

The fix: I enhanced registerObjectChange() to detect when an object isn't directly registered, and in that case, it searches through all indirect objects to find which one contains the modified inline object, then marks that parent for save.

Added tests for:

  • Array modifications (new arrays and push() on existing arrays)
  • Stream content updates
  • Inline array modifications (the MediaBox edge case)
  • Encrypted PDFs correctly throwing an error with forIncrementalUpdate

All 673 tests pass.

It is ready in this PR

@erano067
Copy link

erano067 commented Dec 5, 2025

Hey I synced with @adnsistemas. From what I see, his branch is production ready. What’s needed to get this branch merged?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants