141 lines
6.0 KiB
Markdown
141 lines
6.0 KiB
Markdown
# Plan: Seiten drehen
|
|
|
|
## Goal
|
|
|
|
Add a new tool **"Seiten drehen"** (`/tools/rotate`) that fixes page orientation — the classic
|
|
case being scanned court mail with individual pages rotated 90°/180°. Users rotate pages
|
|
individually or all at once; the result is saved via the standard PDF `/Rotate` mechanism, so the
|
|
operation is **lossless and instant** (no rasterization, no re-encoding).
|
|
|
|
No new dependencies.
|
|
|
|
---
|
|
|
|
## Open decisions (defaults marked ✅)
|
|
|
|
### D1 — How rotation is applied
|
|
|
|
- ✅ **A. pdf-lib `setRotation` (document-level `/Rotate`).** `page.setRotation(degrees(angle))`
|
|
writes the page's rotation entry; every viewer honors it. Lossless, fast, file size unchanged.
|
|
Existing rotation of a page (from scanners that already set `/Rotate`) is read via
|
|
`page.getRotation().angle` and added to, so the user's delta is always applied on top of the
|
|
current state.
|
|
- B. Rasterize-and-rotate pixels. Unnecessary quality loss and memory cost; rejected.
|
|
|
|
### D2 — Grid capabilities
|
|
|
|
- ✅ **Extend `PdfPageGrid` with an optional action snippet.** The current grid renders fixed
|
|
cards. Add an optional snippet prop (e.g. `pageActions`) rendered below each thumbnail; existing
|
|
call sites (separate, watermark, extract) pass nothing and look unchanged. The rotate page uses
|
|
it for per-page rotate buttons and a rotation badge.
|
|
- B. Fork the grid inside the rotate page. Rejected: duplicates the accessible grid work.
|
|
|
|
---
|
|
|
|
## UX
|
|
|
|
- Route `/tools/rotate`, new `src/lib/tools.ts` entry:
|
|
`{ slug: 'rotate', title: 'Seiten drehen', description: 'Drehen Sie einzelne oder alle Seiten eines PDFs.', icon: 'rotate-cw' }`
|
|
- icon map entry.
|
|
- After load (`loadPdfDocument` + thumbnails like separate):
|
|
- Quick-action bar: **"Alle 90° rechts"**, **"Alle 90° links"**, **"Alle 180°"**,
|
|
**"Zurücksetzen"** (clears all deltas).
|
|
- Page grid: each card shows the thumbnail with a CSS rotation preview
|
|
(`transform: rotate({total}deg)` on the `<img>`, contained in a square-ish frame so rotated
|
|
pages still fit), a small badge when the total rotation is non-zero (e.g. "90°"), and two
|
|
icon buttons (rotate left / rotate right) provided through the new `pageActions` snippet.
|
|
Buttons are real `<button>` elements with `aria-label="Seite 3 um 90° nach rechts drehen"`.
|
|
- Count label: "4 von 12 Seiten gedreht".
|
|
- Primary button **"Drehen & herunterladen"** → `{baseName}_gedreht.pdf`. Disabled when no page
|
|
has a non-zero total rotation change.
|
|
- Keyboard operability is inherent (all buttons); no drag interactions required at all.
|
|
|
|
## Technical approach
|
|
|
|
### State
|
|
|
|
```ts
|
|
let rotations = $state(new Map<number, number>()); // pageNumber → delta degrees (0/90/180/270)
|
|
```
|
|
|
|
- `rotatePage(pageNumber, delta)`: `next = ((current + delta) % 360 + 360) % 360`; store; delete
|
|
entry when back to 0 (keeps "gedreht" count honest).
|
|
- Preview angle: `originalRotation(page) + delta` — the thumbnail renderer cannot know the
|
|
original `/Rotate` without pdf-lib; simplest correct-enough approach: apply only the delta to
|
|
the preview (pdfjs already renders pages honoring `/Rotate`, so the preview matches what the
|
|
user sees plus their delta). No need to read pdf-lib during preview.
|
|
|
|
### Export
|
|
|
|
```ts
|
|
const pdfDoc = await PDFDocument.load(bytes);
|
|
for (const page of pdfDoc.getPages()) {
|
|
const delta = rotations.get(pageIndexPlusOne);
|
|
if (!delta) continue;
|
|
const current = page.getRotation().angle; // respects existing /Rotate
|
|
page.setRotation(degrees((((current + delta) % 360) + 360) % 360));
|
|
}
|
|
downloadBlob(await pdfDoc.save(), `${baseName}_gedreht.pdf`);
|
|
```
|
|
|
|
- Instant for any file size — no rasterization limits needed, but keep the shared load error
|
|
handling (encrypted files: pdf-lib load fails → German error; optionally hint to decrypt like
|
|
the metadata plan does).
|
|
|
|
### `PdfPageGrid` extension (backward compatible)
|
|
|
|
```ts
|
|
type Props = {
|
|
thumbnails: PdfThumbnail[];
|
|
selectedPages?: Set<number>;
|
|
onPageClick?: (pageNumber: number) => void;
|
|
thumbnailWidth?: number;
|
|
class?: string;
|
|
pageActions?: Snippet<[pageNumber: number]>; // NEW, optional
|
|
cardClass?: (pageNumber: number) => string; // NEW, optional — rotate preview transform hook
|
|
};
|
|
```
|
|
|
|
- When `pageActions` is provided, render `{@render pageActions(thumb.pageNumber)}` inside the
|
|
card. When `cardClass` is provided, merge it into the image wrapper via `cn()`. Existing pages
|
|
need no changes.
|
|
|
|
## Files changed
|
|
|
|
- `src/lib/components/PdfPageGrid.svelte` — optional snippet/class props.
|
|
- `src/routes/tools/rotate/+page.svelte` — new route.
|
|
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
|
|
- `tests/e2e/toolbox.spec.ts` — spec block.
|
|
- `agents.md` — structure update.
|
|
|
|
## Implementation phases
|
|
|
|
1. `PdfPageGrid` extension; run existing e2e to prove separate/watermark visuals unchanged.
|
|
2. Rotate route: state, quick actions, grid with badges/buttons, CSS preview rotation.
|
|
3. Export via `setRotation`.
|
|
4. Registry + icon.
|
|
5. Playwright: rotate page 2 by 90° twice via its buttons, download, load output with pdf-lib,
|
|
assert `getPage(1).getRotation().angle === 180`; "Zurücksetzen" disables the download button.
|
|
6. Polish + `pnpm check`/`lint`/`build`/`test:e2e`.
|
|
|
|
## Verification
|
|
|
|
- Single page rotate, all-page rotate, reset flows.
|
|
- A PDF whose pages already carry `/Rotate=90` gains a correct cumulative rotation (assert in e2e
|
|
by creating such a fixture with pdf-lib).
|
|
- Output opens with correct orientation in a viewer; file size ~unchanged.
|
|
- Grid cards stay within layout bounds when rotated (object-contain + fixed aspect frame).
|
|
|
|
## Risks
|
|
|
|
- **CSS preview vs. real rotation mismatch** for pages that already carry `/Rotate`: pdfjs
|
|
thumbnails render the page as displayed; the preview adds the delta visually — acceptable.
|
|
- Rotated landscape images inside the grid may look small in portrait cards; use `object-contain`
|
|
and a square min-height frame.
|
|
|
|
## Out of scope
|
|
|
|
- Auto-detection of upside-down scans (needs heuristics/OCR).
|
|
- Rotating arbitrary selections only via a separate tool (quick actions + per-page buttons cover
|
|
it).
|