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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ URL opening, webpage conversion, and Word conversion require the desktop app. Fa
when you reopen a PDF, except for password-protected documents.
- **Organize pages** from either sidebar tab. Drag thumbnails or enter a new position, or turn or delete selected pages. Ctrl-click
(⌘-click on macOS) or Shift-click to select several, then choose **Rotate left**, **Rotate right**, or **Delete**. Undo and redo with `Ctrl+Z` and
`Ctrl+Shift+Z`. Right-click selected thumbnails in **Pages** to apply these actions directly. Save, then open the
`Ctrl+Shift+Z`. Right-click selected thumbnails in **Pages** to apply these actions directly. Use **Insert PDF…** to add another PDF before or after the selected page, at the start,
or at the end. You can also drop a PDF on the organizer. Insertion requires both PDFs to be unencrypted. Save, then open the
organized PDF copy; the original file stays unchanged.
- **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.
- **Search as you type:** match case or whole words, see the current match and total count, browse per-page snippets
Expand Down
Binary file added scripts/fixtures/protected-organizer.pdf
Binary file not shown.
83 changes: 82 additions & 1 deletion scripts/smoke-reader-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -920,7 +920,88 @@ async function checkOrganizePages(app, page, folder, shots) {
assert.deepEqual(copy.map(p => p.rotation), [90, 90, 0]);
await organizer.getByRole("button", { name: "Done", exact: true }).click();
await expectShown([1, 2, 3, 4]);
console.log("ok organizing pages deletes, undoes and redoes, keeps at least one page, and saves the chosen pages in order");
console.log("ok organizing pages deletes and rotates selected pages, supports undo, and saves the draft order");

// Select files through the real input without showing a platform file picker during this hidden run.
const picker = page.getByLabel("PDF to insert", { exact: true });
await picker.evaluate(input => { input.click = () => {}; });
await thumb(2).click({ button: "right" });
await page.getByRole("menuitem", { name: "Insert PDF after selection…" }).click();
const incoming = { name: "Appendix.pdf", mimeType: "application/pdf", buffer: Buffer.from(labeledPdf(["Appendix A", "Appendix B"])) };
await sidebar.screenshot({ path: path.join(shots, "organizer-before-insert.png") });
await picker.setInputFiles(incoming);
const imported = sidebar.getByRole("button", { name: /from Appendix.pdf, position/ });
await imported.nth(1).waitFor();
assert.deepEqual(await imported.evaluateAll(nodes => nodes.map(n => n.getAttribute("aria-label"))),
["Page 1 from Appendix.pdf, position 3", "Page 2 from Appendix.pdf, position 4"]);
await sidebar.screenshot({ path: path.join(shots, "organizer-after-insert.png") });
await organizer.getByRole("button", { name: "Undo", exact: true }).click();
assert.equal(await imported.count(), 0);
await organizer.getByRole("button", { name: "Redo", exact: true }).click();
await imported.first().click();
await organizer.getByRole("button", { name: "Rotate right", exact: true }).click();
await organizer.getByLabel("Move page 1 to").fill("1");
await organizer.getByRole("button", { name: "Move", exact: true }).click();
const insertedFile = path.join(folder, "with-appendix.pdf");
await captureDownload(app, insertedFile);
await organizer.getByRole("button", { name: "Save organized copy" }).click();
await organizer.getByRole("button", { name: "Open organized copy" }).waitFor();
assert.equal(await app.evaluate(() => globalThis.folioReordered), "completed");
const inserted = await readCopy(page, insertedFile);
assert.deepEqual(inserted.map(p => p.text), ["Appendix A", "First page", "Second page", "Appendix B", "Third page", "Fourth page"]);
assert.deepEqual(inserted.map(p => p.rotation), [90, 0, 0, 0, 0, 0]);
for (const [placement, expected] of [["start", 1], ["before", 3], ["end", 7]]) {
await sidebar.locator('.thumb[data-key="2"]').click();
await organizer.getByLabel("Insert pages at").selectOption(placement);
await organizer.getByRole("button", { name: "Insert PDF…" }).click();
await picker.setInputFiles({ name: "Cover.pdf", mimeType: "application/pdf", buffer: Buffer.from(labeledPdf(["Cover"])) });
await sidebar.getByRole("button", { name: `Page 1 from Cover.pdf, position ${expected}`, exact: true }).waitFor();
await organizer.getByRole("button", { name: "Undo", exact: true }).click();
await sidebar.getByRole("button", { name: /from Cover.pdf/ }).waitFor({ state: "detached" });
}
await organizer.evaluate((element, bytes) => {
const dataTransfer = new DataTransfer();
for (const name of ["First dropped.pdf", "Second dropped.pdf"]) {
dataTransfer.items.add(new File([Uint8Array.from(bytes)], name, { type: "application/pdf" }));
}
element.dispatchEvent(new DragEvent("drop", { dataTransfer, bubbles: true, cancelable: true }));
}, Array.from(Buffer.from(labeledPdf(["Dropped page"]))));
await sidebar.getByRole("button", { name: "Page 1 from Second dropped.pdf, position 8", exact: true }).waitFor();
await organizer.getByRole("button", { name: "Undo", exact: true }).click();
const protectedPdf = readFileSync(new URL("./fixtures/protected-organizer.pdf", import.meta.url));
await picker.setInputFiles({ name: "Protected.pdf", mimeType: "application/pdf", buffer: protectedPdf });
await organizer.getByRole("alert").filter({ hasText: "Protected PDFs cannot be inserted." }).waitFor();
assert.equal(await imported.count(), 2);
await organizer.getByRole("button", { name: "Cancel", exact: true }).click();
// The single-page AES-256 fixture was made with PDFium EPDF_SetEncryption. Rotation must preserve its password.
await app.evaluate(({ BrowserWindow }, data) => BrowserWindow.getAllWindows()[0].webContents.send(
"folio:open-file", { name: "Protected.pdf", data: Buffer.from(data) }), Array.from(protectedPdf));
await page.locator('input[type=password]').fill("folio-test-reader");
await page.locator('input[type=password]').press("Enter");
await page.window.waitForFunction(() => document.title.startsWith("Protected.pdf"));
if (await toggle.getAttribute("aria-pressed") !== "true") await toggle.click();
await page.getByRole("tab", { name: "Pages", exact: true }).click();
await sidebar.getByRole("button", { name: "Organize pages" }).click();
assert.ok(await organizer.getByRole("button", { name: "Insert PDF…" }).isDisabled());
await organizer.getByRole("button", { name: "Rotate left" }).click();
const protectedCopy = path.join(folder, "protected-rotated.pdf");
await captureDownload(app, protectedCopy);
await organizer.getByRole("button", { name: "Save organized copy" }).click();
await organizer.getByRole("button", { name: "Open organized copy" }).waitFor();
assert.equal(await app.evaluate(() => globalThis.folioReordered), "completed");
const protection = await page.evaluate(async bytes => {
const open = password => pdfjsLib.getDocument({ data: Uint8Array.from(bytes), password });
const locked = open(undefined);
try { await locked.promise; return "missing password protection"; }
catch (err) { if (err.name !== "PasswordException") throw err; }
finally { await locked.destroy(); }
const unlocked = open("folio-test-reader");
try { return (await (await unlocked.promise).getPage(1)).rotate; }
finally { await unlocked.destroy(); }
}, Array.from(readFileSync(protectedCopy)));
assert.equal(protection, 270);
await organizer.getByRole("button", { name: "Done", exact: true }).click();
console.log("ok PDF insertion, selection, rotation, history, and password protection");
}

