Skip to content

Show a PDF's attached files in a Files tab and save them - #92

Open
pablopda wants to merge 3 commits into
Forward-Future:mainfrom
pablopda:feat/attachments-list-save
Open

pablopda wants to merge 3 commits into
Forward-Future:mainfrom
pablopda:feat/attachments-list-save

Conversation

@pablopda

@pablopda pablopda commented Nov 11, 2026 •

Copy link
Copy Markdown

Problem

A PDF can carry files embedded in the document itself (a spreadsheet, a source file, a signed copy), but Folio gave no sign they were there and no way to get them out.

Change

When a PDF has files attached at the document level, the sidebar gets a Files tab next to Pages, Outline and Notes. It lists each file with its name and description, and the button on a row saves that file exactly as it was embedded. PDFs without attachments show no extra tab.

The names, descriptions and contents come from the PDF, so they are treated as untrusted:

  • A name is reduced to a plain file name before it is shown or offered for saving: no folders, no leading dot, no control, direction or invisible characters, no run of blanks that hides the ending, at most 255 characters.
  • At most 500 files are listed.
  • Size limit, enforced while reading. PDF.js can only unpack a whole attachment, so the contents are not read through it. pdf-lib locates the embedded stream, and the browser's DecompressionStream unpacks it in 16 KB input pieces; the read is cancelled once it passes 300 MB (the existing PDF size limit).
  • Off the main thread. Locating the stream means parsing the PDF with pdf-lib, so the save runs in a worker. While pdf-lib opens the file, what it may unpack is capped at 300 MB in total, the work is given up after 30 seconds, and closing the document cancels it.
  • Files that could run. Saving a file whose ending can run, install or mount something first asks, showing the whole file name, with Cancel focused. The check also covers endings hidden behind trailing dots, spaces, a colon, or letters that Windows folds to ASCII.
  • The file is saved as plain binary, so the offered name doesn't gain an extension guessed from the contents.

Files attached to a page as annotations, and reading attached PDFs in their own tabs, are left out, as agreed in the issue. The README's "Read and navigate" list has one new line.

What this does not save, and what it does not bound

Reading within a limit narrowed what can be saved. These are refused with a message, never saved wrong:

  • Attachments in a protected PDF, including one with only an owner password that opens without a prompt. pdf-lib can't decrypt them, and falling back to PDF.js would bring back the unbounded read.
  • Streams stored with anything other than a single Flate filter, with a predictor, or with a length that doesn't match the data. Across 84 test PDFs written by poppler, Ghostscript, MuPDF, pypdf and qpdf or built by hand (1,114 attachments), every attachment either saved byte for byte, compared with PDF.js, or was refused with a message: 80 in protected PDFs and 6 in hand-built malformed ones. None saved different bytes.

Three things are for your judgement:

  • pdf-lib's parsing of a hostile PDF is only partly bounded. The 300 MB cap covers packed data and the 30 second limit covers time, but a PDF can still make pdf-lib allocate a lot within those 30 seconds without unpacking anything: a 732-byte file whose cross-reference stream claims 9e15 entries took the app from about 670 MB to 2.8 GB before pdf-lib gave up after 16 seconds. The window stayed responsive (worst stall under 60 ms in every case measured), but I could not rule out that such a file crashes the window. The page-edit feature already runs the same PDFDocument.load in its worker, so this is not a new kind of exposure, but Save now reaches it too. Containing it fully would mean a separate process with a memory cap, or a purpose-built reader for the name tree; I can do either here or as a follow-up if you want it.
  • Which endings ask. The list has about 120 endings: programs, scripts, installers, shortcuts, disk images and macro-enabled Office files. It includes js, py and sh, so code attached to a paper will ask. It leaves out doc, xls, html and svg, so the question keeps its meaning.
  • Listing. The 500-file limit bounds what Folio keeps and draws. pdf.js still reads the whole attachment list in its worker when a document opens, so a PDF made to have very many or very long entries can still slow that document down before the limit applies.

Issue or approval

Refs #53. Scope approved in #53 (comment) ("lists attachments embedded at the document level and lets the reader save one. A small PDF fixture can verify that the saved bytes match the attachment."), and the two changes requested in review.

