Files
bea-edit/plan/done/rotate-plan.md
T
2026-10-06 22:52:35 +02:00

6.0 KiB

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

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

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)

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).