One Small Edit Model Behind Rotate, Delete and Rearrange PDF Pages
A phone scan of a 30-page contract comes out with pages 7 and 19 upside down, a blank sheet after page 12, and the signature page at the end where the other side wants it first. Fixing that usually means three different tools, or one paid app. I wanted it to take one page and a minute.
ToolOnTap has three pages for this: Rotate PDF, Delete PDF pages and Rearrange PDF pages. They’re one tool underneath. The page you land on only changes the hint text and the output name (contract-rotated.pdf, contract-edited.pdf or contract-reordered.pdf). Every action works on all three, because a real scan usually needs more than one kind of fix.
The whole document is a short list
The core idea is that the tool never edits the PDF while you work. It edits a list that describes the PDF you want:
export interface PageEdit { source: number; rotation: number } // rotation: extra degrees, 0/90/180/270
export const initialPages = (count: number): PageEdit[] => Array.from({ length: count }, (_, source) => ({ source, rotation: 0 }))
Each entry says “take page source of the original file and turn it rotation degrees more”. A 5-page file starts as five entries, {0, 0} to {4, 0}. Then each action is a small function on that list:
| Action | What happens to the list |
|---|---|
| Rotate | the rotation of the chosen entries goes up or down by 90, wrapped to 0–270 |
| Delete | the chosen entries are filtered out |
| Drag a page | one entry is moved to a new position |
| Move earlier / later | every selected entry swaps one step with its neighbour |
The position of an entry in the list is the page’s new position. Its source is its identity. Because pages are never duplicated in this tool, source stays unique even after any number of moves and deletions. That’s what lets the tool keep track of “page 12” while it travels to third place, and why each preview still shows its original number.
Every function returns a new list and never changes the old one. Rotation, for example:
// Rotate the given pages (or all of them when none are given) by ±90° steps
export function rotatePages(pages: PageEdit[], delta: number, sources?: Set<number>): PageEdit[] {
return pages.map(p => (!sources?.size || sources.has(p.source) ? { ...p, rotation: normalize(p.rotation + delta) } : p))
}
normalize turns any angle into 0, 90, 180 or 270, so rotating left from 0 gives 270, not -90. When no pages are selected, the toolbar rotates all of them. That’s the quickest fix for a whole scan that came out sideways.
Undo comes almost for free
Since no edit changes the old list, undo is just keeping the old lists. Every action goes through one function that pushes the current list onto a history before replacing it:
function edit(next: PageEdit[], message: string) {
if (next === pages.value) return
history.value = [...history.value.slice(-49), pages.value]
pages.value = next
announcement.value = message
}
Three small details are in there:
slice(-49)caps the history at 50 steps. Each step is a list of small objects, so even a long document’s history takes little memory, but a cap keeps it bounded.if (next === pages.value) returnskips actions that changed nothing.movePagereturns the same list for an invalid move, so a drop onto the page’s own position doesn’t add an empty undo step.announcementis read out by screen readers through a hidden live region: “2 pages rotated right.”, “Page moved to position 3.”, “Last change undone.”
“Undo all” is the same edit with a fresh initialPages list, so even “Undo all” can be undone. The “Save PDF” button stays disabled until the list differs from the original, which isChanged checks by looking for any entry that moved, disappeared or has a rotation.
Tap to select, on phones and with a keyboard
Dragging works well with a mouse or a touchpad, but not with a finger on a long grid, or with a keyboard. So every page is also a toggle button: tap it to select it, tap again to deselect it. The toolbar then rotates, moves or deletes everything selected.
The selection is stored by source, not by position. If you select pages 3 and 8 and move them earlier, they’re still selected afterwards, so you can press the arrow again. After an undo, the selection drops any page that no longer exists in the restored list.
Moving several selected pages one step needed some care. Take pages 1 and 3 selected, and “Move earlier”. Page 1 is already first and can’t move. Page 3 should move to second place, but it must not jump over page 1. The rule in shiftPages is that a selected page only swaps with an unselected neighbour. The tests pin down both cases: moving pages 3 and 4 of 5 earlier gives the order 1, 3, 4, 2, 5, and moving pages 1 and 3 of 4 earlier gives 1, 3, 2, 4.
One limit is checked everywhere: a PDF needs at least one page, so the delete buttons are disabled when only one page is left, and the toolbar won’t delete a selection that covers every page.
Saving: copy the pages, set the angle
Nothing touches the PDF until “Save PDF”. Then pdf-lib builds a new document from the list in one pass:
const copied = await out.copyPages(doc, pages.map(p => p.source))
copied.forEach((page, i) => {
const extra = pages[i]!.rotation
if (extra) page.setRotation(degrees((page.getRotation().angle + extra) % 360))
out.addPage(page)
})
Deleting is simply leaving a page out of copyPages. Reordering is the order of the indexes. Rotation is one line, and it’s the decision I care most about in this tool.
Why rotation is a page setting
Every PDF page has a /Rotate entry that tells a viewer to show the page turned by a multiple of 90 degrees. The page content itself, the text and drawing instructions, stays exactly as it was. There were two ways to rotate a page:
- Render the page to an image, rotate the image, and put it back. Text would become pixels: not selectable, not searchable, blurry when you zoom in, and usually a bigger file.
- Change the
/Rotatenumber. Nothing is redrawn and nothing is lost.
The tool does the second. It adds your turn to the page’s existing rotation, because a page can already carry a /Rotate of its own. A page stored with 90 that you turn another 90 right is saved with 180.
That’s also why the number under each preview is the turn you’ve added, not the page’s total rotation. “180°” means “upside down compared to how it opens now”, which is what you see on screen. The previews do the same sum: pdf.js draws each thumbnail at the page’s own rotation plus the pending one, so what you see is what will be saved.
It also explains a common question. Most PDF readers only rotate the view on your screen and don’t change the file, so the next person still gets it sideways. A saved /Rotate is part of the file, so it travels with it to whoever opens it next.
What it can’t do is straighten a page scanned slightly crooked. /Rotate only allows quarter turns, so the tool only offers quarter turns.
What changes in the new file
Pages are copied as they are, with their fonts, images and size, so text stays selectable and links to websites keep working. The original file isn’t changed. Two things don’t carry over well, and the pages say so: links inside the document, like a table of contents, may point to the wrong page after a reorder, and a deleted page’s images can, in a few PDFs, still be in a shared resource that another page uses.
Try it
Open a PDF in Rotate PDF, Delete PDF pages or Rearrange PDF pages; all three let you do everything above before you save. The rest of the PDF tools are on the PDF tools page.