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..6366619 --- /dev/null +++ b/scripts/smoke-attachments.mjs @@ -0,0 +1,96 @@ +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"); + // A script: Folio shows its full name and asks before saving it. + await pdf.attach(notes, "Quarterly report.pdf.bat"); + 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", "Quarterly report.pdf.bat"]); + assert.deepEqual(await list.locator(".attachment-description").allTextContents(), ["Raw figures"]); + await sidebar.screenshot({ path: path.join(shots, "attachments-list.png") }); + + const script = "Quarterly report.pdf.bat"; + const question = page.getByRole("dialog", { name: "Save this attached file?" }); + // A save that never starts would otherwise leave the run waiting for good. + const within = (promise, what) => Promise.race([promise, new Promise((_resolve, reject) => + setTimeout(() => reject(new Error(`${what} didn't happen within 30 seconds`)), 30_000))]); + await app.evaluate(({ session }) => { + globalThis.folioAttachmentStray = 0; + globalThis.folioAttachmentCount = () => { globalThis.folioAttachmentStray++; }; + session.defaultSession.on("will-download", globalThis.folioAttachmentCount); + }); + await list.getByRole("button", { name: `Save ${script}`, exact: true }).click(); + assert.equal(await question.locator(".save-program-name").textContent(), script); + await question.screenshot({ path: path.join(shots, "attachments-program.png") }); + await question.getByRole("button", { name: "Cancel" }).click(); + await question.waitFor({ state: "detached" }); + // Reading the file takes a moment; a save that Cancel let through would have started by then. + await page.waitForTimeout(1500); + assert.equal(await app.evaluate(({ session }) => { + session.defaultSession.off("will-download", globalThis.folioAttachmentCount); + return globalThis.folioAttachmentStray; + }), 0, "cancelling the question still saved the file"); + + for (const [name, expected] of [["figures.bin", figures], ["notes.txt", notes], ["README", notes], [script, 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(); + if (name === script) await question.getByRole("button", { name: "Save", exact: true }).click(); + assert.deepEqual(await within(app.evaluate(() => globalThis.folioAttachmentSaved), `saving ${name}`), { 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, asking first for one that could run; 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..b743322 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 { AttachmentError, readAttachment, runsWhenOpened, 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"; @@ -35,7 +36,7 @@ import { IS_MAC, keys } from "./platform"; import { AiPanel, forgetClearedChats, type AiContext } from "./AiPanel"; import { ZoomMenu } from "./ZoomMenu"; import { SearchBar, type FindResult } from "./SearchBar"; -import { AboutDialog, InfoDialog, OpenUrlDialog, PasswordDialog, ShortcutsDialog, parseWebUrl } from "./Dialogs"; +import { AboutDialog, InfoDialog, OpenUrlDialog, PasswordDialog, SaveProgramDialog, ShortcutsDialog, parseWebUrl } from "./Dialogs"; import { SignatureDialog } from "./SignatureDialog"; import { newImageDraft, type ImageDraft } from "./ImagePlacement"; import { ContentEditor, type ContentEditorHandle, type NextSelection } from "./ContentEditor"; @@ -258,15 +259,22 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const [aiOpen, setAiOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [aiContext, setAiContext] = useState(null); + /** An attached program or script that is saved only once the reader has seen its full name and agreed. */ + const [programToSave, setProgramToSave] = useState<{ file: Attachment; pdf: PDFDocumentProxy } | null>(null); + const programAsked = programToSave?.pdf === doc?.pdf ? programToSave : null; + const attachmentSave = useRef(null); + const [savingAttachment, setSavingAttachment] = useState(false); + // Reading an attachment out of a large PDF can take a while; there is no one to give it to once the document is gone. + useEffect(() => () => attachmentSave.current?.abort(), [doc?.pdf]); const [ocr, setOcr] = useState(null); const [ocrVersion, setOcrVersion] = useState(0); const [ocrPhase, setOcrPhase] = useState<"running" | "done">("done"); useLayoutEffect(() => { - if (active && window.folioDesktop && !focus && !dialog && !signing && !pending && !imageView) { + if (active && window.folioDesktop && !focus && !dialog && !signing && !pending && !imageView && !programAsked) { return trackTitlebar(headerRef.current!); } - }, [active, doc, focus, dialog, signing, pending, imageView]); + }, [active, doc, focus, dialog, signing, pending, imageView, programAsked]); useEffect(() => { setSigning(false); setSignatureTargets([]); setSignatureDismissed(false); @@ -977,6 +985,27 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs } }; + const saveAttachment = async (file: Attachment, confirmed = false) => { + const source = docRef.current; + if (!source || attachmentSave.current) return; + if (!confirmed && runsWhenOpened(file.name)) { setProgramToSave({ file, pdf: source.pdf }); return; } + const save = attachmentSave.current = new AbortController(); + setSavingAttachment(true); + try { + // (pdf.js leaves this unanswered if the document closes first; the race is what lets the next save start.) + const data = await Promise.race([source.pdf.getData(), new Promise((_resolve, reject) => save.signal.addEventListener("abort", reject))]); + const bytes = await readAttachment(data, file.id, save.signal); + if (save.signal.aborted || 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], { type: "application/octet-stream" }), file.name); + } catch (error) { + if (!save.signal.aborted && docRef.current === source) flash(error instanceof AttachmentError ? error.message : `Couldn't save ${file.name}.`); + } finally { + attachmentSave.current = null; + setSavingAttachment(false); + } + }; + const exportNotes = async (format: "markdown" | "json") => { if (!doc) return; const controller = markupRef.current!; @@ -1493,6 +1522,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs onOrder={order => setPageDraft(order ? { order } : null)} onSaveOrder={download} onExportPages={exportPages} + savingAttachment={savingAttachment} onSaveAttachment={(file) => void saveAttachment(file)} onOpenCopy={pageDraft?.copy ? () => { tabs.open([{ kind: "bytes", name: copyName, data: pageDraft.copy!, password: doc.password }]); } : undefined} @@ -1727,6 +1757,10 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs {dialog === "info" && doc && setDialog(null)} />} {printCopy && { printPdfBytes(printCopy.bytes); setPrintCopy(null); }} onClose={() => setPrintCopy(null)} />} + {programAsked && setProgramToSave(null)} onSave={() => { + setProgramToSave(null); + if (docRef.current?.pdf === programAsked.pdf) void saveAttachment(programAsked.file, true); + }} />} {dialog === "shortcuts" && setDialog(null)} />} {dialog === "about" && setDialog(null)} />} {(dialog === "settings" || dialog === "ai-settings") && ); } + +/** Asks before an attached file that could run goes to the save dialog, which may show only part of a long name. The whole name shows here. */ +export function SaveProgramDialog({ name, onSave, onCancel }: { name: string; onSave: () => void; onCancel: () => void }) { + return ( + +
+

{name}

+

Opening this kind of file can run or install something on your computer. Save it only if you trust where this PDF came from.

+
+ + +
+
+
+ ); +} diff --git a/src/Sidebar.tsx b/src/Sidebar.tsx index 8e41ed7..bc79712 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,9 @@ interface Props { onOrder: (order: number[] | null) => void; onSaveOrder: () => Promise; onExportPages: (pages: number[]) => Promise; + /** A file is being read for saving: one at a time. */ + savingAttachment: boolean; + onSaveAttachment: (file: Attachment) => void; onOpenCopy?: () => void; tab: SidebarTab; onTab: (tab: SidebarTab) => void; @@ -24,25 +28,37 @@ 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) { + savingAttachment, onSaveAttachment, tab: chosenTab, onTab: setTab, notes, noteCount, bookmarkedPages, onToggleBookmark }: Props) { const [outline, setOutline] = useState(null); const [bookmarkedOnly, setBookmarkedOnly] = useState(false); + const [files, setFiles] = useState([]); + 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; if (order) return ( ); return (