export async function checkReaderTools(app, page, folder, shots) {
Expand Down
42 changes: 34 additions & 8 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,18 @@ interface OpenDoc {
}

/** The open document's pages that reach a copy of `pages`, zero-based like pdf.js's pageIndex. */
const keptIndexes = (pages: DraftPage[]) => new Set(pages.map(p => p.page - 1));
const keptIndexes = (pages: DraftPage[]) => new Set(pages.filter(p => !p.source).map(p => p.page - 1));

function changesForCopy(doc: OpenDoc, annotated: boolean, pages?: DraftPage[]) {
const changes = [...(doc.edits?.changes ?? [])];
if (annotated && !changes.some(change => change.kind === "annotations")) changes.push({ kind: "annotations" });
if (pages) {
const order = pages.map(p => p.page);
const rotated = pages.filter(p => p.rotation).map(p => p.page);
const order = pages.filter(p => !p.source).map(p => p.page);
const inserted = pages.filter(p => p.source).length;
const rotated = pages.filter(p => !p.source && p.rotation).map(p => p.page);
const kept = new Set(order);
const deleted = Array.from({ length: doc.pdf.numPages }, (_, i) => i + 1).filter(n => !kept.has(n));
changes.push({ kind: "pages", order, ...(rotated.length && { rotated }), ...deleted.length && { deleted } });
changes.push({ kind: "pages", order, ...(inserted && { inserted }), ...(rotated.length && { rotated }), ...deleted.length && { deleted } });
}
return changes;
}
Expand Down Expand Up @@ -237,6 +238,29 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs
const nextSelection = useRef<NextSelection | null>(null);
const [editorDirty, setEditorDirty] = useState(false);
const [pageDraft, setPageDraft] = useState<PageDraft | null>(null);
const insertedPdfs = useRef(new Set<PDFDocumentProxy>());
useEffect(() => {
const owned = insertedPdfs.current;
if (!owned.size) return;
const retained = new Set<PDFDocumentProxy>();
if (pageDraft) {
for (const pages of [pageDraft.pages, ...pageDraft.undo, ...pageDraft.redo]) {
for (const page of pages) if (page.source) retained.add(page.source.pdf);
}
}
for (const pdf of owned) {
if (retained.has(pdf)) continue;
owned.delete(pdf);
void pdf.loadingTask.destroy().catch(() => {});
}
}, [pageDraft]);
useEffect(() => {
const owned = insertedPdfs.current;
return () => {
for (const pdf of owned) void pdf.loadingTask.destroy().catch(() => {});
owned.clear();
};
}, []);
const organized = pageDraft && doc && draftChanged(pageDraft, doc.pdf.numPages) ? pageDraft.pages : undefined;
const keptOrganized = useMemo(() => organized && keptIndexes(organized), [organized]);
const unsaved = !!doc?.sourceId || dirty || editorDirty || (!!organized && !pageDraft?.copy);
Expand Down Expand Up @@ -492,7 +516,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs
const reading = previous ? markupRef.current!.state : undefined;
if (reading?.bookmarkedPages && exportedPages) {
const bookmarks = new Set(reading.bookmarkedPages);
reading.bookmarkedPages = exportedPages.flatMap((p, i) => bookmarks.has(p.page) ? [i + 1] : []);
reading.bookmarkedPages = exportedPages.flatMap((p, i) => !p.source && bookmarks.has(p.page) ? [i + 1] : []);
}
persistRef.current = !password && !revision;
const markupReady = markupRef.current!.load(id, persistRef.current, reading);
Expand Down Expand Up @@ -973,7 +997,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs

const copyName = organized && doc ? doc.name.replace(/\.pdf$/i, "") + " (organized).pdf" : doc?.name ?? "document.pdf";
// Where the reader's page ended up in the organized copy; the first page if it was deleted.
const organizedPage = (n: number) => organized ? Math.max(1, organized.findIndex(p => p.page === n) + 1) : n;
const organizedPage = (n: number) => organized ? Math.max(1, organized.findIndex(p => !p.source && p.page === n) + 1) : n;
const saveBytes = (bytes: Uint8Array) => {
saveBlob(pdfBlob(bytes), copyName.toLowerCase().endsWith(".pdf") ? copyName : `${copyName}.pdf`);
};
Expand Down Expand Up @@ -1020,7 +1044,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs
try {
const copy = await currentBytes(doc, organized);
if (!copy || docRef.current !== doc) return;
const withText = await copyWithText(ocr, copy.bytes, organized?.map(p => p.page));
const withText = await copyWithText(ocr, copy.bytes, organized?.map(p => p.source ? 0 : p.page));
if (docRef.current !== doc) return;
const name = doc.sourceId ? copyName.replace(/\.pdf$/i, "") + " (edited).pdf" : copyName;
const blob = pdfBlob(withText.bytes);
Expand Down Expand Up @@ -1330,7 +1354,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs
if (!hasFiles(e)) return;
e.preventDefault();
depth++;
setDragging(true);
setDragging(!(e.dataTransfer?.types.includes("Files") && (e.target as Element)?.closest?.(".sidebar-organizer")));
};
const leave = (e: DragEvent) => {
if (!hasFiles(e)) return;
Expand All @@ -1345,6 +1369,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs
e.preventDefault();
depth = 0;
setDragging(false);
if (e.dataTransfer?.files.length && (e.target as Element)?.closest?.(".sidebar-organizer")) return;
const files = e.dataTransfer?.files;
const url = !files?.length && e.dataTransfer ? droppedUrl(e.dataTransfer) : null;
if (files?.length) openFiles(files);
Expand Down Expand Up @@ -1506,6 +1531,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs
onToggleBookmark={n => markupRef.current!.toggleBookmark(n)}
draft={pageDraft}
onDraft={setPageDraft}
onImport={source => insertedPdfs.current.add(source)}
onSaveDraft={download}
onExportPages={exportPages}
onOpenCopy={pageDraft?.copy ? () => {
Expand Down
Loading
Loading