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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ URL opening, webpage conversion, and Word conversion require the desktop app. Fa
leaving your current page. Turn on **Bookmarked** at the top of the sidebar to see just your saved pages,
then select a thumbnail to jump to it. These bookmarks are included in Markdown and JSON exports. Folio remembers them locally
when you reopen a PDF, except for password-protected documents.
- **Organize pages** from either sidebar tab. Drag thumbnails or enter a new position, and delete pages: Ctrl-click
(⌘-click on macOS) or Shift-click to select several, then press **Delete**. Undo and redo with `Ctrl+Z` and
`Ctrl+Shift+Z`. Right-click selected thumbnails in **Pages** to start with those pages deleted. Save, then open the
- **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
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
37 changes: 25 additions & 12 deletions scripts/smoke-reader-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ async function readCopy(page, file) {
const pages = [];
for (let n = 1; n <= pdf.numPages; n++) {
const p = await pdf.getPage(n);
pages.push({ text: (await p.getTextContent()).items.map(i => i.str).join(" ").trim() });
pages.push({ rotation: p.rotate, text: (await p.getTextContent()).items.map(i => i.str).join(" ").trim() });
}
return pages;
} finally { await task.destroy(); }
Expand All @@ -846,7 +846,12 @@ async function checkOrganizePages(app, page, folder, shots) {
await page.getByRole("tab", { name: "Pages", exact: true }).click();
const sidebar = page.locator("aside.sidebar");
const thumb = n => sidebar.locator(`.thumb[data-page="${n}"]`);
const shown = () => sidebar.locator(".thumb").evaluateAll(els => els.map(el => Number(el.dataset.page)));
const expectShown = async expected => {
await page.waitForFunction(order => {
const thumbs = document.querySelectorAll("aside.sidebar .thumb");
return thumbs.length === order.length && order.every((n, i) => Number(thumbs[i].dataset.page) === n);
}, expected);
};
const picked = () => sidebar.locator('.thumb[aria-pressed="true"]').evaluateAll(els => els.map(el => Number(el.dataset.page)));
const modifier = process.platform === "darwin" ? "Meta" : "Control";

Expand All @@ -863,10 +868,10 @@ async function checkOrganizePages(app, page, folder, shots) {
await menuDelete.click();
const organizer = page.getByRole("complementary", { name: "Organize pages" });
await organizer.waitFor();
assert.deepEqual(await shown(), [1, 4]);
await expectShown([1, 4]);
await page.waitForFunction(() => document.activeElement?.matches('aside .thumb[data-key="4"]'));
await organizer.getByRole("button", { name: "Undo", exact: true }).click();
assert.deepEqual(await shown(), [1, 2, 3, 4]);
await expectShown([1, 2, 3, 4]);
assert.deepEqual(await picked(), [2, 3]);

// Every page selected: nothing can be deleted, by button or key.
Expand All @@ -875,38 +880,46 @@ async function checkOrganizePages(app, page, folder, shots) {
assert.deepEqual(await picked(), [1, 2, 3, 4]);
assert.ok(await organizer.getByRole("button", { name: "Delete 4 pages" }).isDisabled());
await page.keyboard.press("Delete");
assert.deepEqual(await shown(), [1, 2, 3, 4]);
await expectShown([1, 2, 3, 4]);
await thumb(3).click({ modifiers: [modifier] });
await thumb(1).click({ modifiers: [modifier] });
assert.deepEqual(await picked(), [2, 4]);
await sidebar.screenshot({ path: path.join(shots, "organize-pages-selected.png") });

// Delete and Backspace delete; the shortcuts undo and redo inside the organizer, not the reader's edits.
await page.keyboard.press("Delete");
assert.deepEqual(await shown(), [1, 3]);
await expectShown([1, 3]);
await page.keyboard.press("ControlOrMeta+z");
assert.deepEqual(await shown(), [1, 2, 3, 4]);
await expectShown([1, 2, 3, 4]);
await page.keyboard.press("ControlOrMeta+Shift+z");
assert.deepEqual(await shown(), [1, 3]);
await expectShown([1, 3]);
await page.keyboard.press("ControlOrMeta+z");
await thumb(2).click();
await page.keyboard.press("Backspace");
assert.deepEqual(await shown(), [1, 3, 4]);
await expectShown([1, 3, 4]);
// Dragging is covered by the reorder check; dragging here hangs Playwright on Windows CI.
await thumb(4).click();
await organizer.getByLabel("Move page 4 to").fill("1");
await organizer.getByRole("button", { name: "Move", exact: true }).click();
assert.deepEqual(await shown(), [4, 1, 3]);
await expectShown([4, 1, 3]);

await thumb(4).click();
await thumb(1).click({ modifiers: ["ControlOrMeta"] });
await sidebar.screenshot({ path: path.join(shots, "organizer-before-rotation.png") });
await organizer.getByRole("button", { name: "Rotate right", exact: true }).click();
await organizer.getByRole("button", { name: "Rotate left", exact: true }).click();
await organizer.getByRole("button", { name: "Undo", exact: true }).click();
await sidebar.screenshot({ path: path.join(shots, "organizer-after-rotation.png") });
const file = path.join(folder, "organized.pdf");
await captureDownload(app, file);
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 copy = await readCopy(page, file);
assert.deepEqual(copy.map(p => p.text), ["Fourth page", "First page", "Third page"]);
assert.deepEqual(copy.map(p => p.rotation), [90, 90, 0]);
await organizer.getByRole("button", { name: "Done", exact: true }).click();
assert.deepEqual(await shown(), [1, 2, 3, 4]);
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");
}

Expand Down Expand Up @@ -1102,7 +1115,7 @@ export async function checkReaderTools(app, page, folder, shots) {
const pages = [];
for (let n = 1; n <= pdf.numPages; n++) {
const p = await pdf.getPage(n);
pages.push({ text: (await p.getTextContent()).items.map(i => i.str).join(" "),
pages.push({ rotation: p.rotate, text: (await p.getTextContent()).items.map(i => i.str).join(" "),
marks: (await p.getAnnotations()).map(a => a.subtype) });
}
const [bookmark] = await pdf.getOutline();
Expand Down
5 changes: 3 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,10 @@ function changesForCopy(doc: OpenDoc, annotated: boolean, pages?: DraftPage[]) {
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 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, ...deleted.length && { deleted } });
changes.push({ kind: "pages", order, ...(rotated.length && { rotated }), ...deleted.length && { deleted } });
}
return changes;
}
Expand Down Expand Up @@ -962,7 +963,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs
// Marks on deleted pages don't reach the copy. Form values don't carry their page here, so they always count.
const kept = pages && keptIndexes(pages);
const annotated = [...storage].some(([, value]) => !kept || typeof value?.pageIndex !== "number" || kept.has(value.pageIndex));
if (pages?.length) return { bytes: await copyPdfPages(doc.pdf, pages.map(p => p.page), signal, doc.password), annotated };
if (pages?.length) return { bytes: await copyPdfPages(doc.pdf, pages, signal, doc.password), annotated };
return { bytes: annotated ? await doc.pdf.saveDocument() : await doc.pdf.getData(), annotated };
} finally {
for (const key of keys) storage.remove(key);
Expand Down
29 changes: 22 additions & 7 deletions src/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +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 { deletePages, draftChanged, movePage, pageKey, startDraft, stepDraft, withPages, type DraftPage, type PageDraft } from "./organize";
import { deletePages, draftChanged, movePage, pageKey, rotatePages, startDraft, stepDraft, withPages, type DraftPage, type PageDraft } from "./organize";
import { IS_MAC, keys } from "./platform";
import { anchoredScrollTop, loadPageSizes, rowAt, thumbLayout, THUMB_ROW_EXTRA, type PageSize } from "./thumbnails";

Expand All @@ -27,7 +27,7 @@ interface Props {

export type SidebarTab = "pages" | "outline" | "notes";

type PageAction = "delete";
type PageAction = "delete" | "left" | "right";

export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft, onDraft, onSaveDraft, onOpenCopy, onExportPages,
tab, onTab: setTab, notes, noteCount, bookmarkedPages, onToggleBookmark }: Props) {
Expand All @@ -49,6 +49,8 @@ export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft
const result = deletePages(start.pages, new Set(pick));
next = withPages(start, result.pages);
pick = [pageKey(result.next)];
} else if (pick && (action === "left" || action === "right")) {
next = withPages(start, rotatePages(start.pages, new Set(pick), action === "left" ? -90 : 90));
}
setOrganizerPick(pick);
onDraft(next);
Expand Down Expand Up @@ -97,7 +99,7 @@ export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft
</ul>
)}
</div>
{tab !== "notes" && pdf.numPages > 1 && (
{tab !== "notes" && (
<div className="organize-actions">
<button className="link small" onClick={() => organize()}>
<Icon name="grid" size={14} /> Organize pages
Expand Down Expand Up @@ -144,7 +146,7 @@ interface ThumbnailProps {
}

/** A row of the thumbnail list: which page it shows, and its key in the organizer's draft. */
interface ThumbRow { key: string; page: number }
interface ThumbRow extends DraftPage { key: string }

// Ctrl-click (⌘-click on macOS) toggles a page, Shift-click selects the range from the anchor.
// Returns null for a plain click, which selects just that page.
Expand Down Expand Up @@ -188,7 +190,7 @@ function PageOrganizer({ pdf, page, rotation, onSelect, draft, onDraft, onSave,
focus();
return () => cancelAnimationFrame(frame);
}, []);
const rows = useMemo(() => draft.pages.map((p): ThumbRow => ({ key: pageKey(p), page: p.page })), [draft.pages]);
const rows = useMemo(() => draft.pages.map((p): ThumbRow => ({ ...p, key: pageKey(p) })), [draft.pages]);
const rowKeys = useMemo(() => rows.map(row => row.key), [rows]);
const selected = useMemo(() => rowKeys.filter(key => picked.has(key)), [rowKeys, picked]);
const activeKey = anchor !== null && picked.has(anchor) && rowKeys.includes(anchor) ? anchor : selected[0];
Expand Down Expand Up @@ -266,6 +268,10 @@ function PageOrganizer({ pdf, page, rotation, onSelect, draft, onDraft, onSave,
<strong>Organize pages</strong>
<p>Drag a page to move it. {keys("Ctrl")}-click or Shift-click to select several pages. Save the result as a new PDF.</p>
<div className="organize-tools" role="toolbar" aria-label="Page tools">
<button className="pill-btn" disabled={saving || !selected.length} title="Rotate left" aria-label="Rotate left"
onClick={() => change(rotatePages(draft.pages, new Set(selected), -90))}><Icon name="rotateLeft" size={16} /></button>
<button className="pill-btn" disabled={saving || !selected.length} title="Rotate right" aria-label="Rotate right"
onClick={() => change(rotatePages(draft.pages, new Set(selected), 90))}><Icon name="rotate" size={16} /></button>
<button className="pill-btn" onClick={remove} disabled={!canDelete}
aria-label={selected.length > 1 ? `Delete ${selected.length} pages` : "Delete page"} title="Delete (Delete)">
<Icon name="trash" size={16} />
Expand Down Expand Up @@ -341,6 +347,8 @@ function PageMenu({ target, count, all, onExport, onOrganize, onClose }: {
const at = items.indexOf(document.activeElement as HTMLButtonElement);
items[(at + (e.key === "ArrowDown" ? 1 : items.length - 1)) % items.length]?.focus();
}}>
<button role="menuitem" onClick={run(() => onOrganize("left"))}><Icon name="rotateLeft" size={16} />Rotate left</button>
<button role="menuitem" onClick={run(() => onOrganize("right"))}><Icon name="rotate" size={16} />Rotate right</button>
<button role="menuitem" onClick={run(() => onOrganize("delete"))} disabled={all}
title={all ? "A PDF needs at least one page." : undefined}>
<Icon name="trash" size={16} />{count === 1 ? "Delete page" : `Delete ${count} pages`}
Expand Down Expand Up @@ -418,7 +426,14 @@ function Thumbnails({ pdf, page, rotation, onSelect, rows, activeKey, picks, onM
pagesRef.current = pages;
const loaderRef = useRef<ReturnType<typeof loadPageSizes> | null>(null);
// Filtering or reordering changes which page belongs in a row, but keeps the scale of the full document.
const layout = useMemo(() => thumbLayout(sizes, pdf.numPages, rotation, pages), [sizes, pdf, rotation, pages]);
const layout = useMemo(() => {
if (!rows) return thumbLayout(sizes, pdf.numPages, rotation, pages);
const rowSizes = rows.map(row => {
const size = sizes?.[row.page - 1];
return size && (row.rotation ?? 0) % 180 ? { width: size.height, height: size.width } : size;
});
return thumbLayout(rowSizes, rows.length, rotation);
}, [sizes, pdf, rotation, pages, rows]);
const layoutRef = useRef(layout);
layoutRef.current = layout;
const activeIndex = rows ? Math.max(0, rowKeys.indexOf(activeKey!)) : pages ? Math.max(0, pages.indexOf(page)) : page - 1;
Expand Down Expand Up @@ -553,7 +568,7 @@ function Thumbnails({ pdf, page, rotation, onSelect, rows, activeKey, picks, onM
const index = range.start + i;
const n = pages?.[index] ?? index + 1;
const key = rowKeys[index];
return <Thumb key={`${key}-${rotation}`} pdf={pdf} n={n} rowKey={key} size={layout.frames[index]} rotation={rotation}
return <Thumb key={`${key}-${rotation}`} pdf={pdf} n={n} rowKey={key} size={layout.frames[index]} rotation={rotation + (rows?.[index].rotation ?? 0)}
active={rows ? key === activeKey : page === n}
selected={onExportPages || picks ? selected.has(key) : undefined} showSelection={!!picks || picked !== null || !!menu}
bookmarked={bookmarkedPages?.includes(n)} onToggleBookmark={onToggleBookmark}
Expand Down
1 change: 1 addition & 0 deletions src/VersionHistoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function Changes({ changes }: { changes: PdfChange[] }) {
</div>}
{change.kind === "image" && <p>{change.fileName}</p>}
{change.kind === "pages" && reordered(change.order) && <p>New order: {pageRanges(change.order)}</p>}
{change.kind === "pages" && change.rotated && <p>Rotated {pageList(change.rotated)}</p>}
{change.kind === "pages" && change.deleted && <p>Deleted {pageList(change.deleted)}</p>}
</li>)}</ul>;
}
Expand Down
39 changes: 29 additions & 10 deletions src/contentEditor.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export interface EditTarget {
}
export type Change = { kind: "text"; index: number; text: string } | { kind: "image"; index: number; file: File }
| { kind: "insertImage"; file: File; box: number[]; rotation: number };
export type EditorRequest = { source: Uint8Array } | { page: number } | { page: number; change: Change; snapshot: Uint8Array };
export type EditorRequest = { rotations: number[]; snapshot: Uint8Array; password?: string }
| { source: Uint8Array } | { page: number } | { page: number; change: Change; snapshot: Uint8Array };
export type EditorReply = { page: number; targets: EditTarget[] } | { bytes: Uint8Array } | { error: string; page?: number };

const ready = (async () => {
Expand All @@ -36,22 +37,26 @@ function buffer<T>(m: WrappedPdfiumModule, size: number, use: (ptr: number) => T
try { return use(ptr); } finally { m.pdfium.wasmExports.free(ptr); }
}

async function withPage<T>(m: WrappedPdfiumModule, bytes: Uint8Array, number: number, use: (doc: number, page: number) => T | Promise<T>) {
async function withDocument<T>(m: WrappedPdfiumModule, bytes: Uint8Array, password: string, use: (doc: number) => T | Promise<T>) {
const ptr = m.pdfium.wasmExports.malloc(bytes.length);
if (!ptr) throw new Error("There isn't enough memory to edit this PDF.");
try {
m.pdfium.HEAPU8.set(bytes, ptr);
const doc = m.FPDF_LoadMemDocument(ptr, bytes.length, "");
const doc = m.FPDF_LoadMemDocument(ptr, bytes.length, password);
if (!doc) throw new Error("Couldn't open this PDF for editing.");
try {
if (m.EPDF_IsEncrypted(doc) || m.FPDF_GetSignatureCount(doc) > 0) throw new Error("Editing requires an unsigned, unencrypted PDF.");
const page = m.FPDF_LoadPage(doc, number - 1);
if (!page) throw new Error("Couldn't open this page.");
try { return await use(doc, page); } finally { m.FPDF_ClosePage(page); }
} finally { m.FPDF_CloseDocument(doc); }
try { return await use(doc); } finally { m.FPDF_CloseDocument(doc); }
} finally { m.pdfium.wasmExports.free(ptr); }
}

async function withPage<T>(m: WrappedPdfiumModule, bytes: Uint8Array, number: number, use: (doc: number, page: number) => T | Promise<T>) {
return withDocument(m, bytes, "", async doc => {
if (m.EPDF_IsEncrypted(doc) || m.FPDF_GetSignatureCount(doc) > 0) throw new Error("Editing requires an unsigned, unencrypted PDF.");
const page = m.FPDF_LoadPage(doc, number - 1);
if (!page) throw new Error("Couldn't open this page.");
try { return await use(doc, page); } finally { m.FPDF_ClosePage(page); }
});
}

function markString(m: WrappedPdfiumModule, mark: number, key: string, scratch: number) {
if (key === "ActualText" && m.FPDFPageObjMark_GetParamBlobValue(mark, key, 0, 0, scratch)) {
const length = m.pdfium.HEAPU32[scratch/4];
Expand Down Expand Up @@ -299,6 +304,20 @@ self.onmessage = async ({ data }: MessageEvent<EditorRequest>) => {
if ("source" in data) { source = data.source; return; }
try {
const m = await ready;
if ("rotations" in data) {
const bytes = await withDocument(m, data.snapshot, data.password ?? "", doc => {
for (const [index, degrees] of data.rotations.entries()) {
if (!degrees) continue;
const handle = m.FPDF_LoadPage(doc, index);
if (!handle) throw new Error("Couldn't open the page to rotate.");
try { m.FPDFPage_SetRotation(handle, (m.FPDFPage_GetRotation(handle) + degrees / 90) % 4); }
finally { m.FPDF_ClosePage(handle); }
}
return save(m, doc);
});
self.postMessage({ bytes } satisfies EditorReply, { transfer: [bytes.buffer as ArrayBuffer] });
return;
}
const { page } = data;
if (!("change" in data)) {
const { targets } = await withPage(m, source, page, (_doc, handle) => pageView(m, handle));
Expand All @@ -321,6 +340,6 @@ self.onmessage = async ({ data }: MessageEvent<EditorRequest>) => {
self.postMessage({ bytes } satisfies EditorReply, { transfer: [bytes.buffer as ArrayBuffer] });
} catch (reason) {
self.postMessage({ error: reason instanceof Error ? reason.message : "Couldn't edit this PDF.",
page: "change" in data ? undefined : data.page } satisfies EditorReply);
page: "page" in data && !("change" in data) ? data.page : undefined } satisfies EditorReply);
}
};
Loading
Loading