How I checked it

  • src/attachments.test.ts (15 tests) builds small PDFs with pdf-lib and checks, against real pdf.js where a listing is involved: the listed names and descriptions; that each file's bytes come back identical (a buffer with every byte value); the storage forms that save and the ones that are refused; a protected PDF; the name cleanup and which names ask.
  • The limits are tested with reduced limits: a 64 MB file packed into under 100 KB against a 4 KB limit (no more than four 16 KB pieces are handed to the unpacker), and an 8 MB packed object stream against a 1 MB cap (pdf-lib's unpackers are never let past 1 MB). Each of these tests was run with its fix removed and fails without it.
  • The smoke check (scripts/smoke-attachments.mjs, called from checkReaderTools) opens a PDF with a binary file, a text file, a file with no extension and Quarterly report.pdf.bat. It saves each through the Files tab and compares the saved files with the originals byte for byte and the offered names with the listed ones. For the .bat it checks the question shows that exact name, that Cancel saves nothing, and that Save does. It also checks that a PDF without attachments has no Files tab.
  • Measured in the hidden Electron app on Linux with hand-built hostile PDFs: a 400 MB attachment in a 408 KB PDF is refused in about 0.5 s; a 700 MB packed object stream in a 714 KB PDF is refused in about 1 s; a PDF built to keep pdf-lib busy is given up at 30 s. A 300,000-object PDF saves its attachment in about 1 s.
  • Windows, macOS, and Linux CI passed in my fork on commit dc8ad0e: type check, unit tests, build and the full smoke suite.
  • Locally on Linux (Ubuntu): npx tsc --noEmit, the attachment unit tests, and the attachments smoke check with a hidden window.

Not checked: Windows and macOS by hand (CI only); the native save dialog, which the smoke check bypasses by setting the save path; whether Windows or macOS mark the saved file as downloaded; PDFs written by Acrobat, Word, LibreOffice or pdfTeX.

Before and after, the same PDF with three attached files:

Before:

before

After:

after

Model and harness: Claude Fable 5.2 in Claude Code.

🤖 Generated with Claude Code

When a PDF has files embedded at the document level, the sidebar gets a
Files tab that lists them and saves each one exactly as it was attached.
PDFs without attachments show no extra tab. Names come from the PDF, so
they are reduced to a plain file name before being shown or saved.

Files attached to a page as annotations and reading attached PDFs in
their own tabs are left out, as agreed in Forward-Future#53.
@pablopda
pablopda marked this pull request as ready for review November 11, 2026 20:20

@mberman84 mberman84 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The Files tab covers the agreed first part of #53. On macOS the type check, production build, all 266 unit tests, and hidden smoke suite passed, including saving attachments byte for byte.

Please address the 300 MB limit before merging. Folio currently asks PDF.js for the complete attachment and checks the byte length after it returns. That keeps an oversized file off disk, but the reader has already had to allocate it. Please enforce a bound while reading the contents, or use an extraction path that can stop at the limit and keep the document usable. A small fixture with a reduced test limit is sufficient.

For the executable-file question in the description, I recommend an explicit confirmation that shows the full filename before handing an executable attachment to the save dialog. The automated desktop test verifies the suggested filename and downloaded bytes. It bypasses the native save dialog.

… one that could run

An attachment was unpacked whole by pdf.js and only then checked against the
300 MB limit. It is now read from the PDF's own bytes and unpacked a small
piece at a time, stopping once it passes the limit. Attachments stored in a
form that can't be read that way (a protected PDF, a filter other than a
single Flate, a predictor, a length that doesn't fit) are refused with a
message instead of being saved wrong.

Saving a file whose name could run when opened first shows its full name
and asks.
…ke Folio unpack or wait for

Finding an attached file means reading the whole PDF with pdf-lib, which
unpacks every packed list of objects in it. That now happens in a worker,
stops once 300 MB has been unpacked, and is given up after 30 seconds, so
a PDF built to be expensive no longer freezes the window on Save.

Also: an empty file, a single filter with empty settings, a name starting
with the UTF-8 mark and data followed by a counted line break now save;
the question before saving a file that could run always shows the whole
name and covers more endings; one file is saved at a time.
@pablopda

Copy link
Copy Markdown
Author

Both points are addressed in 7dd0c65 and dc8ad0e, and the description above is updated to match. CI passed on Windows, macOS and Linux in my fork for dc8ad0e.

Size limit. PDF.js has no way to stop partway through an attachment, so the contents are no longer read through it. pdf-lib locates the embedded stream and the browser's DecompressionStream unpacks it in 16 KB input pieces; the read is cancelled once it passes the limit. A test packs 64 MB into under 100 KB and checks, with a 4 KB limit, that no more than four pieces are handed to the unpacker. In the Electron app, a 400 MB attachment in a 408 KB PDF is refused in about half a second.

Executable attachments. Saving a file whose ending can run, install or mount something first asks, showing the whole file name, with Cancel focused. The smoke test attaches Quarterly report.pdf.bat, checks the question shows that name, that Cancel saves nothing, and that Save writes the right bytes.

Three things I'd like your call on:

  1. Parsing the PDF to find the attachment is only partly bounded. It runs in a worker, with a 300 MB cap on what pdf-lib may unpack and a 30 second limit, and the window stayed responsive in every case I measured. But a hostile PDF can still make pdf-lib allocate a lot in that time: a 732-byte file took the app from about 670 MB to 2.8 GB before pdf-lib gave up. Page editing already runs the same PDFDocument.load in its worker, so the exposure isn't new, but Save now reaches it. Closing it fully would take a separate process with a memory cap or a purpose-built reader for the name tree. I can do that here or as a follow-up, whichever you prefer.
  2. Protected PDFs. Attachments in an encrypted PDF are refused, including one with only an owner password that opens without a prompt, because pdf-lib can't decrypt them and PDF.js can't read them within a limit. If you'd rather those save through PDF.js without the bound, that is a small change.
  3. Which endings ask. The list includes js, py and sh, and leaves out doc, xls, html and svg. Happy to move either way.

This PR is larger than the first version (a worker file and more tests). If you'd prefer it split, the confirmation dialog separates cleanly from the read path.

@mberman84 mberman84 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for addressing both earlier review points. The save now stops decoding the attachment when it reaches the limit and asks before saving a runnable file. I verified that all three platforms passed in your fork at dc8ad0e.

I’d address the remaining memory issue in this PR. The save worker still parses the whole PDF before it can extract the attachment. The 300 MB check covers decompression buffers, and the timeout covers elapsed time. Neither bounds the parser’s total memory. Your measurement of roughly 2.8 GB makes that a blocker for an everyday Save action: a reader could lose unrelated unsaved work if the renderer runs out of memory.

A small first version can refuse a file it cannot read within a real memory bound. The protected-PDF refusal is reasonable. Please keep the current document-level scope and verify that the parent PDF and its edits stay usable when extraction is refused.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants