Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down
96 changes: 96 additions & 0 deletions scripts/smoke-attachments.mjs
Original file line number Diff line number Diff line change
@@ -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");
}
2 changes: 2 additions & 0 deletions scripts/smoke-reader-tools.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
40 changes: 37 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<AiContext | null>(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<AbortController | null>(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<OcrSession | null>(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);
Expand Down Expand Up @@ -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<never>((_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!;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1727,6 +1757,10 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs
{dialog === "info" && doc && <InfoDialog key={doc.pdf.loadingTask.docId} pdf={doc.pdf} name={doc.name} size={doc.size} onClose={() => setDialog(null)} />}
{printCopy && <PrintDialog pdf={printCopy.pdf} currentPage={printCopy.page} name={printCopy.name}
onPrint={() => { printPdfBytes(printCopy.bytes); setPrintCopy(null); }} onClose={() => setPrintCopy(null)} />}
{programAsked && <SaveProgramDialog name={programAsked.file.name} onCancel={() => setProgramToSave(null)} onSave={() => {
setProgramToSave(null);
if (docRef.current?.pdf === programAsked.pdf) void saveAttachment(programAsked.file, true);
}} />}
{dialog === "shortcuts" && <ShortcutsDialog onClose={() => setDialog(null)} />}
{dialog === "about" && <AboutDialog onClose={() => setDialog(null)} />}
{(dialog === "settings" || dialog === "ai-settings") && <SettingsDialog initialSection={dialog === "ai-settings" ? "Ask AI" : "General"}
Expand Down
16 changes: 16 additions & 0 deletions src/Dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,19 @@ export function PasswordDialog({
</Modal>
);
}

/** 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 (
<Modal title="Save this attached file?" onClose={onCancel}>
<div className="password-form">
<p className="save-program-name">{name}</p>
<p>Opening this kind of file can run or install something on your computer. Save it only if you trust where this PDF came from.</p>
<div className="form-actions">
<button className="btn ghost" autoFocus onClick={onCancel}>Cancel</button>
<button className="btn primary" onClick={onSave}>Save</button>
</div>
</div>
</Modal>
);
}
46 changes: 42 additions & 4 deletions src/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -17,32 +18,47 @@ interface Props {
onOrder: (order: number[] | null) => void;
onSaveOrder: () => Promise<void>;
onExportPages: (pages: number[]) => Promise<void>;
/** 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;
notes: ReactNode;
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<OutlineItem[] | null>(null);
const [bookmarkedOnly, setBookmarkedOnly] = useState(false);

const [files, setFiles] = useState<Attachment[]>([]);

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 (
<PageOrganizer pdf={pdf} page={page} rotation={rotation} onSelect={onSelectPage}
order={order} onOrder={onOrder} onSave={onSaveOrder} onOpenCopy={onOpenCopy} />
);
return (
<aside className={`sidebar tab-${tab}`}>
<div className="sidebar-tabs" role="tablist">
<div className={`sidebar-tabs ${files.length ? "with-files" : ""}`} role="tablist">
<button role="tab" aria-selected={tab === "pages"} className={tab === "pages" ? "on" : ""} onClick={() => setTab("pages")}>
Pages
</button>
Expand All @@ -52,6 +68,11 @@ export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, order
<button role="tab" aria-selected={tab === "notes"} className={tab === "notes" ? "on" : ""} onClick={() => setTab("notes")}>
Notes{noteCount > 0 && <span className="tab-count">{noteCount}</span>}
</button>
{files.length > 0 && (
<button role="tab" aria-selected={tab === "files"} className={tab === "files" ? "on" : ""} onClick={() => setTab("files")}>
Files
</button>
)}
</div>
{tab === "pages" && (
<button type="button" className="bookmark-filter" aria-pressed={bookmarkedOnly} disabled={bookmarkedPages === null}
Expand All @@ -62,6 +83,23 @@ export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, order
<div className="sidebar-scroll">
{tab === "notes" ? (
notes
) : tab === "files" ? (
<ul className="attachments" aria-label="Attached files">
{files.map(file => (
<li key={file.id}>
<Icon name="file" size={16} />
<div className="attachment-text">
<span className="attachment-name" title={file.name}>{file.name}</span>
{file.description && <span className="attachment-description">{file.description}</span>}
</div>
<button className="icon-btn" disabled={savingAttachment} onClick={() => onSaveAttachment(file)}
aria-label={`Save ${file.name}`} title={`Save ${file.name}`}>
<Icon name="download" size={15} />
</button>
</li>
))}
{files.length === MAX_ATTACHMENTS && <li className="attachments-more">Only the first {MAX_ATTACHMENTS} files are listed.</li>}
</ul>
) : tab === "pages" ? (
<Thumbnails pdf={pdf} page={page} rotation={rotation}
onSelect={onSelectPage} onExportPages={onExportPages}
Expand All @@ -79,7 +117,7 @@ export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, order
</ul>
)}
</div>
{tab !== "notes" && pdf.numPages > 1 && (
{tab !== "notes" && tab !== "files" && pdf.numPages > 1 && (
<div className="organize-actions">
<button className="link small" onClick={() => onOrder(Array.from({ length: pdf.numPages }, (_, i) => i + 1))}>
<Icon name="grid" size={14} /> Reorder pages
Expand Down
Loading