From ba982ec8637bcdcf81af70fc18596f0fb2aea19f Mon Sep 17 00:00:00 2001 From: Pablo Perez De Angelis Date: Wed, 11 Nov 2026 12:44:17 -0300 Subject: [PATCH 1/3] Show a PDF's attached files in a Files tab and save them 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 #53. --- README.md | 1 + scripts/smoke-attachments.mjs | 71 ++++++++++++++++++++++++++ scripts/smoke-reader-tools.mjs | 2 + src/App.tsx | 15 ++++++ src/Sidebar.tsx | 54 ++++++++++++++++++-- src/attachments.test.ts | 93 ++++++++++++++++++++++++++++++++++ src/attachments.ts | 53 +++++++++++++++++++ src/styles.css | 49 ++++++++++++++++++ 8 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 scripts/smoke-attachments.mjs create mode 100644 src/attachments.test.ts create mode 100644 src/attachments.ts diff --git a/README.md b/README.md index dc78c2a..a28ce44 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ URL opening, webpage conversion, and Word conversion require the desktop app. Fa when you reopen a PDF, except for password-protected documents. - **Reorder pages** from either sidebar tab. Drag thumbnails or enter a new position, then save and open the reordered PDF copy. - **Export selected pages** from the Pages sidebar. Ctrl-click thumbnails (⌘-click on macOS) to select individual pages, or Shift-click for a range. Right-click a selected thumbnail to save those pages in document order as a new PDF. +- **Attached files:** when a PDF has files embedded in it, a **Files** tab lists them and saves each one as it was attached. - **Search as you type:** match case or whole words, see the current match and total count, browse per-page snippets and match counts, and move between matches with `Enter` / `Shift+Enter`. - **Flexible views:** fit to width, fit to page, automatic sizing, preset zoom levels, zoom buttons, `Ctrl+wheel` / diff --git a/scripts/smoke-attachments.mjs b/scripts/smoke-attachments.mjs new file mode 100644 index 0000000..425b04c --- /dev/null +++ b/scripts/smoke-attachments.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { PDFDocument, StandardFonts } from "pdf-lib"; + +// Every byte value, so a file that was decoded as text or cut short on its way to disk would differ. +const figures = Uint8Array.from({ length: 4096 }, (_, i) => (i * 7) % 256); +const notes = new TextEncoder().encode("Quarterly figures — see the spreadsheet.\n"); + +export async function attachmentsPdf() { + const pdf = await PDFDocument.create(); + const font = await pdf.embedFont(StandardFonts.Helvetica); + pdf.addPage([420, 300]).drawText("Report with attached files", { x: 32, y: 240, size: 20, font }); + await pdf.attach(figures, "figures.bin", { description: "Raw figures" }); + await pdf.attach(notes, "notes.txt"); + // No extension: the name offered when saving must not gain one guessed from the contents. + await pdf.attach(notes, "README"); + return pdf.save(); +} + +export async function checkAttachments(app, page, folder, shots) { + const open = (name, bytes) => app.evaluate(({ BrowserWindow }, { name, bytes }) => + BrowserWindow.getAllWindows()[0].webContents.send("folio:open-file", { name, data: Buffer.from(bytes) }), + { name, bytes: Array.from(bytes) }); + // Each document is closed while it is the selected one, so this works with or without a tab bar. + const close = async name => { + await page.keyboard.press("ControlOrMeta+w"); + await page.window.waitForFunction(name => !document.title.startsWith(name), name); + }; + const toggle = page.getByRole("button", { name: /Toggle sidebar/ }); + const filesTab = page.getByRole("tab", { name: /^Files/ }); + + const plain = await PDFDocument.create(); + plain.addPage([420, 300]); + await open("No files.pdf", await plain.save()); + await page.window.waitForFunction(() => document.title.startsWith("No files.pdf")); + if (await toggle.getAttribute("aria-pressed") !== "true") await toggle.click(); + await page.getByRole("tab", { name: "Pages", exact: true }).waitFor(); + // The attachment list is asked for before the thumbnail, so once the page is drawn the answer is in. + await page.waitForFunction(() => document.querySelector("aside.sidebar .thumb canvas")?.style.opacity === "1"); + assert.equal(await filesTab.count(), 0, "a PDF without attachments got a Files tab"); + await close("No files.pdf"); + + await open("Report.pdf", await attachmentsPdf()); + await page.window.waitForFunction(() => document.title.startsWith("Report.pdf")); + if (await toggle.getAttribute("aria-pressed") !== "true") await toggle.click(); + const sidebar = page.locator("aside.sidebar"); + await filesTab.waitFor(); + await sidebar.screenshot({ path: path.join(shots, "attachments-tab.png") }); + await filesTab.click(); + const list = sidebar.getByRole("list", { name: "Attached files" }); + assert.deepEqual(await list.locator(".attachment-name").allTextContents(), ["figures.bin", "notes.txt", "README"]); + assert.deepEqual(await list.locator(".attachment-description").allTextContents(), ["Raw figures"]); + await sidebar.screenshot({ path: path.join(shots, "attachments-list.png") }); + + for (const [name, expected] of [["figures.bin", figures], ["notes.txt", notes], ["README", notes]]) { + const file = path.join(folder, `saved-${name}`); + await app.evaluate(({ session }, file) => { + globalThis.folioAttachmentSaved = new Promise(resolve => session.defaultSession.once("will-download", (_event, item) => { + const suggested = item.getFilename(); + item.setSavePath(file); + item.once("done", (_done, state) => resolve({ state, suggested })); + })); + }, file); + await list.getByRole("button", { name: `Save ${name}`, exact: true }).click(); + assert.deepEqual(await app.evaluate(() => globalThis.folioAttachmentSaved), { state: "completed", suggested: name }); + assert.ok(Buffer.from(expected).equals(readFileSync(file)), `${name} was not saved byte for byte`); + } + await close("Report.pdf"); + console.log("ok Files lists a PDF's attached files and saves each one byte for byte; a PDF without any has no Files tab"); +} diff --git a/scripts/smoke-reader-tools.mjs b/scripts/smoke-reader-tools.mjs index 411e127..f1aab80 100644 --- a/scripts/smoke-reader-tools.mjs +++ b/scripts/smoke-reader-tools.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { checkImageInsertion } from "./smoke-image-insertion.mjs"; +import { checkAttachments } from "./smoke-attachments.mjs"; import { readFileSync } from "node:fs"; import path from "node:path"; import { deflateSync } from "node:zlib"; @@ -1063,5 +1064,6 @@ export async function checkReaderTools(app, page, folder, shots) { await checkArabicEditing(app, page, folder, shots); await checkLinkHistory(app, page, shots); await checkPageExport(app, page, folder, shots); + await checkAttachments(app, page, folder, shots); await checkPenDrawing(app, page, folder, shots); } diff --git a/src/App.tsx b/src/App.tsx index 56bfcb0..0f12d90 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { SearchResults } from "./SearchResults"; import { Icon, type IconName } from "./icons"; import { Welcome } from "./Welcome"; import { Sidebar, type SidebarTab } from "./Sidebar"; +import { attachmentBytes, type Attachment } from "./attachments"; import { MarkupController, type Mark, type MarkupState } from "./markup"; import { HighlightMenu, NoteBox, SelectionPopover, useTextSelection, type SelectionInfo } from "./Selection"; import { NotesPanel } from "./NotesPanel"; @@ -977,6 +978,19 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs } }; + const saveAttachment = async (file: Attachment) => { + const source = docRef.current; + if (!source) return; + try { + const bytes = await attachmentBytes(source.pdf, file.id); + if (docRef.current !== source) return; + // Without a type, Chromium guesses one from the bytes and adds its extension to a name that has none. + saveBlob(new Blob([bytes as Uint8Array], { type: "application/octet-stream" }), file.name); + } catch { + if (docRef.current === source) flash(`Couldn't save ${file.name}.`); + } + }; + const exportNotes = async (format: "markdown" | "json") => { if (!doc) return; const controller = markupRef.current!; @@ -1493,6 +1507,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs onOrder={order => setPageDraft(order ? { order } : null)} onSaveOrder={download} onExportPages={exportPages} + onSaveAttachment={saveAttachment} onOpenCopy={pageDraft?.copy ? () => { tabs.open([{ kind: "bytes", name: copyName, data: pageDraft.copy!, password: doc.password }]); } : undefined} diff --git a/src/Sidebar.tsx b/src/Sidebar.tsx index 8e41ed7..5025cc7 100644 --- a/src/Sidebar.tsx +++ b/src/Sidebar.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, typ import { createPortal } from "react-dom"; import { renderThumbnail, type OutlineItem, type PDFDocumentProxy } from "./pdf"; import { Icon } from "./icons"; +import { listAttachments, MAX_ATTACHMENTS, type Attachment } from "./attachments"; import { IS_MAC } from "./platform"; import { anchoredScrollTop, loadPageSizes, rowAt, thumbLayout, THUMB_ROW_EXTRA, type PageSize } from "./thumbnails"; @@ -17,6 +18,7 @@ interface Props { onOrder: (order: number[] | null) => void; onSaveOrder: () => Promise; onExportPages: (pages: number[]) => Promise; + onSaveAttachment: (file: Attachment) => Promise; onOpenCopy?: () => void; tab: SidebarTab; onTab: (tab: SidebarTab) => void; @@ -24,25 +26,47 @@ interface Props { noteCount: number; } -export type SidebarTab = "pages" | "outline" | "notes"; +export type SidebarTab = "pages" | "outline" | "notes" | "files"; export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, order, onOrder, onSaveOrder, onOpenCopy, onExportPages, - tab, onTab: setTab, notes, noteCount, bookmarkedPages, onToggleBookmark }: Props) { + onSaveAttachment, tab: chosenTab, onTab: setTab, notes, noteCount, bookmarkedPages, onToggleBookmark }: Props) { const [outline, setOutline] = useState(null); const [bookmarkedOnly, setBookmarkedOnly] = useState(false); + const [files, setFiles] = useState([]); + const [savingFile, setSavingFile] = useState(null); + useEffect(() => { setOutline(null); pdf.getOutline().then((o) => setOutline((o as OutlineItem[] | null) ?? [])); }, [pdf]); + useEffect(() => { + let current = true; + setFiles([]); + // A PDF whose attachment list can't be read is still a PDF to read: it just gets no Files tab. + listAttachments(pdf).then(found => { if (current) setFiles(found); }, () => {}); + return () => { current = false; }; + }, [pdf]); + + // Most PDFs carry no files, so the tab is there only for the ones that do. + const tab = chosenTab === "files" && !files.length ? "pages" : chosenTab; + const saveFile = async (file: Attachment) => { + setSavingFile(file.id); + try { + await onSaveAttachment(file); + } finally { + setSavingFile(null); + } + }; + if (order) return ( ); return (