implements page-numbers-plan
This commit is contained in:
1 parent
39da8dac4d
commit
b5279434b2
19 files changed
+1823
-339
No files matched your search
@@ -90,10 +90,13 @@ src/
|
|||||||
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
||||||
│ ├── batch.ts # Sequential batch engine for multi-file tools
|
│ ├── batch.ts # Sequential batch engine for multi-file tools
|
||||||
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
|
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
|
||||||
|
│ ├── pdf-errors.ts # Shared encrypted-PDF detection and German messages
|
||||||
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only)
|
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only)
|
||||||
│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing
|
│ ├── pdf-overlay.ts # Shared overlay positions, preview CSS and color parsing
|
||||||
|
│ ├── pdf-pagination.ts # Sheet-number labels and the pagination pipeline
|
||||||
│ ├── pdf-processing.ts # Reusable PDF merge helper
|
│ ├── pdf-processing.ts # Reusable PDF merge helper
|
||||||
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
|
│ ├── pdf-text-overlay.ts # Shared framed/rotated text overlay drawing
|
||||||
|
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnails, on-demand page previews, cleanup
|
||||||
│ ├── utils.ts # cn() and shared component utility types
|
│ ├── utils.ts # cn() and shared component utility types
|
||||||
│ └── zip-processing.ts # Archive model, transforms, and PDF export
|
│ └── zip-processing.ts # Archive model, transforms, and PDF export
|
||||||
└── routes/
|
└── routes/
|
||||||
@@ -104,7 +107,7 @@ src/
|
|||||||
├── tools/
|
├── tools/
|
||||||
│ ├── +layout.svelte # Shared back-navigation shell
|
│ ├── +layout.svelte # Shared back-navigation shell
|
||||||
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
||||||
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
|
│ └── {pagination,merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
|
||||||
├── datenschutz/+page.svelte # Privacy policy
|
├── datenschutz/+page.svelte # Privacy policy
|
||||||
└── impressum/+page.svelte # Imprint
|
└── impressum/+page.svelte # Imprint
|
||||||
static/ # Public logo, footer art, PWA icons, and robots.txt
|
static/ # Public logo, footer art, PWA icons, and robots.txt
|
||||||
@@ -146,7 +149,13 @@ committed.
|
|||||||
## Application flow and state
|
## Application flow and state
|
||||||
|
|
||||||
- `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and
|
- `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and
|
||||||
forwarded to `/tools/bea`; the file is not uploaded or persisted.
|
forwarded to `/tools/bea`; the file is not uploaded or persisted. The same `queuePendingFiles` /
|
||||||
|
`takePendingFiles` handoff carries the merged PDF from `/tools/merge` to `/tools/pagination`.
|
||||||
|
- `/tools/pagination` is a single-PDF tool: it renders one page at a time through
|
||||||
|
`loadPdfPageRenderer` (never the all-pages thumbnail array), composes every label with
|
||||||
|
`buildPaginationLabel` — the single source for both the live preview and the export — and stamps
|
||||||
|
all pages through `drawTextOverlay`. The page number is always on every page; the optional free
|
||||||
|
text (Aktenzeichen) follows its own “every page” / “first page only” scope.
|
||||||
- `/tools/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document
|
- `/tools/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document
|
||||||
properties with pdf-lib only (no rasterization), returns a discriminated `{ status: 'encrypted' }`
|
properties with pdf-lib only (no rasterization), returns a discriminated `{ status: 'encrypted' }`
|
||||||
result for password-protected files, and detects embedded-file markers by scanning raw bytes.
|
result for password-protected files, and detects embedded-file markers by scanning raw bytes.
|
||||||
@@ -231,6 +240,13 @@ All case-file processing is intentionally local to the browser.
|
|||||||
- Page-set copies (separate/extract tools, PDF splitting) share
|
- Page-set copies (separate/extract tools, PDF splitting) share
|
||||||
`copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy
|
`copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy
|
||||||
semantics cannot drift apart.
|
semantics cannot drift apart.
|
||||||
|
- `src/lib/pdf-text-overlay.ts` owns text fitting, the rotated-box placement and the optional
|
||||||
|
`/Rotate` compensation. Stamp keeps `compensatePageRotation` off so its output does not change;
|
||||||
|
pagination turns it on so numbers stay at the visual position on rotated scans. Share it instead
|
||||||
|
of re-implementing overlay maths in a route.
|
||||||
|
- `src/lib/pdf-thumbnails.ts` offers two loaders: `loadPdfDocument` (all thumbnails, for pickers and
|
||||||
|
page grids) and `loadPdfPageRenderer` (one page at a time, for long documents). Both revoke object
|
||||||
|
URLs and tear down the pdfjs loading task in `destroy()`.
|
||||||
- `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before
|
- `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before
|
||||||
rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
|
rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
|
||||||
safeguards.
|
safeguards.
|
||||||
@@ -243,15 +259,15 @@ called in the browser.
|
|||||||
|
|
||||||
## Batch processing pattern for single-PDF tools
|
## Batch processing pattern for single-PDF tools
|
||||||
|
|
||||||
Tools that transform one PDF per run (compress, watermark, encrypt, …) are being retrofitted to
|
Tools that transform one PDF per run (compress, watermark, stamp, encrypt, decrypt) accept multiple
|
||||||
accept multiple files. Follow the established compress retrofit when adding the pattern to another
|
files. Follow the established compress retrofit when adding the pattern to another tool; see
|
||||||
tool; see `plan/batch-processing-plan.md` for the full design.
|
`plan/done/batch-processing-plan.md` for the full design.
|
||||||
|
|
||||||
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update,
|
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update,
|
||||||
options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PDF in flight for
|
options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PDF in flight for
|
||||||
memory safety), never throws — per-item failures are caught and written to the item as a German
|
memory safety), never throws — per-item failures are caught and written to the item as a German
|
||||||
error message — and consults the optional `shouldProcess` predicate so rows removed mid-run are
|
error message (a string, or a formatter that can inspect the thrown error) — and consults the
|
||||||
skipped.
|
optional `shouldProcess` predicate so rows removed mid-run are skipped.
|
||||||
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip
|
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip
|
||||||
(Wartet / Verarbeite… / Fertig ✓ / Fehler), per-row download, remove, and retry buttons with
|
(Wartet / Verarbeite… / Fertig ✓ / Fehler), per-row download, remove, and retry buttons with
|
||||||
accessible names including the file name, a progress line (“Datei 3 von 7 wird verarbeitet…”), a
|
accessible names including the file name, a progress line (“Datei 3 von 7 wird verarbeitet…”), a
|
||||||
@@ -262,10 +278,11 @@ options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PD
|
|||||||
deduplicate by name + size), pass a `process(file)` callback that reads the current option state
|
deduplicate by name + size), pass a `process(file)` callback that reads the current option state
|
||||||
so option changes mid-run affect only subsequent files, and gate the run button on pending items.
|
so option changes mid-run affect only subsequent files, and gate the run button on pending items.
|
||||||
Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead
|
Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead
|
||||||
of blocking the batch.
|
of blocking the batch. Keep thumbnails, previews, and page-specific selection to a single dropped
|
||||||
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, encrypt
|
file; multi-file runs apply the tool to every page of every file.
|
||||||
`geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the batch
|
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, stamp `stempel`,
|
||||||
flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal).
|
encrypt `geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the
|
||||||
|
batch flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal).
|
||||||
|
|
||||||
## Svelte conventions
|
## Svelte conventions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Plan: Paginierung — fortlaufende Blattnummern mit Aktenzeichen
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a new tool **„Paginieren“** (`/tools/pagination`) that stamps every page of one PDF with an
|
||||||
|
ascending sheet number — the way authorities paginate a case file („Bl. 26“) — optionally combined
|
||||||
|
with a static free text (e.g. an Aktenzeichen `123 Ja 60/26`) that repeats on every page (default) or
|
||||||
|
only on the first page. The same operation is offered as a follow-up action after **„PDFs
|
||||||
|
zusammenfügen“**.
|
||||||
|
|
||||||
|
Context: mail thread with Nikolai Klute (26.09.2026 / 06.10.2026). Two clarifications shape this
|
||||||
|
plan: pagination needs a _simple_ free text next to the tool-generated number (no `{0}/{1}` template
|
||||||
|
language), and that text should repeat „auf der ersten oder auf jeder Seite (Default)“.
|
||||||
|
|
||||||
|
Related: `plan/done/stamp-plan.md` already references this plan as `page-numbers-plan`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open decisions (defaults marked ✅)
|
||||||
|
|
||||||
|
### D1 — Free-text model
|
||||||
|
|
||||||
|
- ✅ **A. Static free text + tool-generated number.** No placeholder syntax: the number is always
|
||||||
|
produced by the tool, the free text is an optional label rendered with it. Directly matches
|
||||||
|
Nikolai's answer („Einfach nur ein Text im Zusammenhang mit der Seitenzahl“).
|
||||||
|
- B. Template field (`Seite {n} von {total}`). Rejected for v1: more validation and support surface
|
||||||
|
than the use case needs; `{total}` is not requested.
|
||||||
|
|
||||||
|
### D2 — What repeats, what increments
|
||||||
|
|
||||||
|
- ✅ **A. Two independent scopes.** Page numbers always on every page (that is the point of
|
||||||
|
pagination). The free text has its own scope: „Auf jeder Seite“ (default) or „Nur auf der ersten
|
||||||
|
Seite“ — on page 2+ only the number remains.
|
||||||
|
- B. One shared scope (text scope also gates numbering). Rejected: a first-page-only pagination is
|
||||||
|
not a thing.
|
||||||
|
|
||||||
|
### D3 — Number format
|
||||||
|
|
||||||
|
- ✅ **A. Select with four fixed formats** — `Bl. {n}` (default), `Seite {n}`, `S. {n}`, `{n}`.
|
||||||
|
Internally a prefix/suffix pair, so no user-visible template language, but „Bl.“ as the
|
||||||
|
German-authority default. Free text is prepended as `„{Text}, “`.
|
||||||
|
- B. Free prefix/suffix inputs. More knobs, more invalid combinations.
|
||||||
|
- C. One free format string. Reopened D1.
|
||||||
|
|
||||||
|
### D4 — Look and placement
|
||||||
|
|
||||||
|
- ✅ **A. Restrained authority style:** Helvetica, 11 pt (slider 8–18), black `#1a1a1a`, no frame,
|
||||||
|
no rotation, default position **unten mittig** with 28 pt margin; the six other positions are
|
||||||
|
selectable via the existing `getPositionCoords` presets.
|
||||||
|
- B. Reuse the stamp tool's framed/bold look. Wrong semantics: a page number is a neutral mark, not
|
||||||
|
an authority stamp.
|
||||||
|
|
||||||
|
### D5 — Files per run
|
||||||
|
|
||||||
|
- ✅ **A. Exactly one PDF.** A Gesamtdokument is paginated in one pass; multi-document cases are
|
||||||
|
handled by „PDFs zusammenfügen“ + handoff. Continuous numbering across several dropped files is a
|
||||||
|
different (and silently wrong) semantic.
|
||||||
|
- B. Batch with the same start number per file. Ambiguous for users; deferred until requested.
|
||||||
|
|
||||||
|
### D6 — Integration into „PDF Zusammenfügen“
|
||||||
|
|
||||||
|
- ✅ **A. Handoff to the pagination tool.** Merge writes the merged bytes into a `File`, queues it via
|
||||||
|
the existing in-memory file handoff, and navigates to `/tools/pagination`, where options and live
|
||||||
|
preview live. One implementation of pagination, preview included.
|
||||||
|
- B. Inline pagination options on the merge page. Duplicated UI and no preview.
|
||||||
|
|
||||||
|
### D7 — Preview renderer
|
||||||
|
|
||||||
|
- ✅ **A. On-demand single-page renderer.** A paginated case file can have hundreds of pages;
|
||||||
|
`loadPdfDocument` rasterises _every_ page up front. A new
|
||||||
|
`loadPdfPageRenderer(bytes)` renders only the visible page and disposes the previous one.
|
||||||
|
- B. Reuse `loadPdfDocument` (all thumbnails). Unacceptable for long documents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
Route `/tools/pagination`, title „Paginieren“, registry entry after `stamp`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
slug: 'pagination',
|
||||||
|
title: 'Paginieren',
|
||||||
|
description: 'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
|
||||||
|
icon: 'list-ordered'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Page layout (stamp tool as the pattern: options left, preview right):
|
||||||
|
|
||||||
|
- **FileDropzone** (single PDF) — „PDF zum Paginieren hinzufügen“; shows the file name and page count
|
||||||
|
after loading.
|
||||||
|
- **Options card**
|
||||||
|
- **Startnummer** (number input, default 1, min 1, max 99999) with hint „Seite 1 erhält diese
|
||||||
|
Nummer.“
|
||||||
|
- **Zusatztext (optional)** — e.g. Aktenzeichen, `maxlength="60"`, placeholder
|
||||||
|
„z. B. 123 Ja 60/26“, plus radio/checkbox „Auf jeder Seite“ (default) / „Nur auf der ersten
|
||||||
|
Seite“.
|
||||||
|
- **Zahlenformat** select: `Bl. {n}`, `Seite {n}`, `S. {n}`, `{n}`.
|
||||||
|
- **Position** select (default „Unten mittig“), **Schriftgröße** slider 8–18 pt (default 11).
|
||||||
|
- Live example line: `123 Ja 60/26, Bl. 26`.
|
||||||
|
- **Preview card** — the current page rendered at reading size with an absolutely positioned overlay
|
||||||
|
(same technique as the stamp preview), page counter „Seite 3 von 42“, prev/next buttons and a
|
||||||
|
„Springe zu Seite“ number input (long documents). The overlay shows the number that page will
|
||||||
|
actually receive (`start + pageIndex`). Label it „Annäherung an die Ausgabe im PDF“.
|
||||||
|
- **Primary action** „Paginieren & herunterladen“ → `{base}_paginiert.pdf`. Options are read at click
|
||||||
|
time, so the button can be pressed again after changing settings.
|
||||||
|
- Errors: encrypted PDF → the shared German hint pointing to „Passwort entfernen“; text too long for
|
||||||
|
a page → German error instead of silent clipping.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
### Shared text overlay (`src/lib/pdf-text-overlay.ts`)
|
||||||
|
|
||||||
|
Extract the reusable parts of `src/routes/tools/stamp/+page.svelte` into a module used by stamp,
|
||||||
|
pagination and exhibit numbering:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type TextOverlayOptions = {
|
||||||
|
font: PDFFont;
|
||||||
|
text: string;
|
||||||
|
size: number;
|
||||||
|
color: RGB;
|
||||||
|
position: OverlayPosition;
|
||||||
|
margin?: number;
|
||||||
|
padding?: number;
|
||||||
|
framed?: boolean;
|
||||||
|
borderWidth?: number;
|
||||||
|
rotation?: number; // degrees
|
||||||
|
minSize?: number; // shrink floor before throwing
|
||||||
|
compensatePageRotation?: boolean; // default false; new tools set true
|
||||||
|
};
|
||||||
|
|
||||||
|
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions) => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Owns fit-to-page shrinking (loop down to `minSize`), the rotated placement maths already present in
|
||||||
|
the stamp route (`getRotatedStampPlacement`), optional border, and the text draw.
|
||||||
|
- The stamp route is rewired to call it with `framed: true`, `rotation: value` and its bold font;
|
||||||
|
behaviour must stay identical (guard: `tests/e2e/stamp.spec.ts`).
|
||||||
|
- `compensatePageRotation` maps bottom-center on a `/Rotate 90` scan to the correct user-space anchor
|
||||||
|
and counter-rotates the text. Stamp keeps `false` so its output does not change.
|
||||||
|
- Move `isEncryptedPdfError` / the encrypted-PDF message into `src/lib/pdf-errors.ts` so all three
|
||||||
|
tools share one wording.
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const numberText = `${formatPrefix}${startNumber + pageIndex}${formatSuffix}`;
|
||||||
|
const textVisible = textScope === 'all' || pageIndex === 0;
|
||||||
|
const line = textVisible && freeText.trim() ? `${freeText.trim()}, ${numberText}` : numberText;
|
||||||
|
|
||||||
|
for (const [pageIndex, page] of pdfDoc.getPages().entries()) {
|
||||||
|
drawTextOverlay(page, {
|
||||||
|
font,
|
||||||
|
text: line,
|
||||||
|
size,
|
||||||
|
color: parseHexColor('#1a1a1a'),
|
||||||
|
position,
|
||||||
|
margin: 28,
|
||||||
|
compensatePageRotation: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Embed one `StandardFonts.Helvetica` per document, save, name `${base}_paginiert.pdf`.
|
||||||
|
- Page count comes from pdfjs (`numPages`) for the preview and from pdf-lib for processing; both are
|
||||||
|
cheap, no full rasterisation.
|
||||||
|
- **Renderer:** extend `src/lib/pdf-thumbnails.ts` with
|
||||||
|
`loadPdfPageRenderer(bytes): Promise<{ pageCount, render(pageNumber), destroy() }>`; `render`
|
||||||
|
builds the canvas from the requested page's own viewport (mixed page sizes stay correct), revokes
|
||||||
|
the previous object URL, and `destroy` tears down the pdfjs loading task. Reused by the exhibit
|
||||||
|
tool's page-1 preview.
|
||||||
|
|
||||||
|
### Merge integration
|
||||||
|
|
||||||
|
- Generalise `src/lib/pending-files.ts`: `queueZipFiles` / `takeQueuedZipFiles` become
|
||||||
|
`queuePendingFiles` / `takePendingFiles` (same behaviour; update `src/routes/+page.svelte` and
|
||||||
|
`src/routes/tools/bea/+page.svelte`).
|
||||||
|
- `src/routes/tools/merge/+page.svelte`: keep „PDFs zusammenfügen“ (direct download) and add
|
||||||
|
„Zusammenfügen & paginieren“, which merges, wraps the bytes in
|
||||||
|
`new File([bytes], 'zusammengefuegt.pdf', { type: 'application/pdf' })`, queues it and calls
|
||||||
|
`goto('/tools/pagination')`. Keep the change minimal — the page still uses older styling tokens;
|
||||||
|
do not restyle it in this task.
|
||||||
|
- The pagination route consumes `takePendingFiles()` in `onMount`, accepts the first PDF and ignores
|
||||||
|
anything else defensively.
|
||||||
|
|
||||||
|
### Registry and docs
|
||||||
|
|
||||||
|
- `src/lib/tools.ts` + `iconMap` in `src/routes/+page.svelte` (`ListOrdered`).
|
||||||
|
- `agents.md`: new route, shared overlay/error helpers, renderer, pending-file naming, merge handoff.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `src/routes/tools/pagination/+page.svelte` — new route.
|
||||||
|
- `src/lib/pdf-text-overlay.ts`, `src/lib/pdf-errors.ts` — extracted shared helpers.
|
||||||
|
- `src/routes/tools/stamp/+page.svelte` — use the shared helpers (no behaviour change).
|
||||||
|
- `src/lib/pdf-thumbnails.ts` — add `loadPdfPageRenderer`.
|
||||||
|
- `src/lib/pending-files.ts`, `src/routes/+page.svelte`, `src/routes/tools/bea/+page.svelte` —
|
||||||
|
generic pending-file naming.
|
||||||
|
- `src/routes/tools/merge/+page.svelte` — handoff button.
|
||||||
|
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
|
||||||
|
- `tests/e2e/pagination.spec.ts`, `tests/e2e/merge.spec.ts` — new/updated specs.
|
||||||
|
- `agents.md` — structure and flow documentation.
|
||||||
|
|
||||||
|
## Implementation phases
|
||||||
|
|
||||||
|
1. Extract `pdf-text-overlay.ts` + `pdf-errors.ts`, rewire stamp, keep `stamp.spec.ts` green.
|
||||||
|
2. Add `loadPdfPageRenderer` and use it for the preview.
|
||||||
|
3. Build the pagination route (options, live preview, export, errors).
|
||||||
|
4. Merge handoff + generic pending files.
|
||||||
|
5. Registry/icon/docs.
|
||||||
|
6. Playwright coverage + command gauntlet.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 3-page PDF, start 26, text `123 Ja 60/26`, all pages → each page's content stream contains
|
||||||
|
`123 Ja 60/26, Bl. 26/27/28`; page count and page order preserved; download named
|
||||||
|
`akte_paginiert.pdf`.
|
||||||
|
- First-page-only text → page 1 contains the Aktenzeichen, page 2 contains `Bl. 2` but not the
|
||||||
|
Aktenzeichen; the original page content is preserved on every page.
|
||||||
|
- Formats `Seite {n}` / `{n}` and empty free text → expected strings.
|
||||||
|
- Preview: navigating to page 2 shows `Bl. 27` (start 26); changing the start number updates the
|
||||||
|
preview before export.
|
||||||
|
- Rotated input (`createPreRotatedPdf`) → number appears at the visual bottom-center (test asserts
|
||||||
|
the draw coordinates in the content stream or compares stream text plus position).
|
||||||
|
- Merge: two PDFs (1 + 2 pages) → „Zusammenfügen & paginieren“ lands on `/tools/pagination` with a
|
||||||
|
3-page document; running produces continuous `Bl. 1…3`.
|
||||||
|
- Encrypted PDF → German error, no download.
|
||||||
|
- `pnpm check`, targeted `pnpm test:e2e`, `pnpm build`, Prettier on touched files.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Long documents:** never call `loadPdfDocument` here; one page in memory at a time. The preview
|
||||||
|
navigation must not leak object URLs or pdfjs tasks.
|
||||||
|
- **Text length on narrow pages:** fit-to-page shrinks to `minSize`; below that, fail with a German
|
||||||
|
message rather than clipping.
|
||||||
|
- **Unsupported characters:** StandardFonts use WinAnsi; validate the free text (e.g. no emoji) and
|
||||||
|
show a German hint instead of a pdf-lib save error.
|
||||||
|
- **Rotated pages:** compensation is new logic; if the mapping turns out unreliable, ship v1 with a
|
||||||
|
visible warning for rotated pages instead of silently misplaced numbers.
|
||||||
|
- **Preview fidelity:** browser font metrics differ slightly from pdf-lib; the panel is labelled as
|
||||||
|
an approximation.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Placeholder templates (`{total}`, Roman numerals, ranges) and numbering only selected pages.
|
||||||
|
- Multiple files per run with continuous numbering (use merge first).
|
||||||
|
- Persisted pagination settings.
|
||||||
|
- Automatic pagination inside the beA archive export.
|
||||||
+7
-3
@@ -12,8 +12,11 @@ export type BatchItem = {
|
|||||||
export type BatchProcessResult = { bytes: Uint8Array; name: string };
|
export type BatchProcessResult = { bytes: Uint8Array; name: string };
|
||||||
|
|
||||||
export type BatchRunOptions = {
|
export type BatchRunOptions = {
|
||||||
/** Error message written to a row when `process` throws for it. */
|
/**
|
||||||
errorMessage?: string;
|
* Error message written to a row when `process` throws for it. A function
|
||||||
|
* receives the thrown error so tools can surface a specific German message.
|
||||||
|
*/
|
||||||
|
errorMessage?: string | ((error: unknown, file: File) => string);
|
||||||
/**
|
/**
|
||||||
* Optional predicate consulted before each item; when it returns `false`
|
* Optional predicate consulted before each item; when it returns `false`
|
||||||
* (for example because the row was removed mid-run), the item is skipped.
|
* (for example because the row was removed mid-run), the item is skipped.
|
||||||
@@ -58,7 +61,8 @@ export const runBatch = async (
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
resultBytes: undefined,
|
resultBytes: undefined,
|
||||||
resultName: undefined,
|
resultName: undefined,
|
||||||
errorMessage
|
errorMessage:
|
||||||
|
typeof errorMessage === 'function' ? errorMessage(error, item.file) : errorMessage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const ENCRYPTED_PDF_PATTERN = /password|encrypted|encryption|verschl/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pdf-lib (`EncryptedPDFError`) and pdfjs (`PasswordException`) both surface
|
||||||
|
* password protection through the error name/message. Tools use this to show a
|
||||||
|
* single German hint instead of a generic failure.
|
||||||
|
*/
|
||||||
|
export const isEncryptedPdfError = (error: unknown): boolean => {
|
||||||
|
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error);
|
||||||
|
return ENCRYPTED_PDF_PATTERN.test(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENCRYPTED_PDF_MESSAGE =
|
||||||
|
'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.';
|
||||||
|
|
||||||
|
export const pdfLoadErrorMessage = (error: unknown): string =>
|
||||||
|
isEncryptedPdfError(error)
|
||||||
|
? ENCRYPTED_PDF_MESSAGE
|
||||||
|
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
|
||||||
|
|
||||||
|
/** Shared row/banner message for PDFs that could not be transformed. */
|
||||||
|
export const pdfProcessingErrorMessage = (error: unknown, fallback: string): string => {
|
||||||
|
if (isEncryptedPdfError(error)) return ENCRYPTED_PDF_MESSAGE;
|
||||||
|
if (error instanceof Error && /^Der (Stempel)?text/.test(error.message)) return error.message;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
@@ -50,3 +50,26 @@ export const parseHexColor = (hexColor: string): RGB => {
|
|||||||
Number.parseInt(match[3], 16) / 255
|
Number.parseInt(match[3], 16) / 255
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS for the absolutely positioned overlay used by browser previews. The 5%
|
||||||
|
* inset approximates the PDF margin at reading size.
|
||||||
|
*/
|
||||||
|
export const getPreviewPositionStyle = (position: OverlayPosition): string => {
|
||||||
|
switch (position) {
|
||||||
|
case 'center':
|
||||||
|
return 'left: 50%; top: 50%; transform: translate(-50%, -50%);';
|
||||||
|
case 'top-left':
|
||||||
|
return 'left: 5%; top: 5%;';
|
||||||
|
case 'top-center':
|
||||||
|
return 'left: 50%; top: 5%; transform: translateX(-50%);';
|
||||||
|
case 'top-right':
|
||||||
|
return 'right: 5%; top: 5%;';
|
||||||
|
case 'bottom-left':
|
||||||
|
return 'bottom: 5%; left: 5%;';
|
||||||
|
case 'bottom-center':
|
||||||
|
return 'bottom: 5%; left: 50%; transform: translateX(-50%);';
|
||||||
|
case 'bottom-right':
|
||||||
|
return 'bottom: 5%; right: 5%;';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
import { parseHexColor, type OverlayPosition } from './pdf-overlay';
|
||||||
|
import { drawTextOverlay } from './pdf-text-overlay';
|
||||||
|
|
||||||
|
export const MIN_START_NUMBER = 1;
|
||||||
|
export const MAX_START_NUMBER = 99999;
|
||||||
|
export const PAGE_NUMBER_MARGIN = 28;
|
||||||
|
export const PAGE_NUMBER_COLOR = '#1a1a1a';
|
||||||
|
export const MIN_FONT_SIZE = 8;
|
||||||
|
export const MAX_FONT_SIZE = 18;
|
||||||
|
|
||||||
|
export const PAGINATION_FORMATS = [
|
||||||
|
{ value: 'blatt', label: 'Bl. {n}', prefix: 'Bl. ' },
|
||||||
|
{ value: 'seite', label: 'Seite {n}', prefix: 'Seite ' },
|
||||||
|
{ value: 's', label: 'S. {n}', prefix: 'S. ' },
|
||||||
|
{ value: 'nummer', label: 'Nur Nummer', prefix: '' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PaginationFormat = (typeof PAGINATION_FORMATS)[number]['value'];
|
||||||
|
export type PaginationTextScope = 'all' | 'first';
|
||||||
|
|
||||||
|
export type PaginationOptions = {
|
||||||
|
startNumber: number;
|
||||||
|
freeText: string;
|
||||||
|
textScope: PaginationTextScope;
|
||||||
|
format: PaginationFormat;
|
||||||
|
position: OverlayPosition;
|
||||||
|
fontSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeStartNumber = (value: number): number =>
|
||||||
|
Math.min(
|
||||||
|
MAX_START_NUMBER,
|
||||||
|
Math.max(MIN_START_NUMBER, Math.round(Number(value) || MIN_START_NUMBER))
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFormatPrefix = (format: PaginationFormat): string =>
|
||||||
|
(PAGINATION_FORMATS.find((entry) => entry.value === format) ?? PAGINATION_FORMATS[0]).prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheet label for one page: `123 Ja 60/26, Bl. 27`. The number always appears;
|
||||||
|
* the free text only on the first page when that scope is selected.
|
||||||
|
*/
|
||||||
|
export const buildPaginationLabel = (
|
||||||
|
pageIndex: number,
|
||||||
|
options: Pick<PaginationOptions, 'startNumber' | 'freeText' | 'textScope' | 'format'>
|
||||||
|
): string => {
|
||||||
|
const numberText = `${getFormatPrefix(options.format)}${normalizeStartNumber(options.startNumber) + pageIndex}`;
|
||||||
|
const freeText = options.freeText.trim();
|
||||||
|
const showsFreeText = options.textScope === 'all' || pageIndex === 0;
|
||||||
|
|
||||||
|
return showsFreeText && freeText ? `${freeText}, ${numberText}` : numberText;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamps every page with its ascending sheet number. Rotated scans are
|
||||||
|
* compensated so the number stays at the visual position.
|
||||||
|
*/
|
||||||
|
export const paginatePdf = async (
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: PaginationOptions
|
||||||
|
): Promise<Uint8Array> => {
|
||||||
|
const document = await PDFDocument.load(new Uint8Array(bytes));
|
||||||
|
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||||
|
const color = parseHexColor(PAGE_NUMBER_COLOR);
|
||||||
|
|
||||||
|
for (const [pageIndex, page] of document.getPages().entries()) {
|
||||||
|
drawTextOverlay(page, {
|
||||||
|
font,
|
||||||
|
text: buildPaginationLabel(pageIndex, options),
|
||||||
|
size: options.fontSize,
|
||||||
|
color,
|
||||||
|
position: options.position,
|
||||||
|
margin: PAGE_NUMBER_MARGIN,
|
||||||
|
padding: 0,
|
||||||
|
compensatePageRotation: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.save();
|
||||||
|
};
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { degrees, type PDFFont, type PDFPage, type RGB } from 'pdf-lib';
|
||||||
|
import { getPositionCoords, type OverlayPosition } from './pdf-overlay';
|
||||||
|
|
||||||
|
const DEFAULT_MARGIN = 20;
|
||||||
|
const DEFAULT_PADDING = 8;
|
||||||
|
const DEFAULT_MIN_SIZE = 4;
|
||||||
|
const DEFAULT_BORDER_WIDTH = 2;
|
||||||
|
/** Baseline nudge used by the framed text stamps so glyphs sit inside the border. */
|
||||||
|
const BASELINE_OFFSET_RATIO = 0.2;
|
||||||
|
const DEFAULT_TOO_LONG_MESSAGE =
|
||||||
|
'Der Text ist für mindestens eine Seite zu lang. Kürzen Sie den Text.';
|
||||||
|
|
||||||
|
export type TextOverlayOptions = {
|
||||||
|
font: PDFFont;
|
||||||
|
text: string;
|
||||||
|
size: number;
|
||||||
|
color: RGB;
|
||||||
|
position: OverlayPosition;
|
||||||
|
/** Distance between the mark and the page edge in points. */
|
||||||
|
margin?: number;
|
||||||
|
/** Inner box padding; use `0` for a frameless, anchor-aligned mark. */
|
||||||
|
padding?: number;
|
||||||
|
framed?: boolean;
|
||||||
|
borderWidth?: number;
|
||||||
|
/** Additional counter-clockwise rotation of the mark in degrees. */
|
||||||
|
rotation?: number;
|
||||||
|
/** Font size floor while shrinking to fit the page. */
|
||||||
|
minSize?: number;
|
||||||
|
/**
|
||||||
|
* Place the mark on rotated scans (`/Rotate 90`, `180`, `270`) where it is
|
||||||
|
* visible instead of sideways. Opt-in so existing tools keep their output.
|
||||||
|
*/
|
||||||
|
compensatePageRotation?: boolean;
|
||||||
|
tooLongMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextBox = {
|
||||||
|
size: number;
|
||||||
|
textHeight: number;
|
||||||
|
boxWidth: number;
|
||||||
|
boxHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureText = (font: PDFFont, text: string, size: number) => {
|
||||||
|
try {
|
||||||
|
return { width: font.widthOfTextAtSize(text, size), height: font.heightAtSize(size) };
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'Der Text enthält Zeichen, die nicht dargestellt werden können. Entfernen Sie z. B. Emoji.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitTextToPage = (
|
||||||
|
font: PDFFont,
|
||||||
|
text: string,
|
||||||
|
requestedSize: number,
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
margin: number,
|
||||||
|
padding: number,
|
||||||
|
minSize: number,
|
||||||
|
tooLongMessage: string
|
||||||
|
): TextBox => {
|
||||||
|
const availableWidth = pageWidth - margin * 2;
|
||||||
|
const availableHeight = pageHeight - margin * 2;
|
||||||
|
let size = requestedSize;
|
||||||
|
let metrics = measureText(font, text, size);
|
||||||
|
|
||||||
|
while (
|
||||||
|
size > minSize &&
|
||||||
|
(metrics.width + padding * 2 > availableWidth || metrics.height + padding * 2 > availableHeight)
|
||||||
|
) {
|
||||||
|
size = Math.max(minSize, size - 0.5);
|
||||||
|
metrics = measureText(font, text, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
metrics.width + padding * 2 > availableWidth ||
|
||||||
|
metrics.height + padding * 2 > availableHeight
|
||||||
|
) {
|
||||||
|
throw new Error(tooLongMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
size,
|
||||||
|
textHeight: metrics.height,
|
||||||
|
boxWidth: metrics.width + padding * 2,
|
||||||
|
boxHeight: metrics.height + padding * 2
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower-left corner for a (possibly rotated) box inside the given page size.
|
||||||
|
* The bounding box of the rotated mark is aligned to the requested position, so
|
||||||
|
* tilted text never crosses the page edge.
|
||||||
|
*/
|
||||||
|
export const getRotatedBoxPlacement = (
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
boxWidth: number,
|
||||||
|
boxHeight: number,
|
||||||
|
position: OverlayPosition,
|
||||||
|
angle: number,
|
||||||
|
margin = DEFAULT_MARGIN
|
||||||
|
) => {
|
||||||
|
const radians = (angle * Math.PI) / 180;
|
||||||
|
const cosine = Math.cos(radians);
|
||||||
|
const sine = Math.sin(radians);
|
||||||
|
const rotatedCorners = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: boxWidth * cosine, y: boxWidth * sine },
|
||||||
|
{ x: -boxHeight * sine, y: boxHeight * cosine },
|
||||||
|
{
|
||||||
|
x: boxWidth * cosine - boxHeight * sine,
|
||||||
|
y: boxWidth * sine + boxHeight * cosine
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
|
||||||
|
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
|
||||||
|
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
|
||||||
|
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
|
||||||
|
const boundingCoordinates = getPositionCoords(
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
maxX - minX,
|
||||||
|
maxY - minY,
|
||||||
|
position,
|
||||||
|
margin
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: boundingCoordinates.x - minX,
|
||||||
|
y: boundingCoordinates.y - minY,
|
||||||
|
cosine,
|
||||||
|
sine
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Normalised page rotation; anything that is not a multiple of 90 is ignored. */
|
||||||
|
const getPageRotation = (page: PDFPage): number => {
|
||||||
|
const angle = Math.round(page.getRotation().angle / 90) * 90;
|
||||||
|
return ((angle % 360) + 360) % 360;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Displayed page size after applying `/Rotate`. */
|
||||||
|
const getVisualPageSize = (pageWidth: number, pageHeight: number, pageRotation: number) =>
|
||||||
|
pageRotation % 180 === 0
|
||||||
|
? { width: pageWidth, height: pageHeight }
|
||||||
|
: { width: pageHeight, height: pageWidth };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a point on the displayed page back into default user space. Viewers
|
||||||
|
* rotate page content clockwise by `/Rotate`, so this is the inverse rotation
|
||||||
|
* that keeps a mark at the requested visual position.
|
||||||
|
*/
|
||||||
|
const toUserSpace = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
pageRotation: number
|
||||||
|
) => {
|
||||||
|
switch (pageRotation) {
|
||||||
|
case 90:
|
||||||
|
return { x: pageWidth - point.y, y: point.x };
|
||||||
|
case 180:
|
||||||
|
return { x: pageWidth - point.x, y: pageHeight - point.y };
|
||||||
|
case 270:
|
||||||
|
return { x: point.y, y: pageHeight - point.x };
|
||||||
|
default:
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a single line of text (optionally framed) at one of the standard
|
||||||
|
* positions. Shrinks the font to fit the page and throws a German error instead
|
||||||
|
* of clipping.
|
||||||
|
*/
|
||||||
|
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): void => {
|
||||||
|
const {
|
||||||
|
font,
|
||||||
|
text,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
position,
|
||||||
|
margin = DEFAULT_MARGIN,
|
||||||
|
padding = DEFAULT_PADDING,
|
||||||
|
framed = false,
|
||||||
|
borderWidth = DEFAULT_BORDER_WIDTH,
|
||||||
|
rotation = 0,
|
||||||
|
minSize = DEFAULT_MIN_SIZE,
|
||||||
|
compensatePageRotation = false,
|
||||||
|
tooLongMessage = DEFAULT_TOO_LONG_MESSAGE
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
const pageRotation = compensatePageRotation ? getPageRotation(page) : 0;
|
||||||
|
const visual = getVisualPageSize(width, height, pageRotation);
|
||||||
|
const box = fitTextToPage(
|
||||||
|
font,
|
||||||
|
text,
|
||||||
|
size,
|
||||||
|
visual.width,
|
||||||
|
visual.height,
|
||||||
|
margin,
|
||||||
|
padding,
|
||||||
|
minSize,
|
||||||
|
tooLongMessage
|
||||||
|
);
|
||||||
|
const placement = getRotatedBoxPlacement(
|
||||||
|
visual.width,
|
||||||
|
visual.height,
|
||||||
|
box.boxWidth,
|
||||||
|
box.boxHeight,
|
||||||
|
position,
|
||||||
|
rotation,
|
||||||
|
margin
|
||||||
|
);
|
||||||
|
const origin = toUserSpace({ x: placement.x, y: placement.y }, width, height, pageRotation);
|
||||||
|
const angle = rotation + pageRotation;
|
||||||
|
const radians = (angle * Math.PI) / 180;
|
||||||
|
const cosine = Math.cos(radians);
|
||||||
|
const sine = Math.sin(radians);
|
||||||
|
const offsetX = padding;
|
||||||
|
const offsetY = padding + box.textHeight * BASELINE_OFFSET_RATIO;
|
||||||
|
|
||||||
|
if (framed) {
|
||||||
|
page.drawRectangle({
|
||||||
|
x: origin.x,
|
||||||
|
y: origin.y,
|
||||||
|
width: box.boxWidth,
|
||||||
|
height: box.boxHeight,
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page.drawText(text, {
|
||||||
|
x: origin.x + offsetX * cosine - offsetY * sine,
|
||||||
|
y: origin.y + offsetX * sine + offsetY * cosine,
|
||||||
|
size: box.size,
|
||||||
|
font,
|
||||||
|
color,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
};
|
||||||
+100
-9
@@ -11,7 +11,27 @@ export type PdfDocumentHandle = {
|
|||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
|
/** Pixel scale used for on-demand page previews. */
|
||||||
|
export const PDF_PREVIEW_SCALE = 1.25;
|
||||||
|
|
||||||
|
export type PdfPagePreview = {
|
||||||
|
pageNumber: number;
|
||||||
|
imageUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one page at a time. Used by tools that operate on a whole document
|
||||||
|
* (pagination) where rasterising every page up front would exhaust memory.
|
||||||
|
*/
|
||||||
|
export type PdfPageRenderer = {
|
||||||
|
pageCount: number;
|
||||||
|
render: (pageNumber: number) => Promise<PdfPagePreview>;
|
||||||
|
destroy: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPdfJs = async () => {
|
||||||
const pdfjsLib = await import('pdfjs-dist');
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
|
||||||
@@ -19,6 +39,20 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
|
|||||||
(module) => module.default
|
(module) => module.default
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return getDocument;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canvasToPngBlob = (canvas: HTMLCanvasElement) =>
|
||||||
|
new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(result) => (result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.'))),
|
||||||
|
'image/png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
|
||||||
|
const getDocument = await loadPdfJs();
|
||||||
|
|
||||||
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
||||||
const thumbnails: PdfThumbnail[] = [];
|
const thumbnails: PdfThumbnail[] = [];
|
||||||
|
|
||||||
@@ -36,17 +70,10 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
|
|||||||
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
|
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
|
||||||
|
|
||||||
await page.render({ canvas, canvasContext, viewport }).promise;
|
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
||||||
canvas.toBlob(
|
|
||||||
(result) =>
|
|
||||||
result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
|
|
||||||
'image/png'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
thumbnails.push({
|
thumbnails.push({
|
||||||
pageNumber,
|
pageNumber,
|
||||||
imageUrl: URL.createObjectURL(blob),
|
imageUrl: URL.createObjectURL(await canvasToPngBlob(canvas)),
|
||||||
width: viewport.width,
|
width: viewport.width,
|
||||||
height: viewport.height
|
height: viewport.height
|
||||||
});
|
});
|
||||||
@@ -69,3 +96,67 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadPdfPageRenderer(data: Uint8Array): Promise<PdfPageRenderer> {
|
||||||
|
const getDocument = await loadPdfJs();
|
||||||
|
|
||||||
|
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
||||||
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
|
||||||
|
let destroyed = false;
|
||||||
|
let renderToken = 0;
|
||||||
|
let currentUrl: string | null = null;
|
||||||
|
|
||||||
|
const releaseUrl = () => {
|
||||||
|
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
||||||
|
currentUrl = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageCount: pdfDocument.numPages,
|
||||||
|
render: async (pageNumber) => {
|
||||||
|
if (destroyed) throw new Error('Die PDF-Vorschau wurde bereits geschlossen.');
|
||||||
|
|
||||||
|
const token = ++renderToken;
|
||||||
|
const clampedPage = Math.min(
|
||||||
|
pdfDocument.numPages,
|
||||||
|
Math.max(1, Math.round(Number(pageNumber) || 1))
|
||||||
|
);
|
||||||
|
const page = await pdfDocument.getPage(clampedPage);
|
||||||
|
const viewport = page.getViewport({ scale: PDF_PREVIEW_SCALE });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const canvasContext = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!canvasContext) throw new Error('Could not create preview canvas context.');
|
||||||
|
|
||||||
|
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||||
|
const imageUrl = URL.createObjectURL(await canvasToPngBlob(canvas));
|
||||||
|
|
||||||
|
// A newer render (or unmount) superseded this one: drop the bitmap
|
||||||
|
// instead of leaving an orphaned object URL behind.
|
||||||
|
if (destroyed || token !== renderToken) {
|
||||||
|
URL.revokeObjectURL(imageUrl);
|
||||||
|
throw new Error('Die PDF-Vorschau wurde zwischenzeitlich geändert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseUrl();
|
||||||
|
currentUrl = imageUrl;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageNumber: clampedPage,
|
||||||
|
imageUrl,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height
|
||||||
|
};
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
renderToken += 1;
|
||||||
|
releaseUrl();
|
||||||
|
void loadingTask.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
let pendingZipFiles: File[] = [];
|
let pendingFiles: File[] = [];
|
||||||
|
|
||||||
export const queueZipFiles = (files: File[]) => {
|
/**
|
||||||
pendingZipFiles = [...pendingZipFiles, ...files];
|
* In-memory handoff between routes (start page → beA workspace, merge →
|
||||||
|
* pagination). Nothing is uploaded or persisted; the receiving route takes and
|
||||||
|
* clears the queue on mount.
|
||||||
|
*/
|
||||||
|
export const queuePendingFiles = (files: File[]) => {
|
||||||
|
pendingFiles = [...pendingFiles, ...files];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const takeQueuedZipFiles = () => {
|
export const takePendingFiles = () => {
|
||||||
const files = pendingZipFiles;
|
const files = pendingFiles;
|
||||||
pendingZipFiles = [];
|
pendingFiles = [];
|
||||||
return files;
|
return files;
|
||||||
};
|
};
|
||||||
@@ -12,6 +12,13 @@ export const tools: Tool[] = [
|
|||||||
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
|
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
|
||||||
icon: 'stamp'
|
icon: 'stamp'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: 'pagination',
|
||||||
|
title: 'Paginieren',
|
||||||
|
description:
|
||||||
|
'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
|
||||||
|
icon: 'list-ordered'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: 'watermark',
|
slug: 'watermark',
|
||||||
title: 'Wasserzeichen',
|
title: 'Wasserzeichen',
|
||||||
|
|||||||
+12
-10
@@ -9,6 +9,7 @@
|
|||||||
Scissors,
|
Scissors,
|
||||||
Merge,
|
Merge,
|
||||||
Stamp,
|
Stamp,
|
||||||
|
ListOrdered,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Info,
|
Info,
|
||||||
FileText
|
FileText
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import * as Card from '$lib/components/ui/card/index';
|
import * as Card from '$lib/components/ui/card/index';
|
||||||
import { queueZipFiles } from '$lib/pending-files';
|
import { queuePendingFiles } from '$lib/pending-files';
|
||||||
import { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
|
import { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
|
||||||
import { tools } from '$lib/tools';
|
import { tools } from '$lib/tools';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
scissors: Scissors,
|
scissors: Scissors,
|
||||||
merge: Merge,
|
merge: Merge,
|
||||||
stamp: Stamp,
|
stamp: Stamp,
|
||||||
|
'list-ordered': ListOrdered,
|
||||||
'rotate-cw': RotateCw,
|
'rotate-cw': RotateCw,
|
||||||
info: Info
|
info: Info
|
||||||
};
|
};
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
const workspaceFiles = files.filter(isAcceptedWorkspaceFile);
|
const workspaceFiles = files.filter(isAcceptedWorkspaceFile);
|
||||||
if (workspaceFiles.length === 0) return;
|
if (workspaceFiles.length === 0) return;
|
||||||
|
|
||||||
queueZipFiles(workspaceFiles);
|
queuePendingFiles(workspaceFiles);
|
||||||
void goto('/tools/bea');
|
void goto('/tools/bea');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,12 +64,12 @@
|
|||||||
<div class="flex flex-col gap-10 px-4 py-10">
|
<div class="flex flex-col gap-10 px-4 py-10">
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<div class="flex flex-col items-center gap-2 text-center">
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
<h1 class="text-[28px] font-bold text-primary-900">Werkzeugkasten für beA</h1>
|
<h1 class="text-primary-900 text-[28px] font-bold">Werkzeugkasten für beA</h1>
|
||||||
<p class="max-w-lg text-[15px] font-medium text-primary-700">
|
<p class="text-primary-700 max-w-lg text-[15px] font-medium">
|
||||||
Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
|
Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
|
||||||
praktische PDF-Werkzeuge.
|
praktische PDF-Werkzeuge.
|
||||||
</p>
|
</p>
|
||||||
<p class="max-w-lg text-[13px] text-primary-700">
|
<p class="text-primary-700 max-w-lg text-[13px]">
|
||||||
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
|
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +86,7 @@
|
|||||||
icon={Archive}
|
icon={Archive}
|
||||||
class="py-10"
|
class="py-10"
|
||||||
/>
|
/>
|
||||||
<p class="mt-2 text-center text-[12px] text-primary-600">
|
<p class="text-primary-600 mt-2 text-center text-[12px]">
|
||||||
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF. PDFs
|
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF. PDFs
|
||||||
können direkt in Teildokumente aufgeteilt werden.
|
können direkt in Teildokumente aufgeteilt werden.
|
||||||
</p>
|
</p>
|
||||||
@@ -92,12 +94,12 @@
|
|||||||
|
|
||||||
<!-- Tool grid -->
|
<!-- Tool grid -->
|
||||||
<div class="mx-auto w-full max-w-4xl">
|
<div class="mx-auto w-full max-w-4xl">
|
||||||
<h2 class="mb-4 text-[18px] font-bold text-primary-900">Weitere PDF-Werkzeuge</h2>
|
<h2 class="text-primary-900 mb-4 text-[18px] font-bold">Weitere PDF-Werkzeuge</h2>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each tools as tool}
|
{#each tools as tool}
|
||||||
{@const Icon = iconMap[tool.icon] ?? FileText}
|
{@const Icon = iconMap[tool.icon] ?? FileText}
|
||||||
<a href="/tools/{tool.slug}" class="block">
|
<a href="/tools/{tool.slug}" class="block">
|
||||||
<Card.Root class="h-full transition hover:shadow-md cursor-pointer">
|
<Card.Root class="h-full cursor-pointer transition hover:shadow-md">
|
||||||
<Card.Content class="flex flex-col gap-3 py-6">
|
<Card.Content class="flex flex-col gap-3 py-6">
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
|
||||||
@@ -105,8 +107,8 @@
|
|||||||
<Icon class="h-5 w-5" />
|
<Icon class="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-[15px] font-bold text-primary-900">{tool.title}</h3>
|
<h3 class="text-primary-900 text-[15px] font-bold">{tool.title}</h3>
|
||||||
<p class="mt-1 text-[13px] leading-snug text-primary-700">{tool.description}</p>
|
<p class="text-primary-700 mt-1 text-[13px] leading-snug">{tool.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
} from '$lib/zip-processing';
|
} from '$lib/zip-processing';
|
||||||
import { isAcceptedPdfFile } from '$lib/zip-selection';
|
import { isAcceptedPdfFile } from '$lib/zip-selection';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
import { takeQueuedZipFiles } from '$lib/pending-files';
|
import { takePendingFiles } from '$lib/pending-files';
|
||||||
import * as Card from '$lib/components/ui/card/index';
|
import * as Card from '$lib/components/ui/card/index';
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
|
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
thumbnailWidthHydrated = true;
|
thumbnailWidthHydrated = true;
|
||||||
|
|
||||||
const queuedFiles = takeQueuedZipFiles();
|
const queuedFiles = takePendingFiles();
|
||||||
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
|
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,6 +201,6 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
|
<h1 class="h1 text-primary-900 text-[20px] font-bold">beA-Edit</h1>
|
||||||
{@render content()}
|
{@render content()}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { queuePendingFiles } from '$lib/pending-files';
|
||||||
import { mergePdfFiles } from '$lib/pdf-processing';
|
import { mergePdfFiles } from '$lib/pdf-processing';
|
||||||
import { Trash2 } from '@lucide/svelte';
|
import { Trash2 } from '@lucide/svelte';
|
||||||
|
|
||||||
@@ -41,12 +43,34 @@
|
|||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeAndPaginate = async () => {
|
||||||
|
if (pdfFiles.length < 2) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = await mergePdfFiles(pdfFiles);
|
||||||
|
const merged = new Uint8Array(bytes.byteLength);
|
||||||
|
merged.set(bytes);
|
||||||
|
queuePendingFiles([
|
||||||
|
new File([merged.buffer], 'zusammengefuegt.pdf', { type: 'application/pdf' })
|
||||||
|
]);
|
||||||
|
await goto('/tools/pagination');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Merge for pagination failed:', err);
|
||||||
|
error = 'Fehler beim Zusammenfügen der PDFs.';
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-primary-900">PDFs zusammenfügen</h1>
|
<h1 class="text-primary-900 text-2xl font-bold">PDFs zusammenfügen</h1>
|
||||||
<p class="mt-2 text-sm text-primary-700">
|
<p class="text-primary-700 mt-2 text-sm">
|
||||||
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
|
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,13 +79,22 @@
|
|||||||
|
|
||||||
{#if pdfFiles.length > 0}
|
{#if pdfFiles.length > 0}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h2 class="text-lg font-semibold text-primary-900">
|
<h2 class="text-primary-900 text-lg font-semibold">
|
||||||
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
|
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
|
||||||
</h2>
|
</h2>
|
||||||
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
|
<Button
|
||||||
</Button>
|
variant="tertiary"
|
||||||
|
onclick={mergeAndPaginate}
|
||||||
|
disabled={isProcessing || pdfFiles.length < 2}
|
||||||
|
>
|
||||||
|
Zusammenfügen & paginieren
|
||||||
|
</Button>
|
||||||
|
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
|
||||||
|
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -72,13 +105,13 @@
|
|||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each pdfFiles as file, index}
|
{#each pdfFiles as file, index}
|
||||||
<div class="flex items-center gap-3 rounded-lg border border-primary-200 bg-white p-3">
|
<div class="border-primary-200 flex items-center gap-3 rounded-lg border bg-white p-3">
|
||||||
<span
|
<span
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
|
||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex-1 truncate text-sm text-primary-900">{file.name}</span>
|
<span class="text-primary-900 flex-1 truncate text-sm">{file.name}</span>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { ChevronLeft, ChevronRight, ListOrdered } from '@lucide/svelte';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { isEncryptedPdfError, pdfLoadErrorMessage } from '$lib/pdf-errors';
|
||||||
|
import { getPreviewPositionStyle, type OverlayPosition } from '$lib/pdf-overlay';
|
||||||
|
import {
|
||||||
|
MAX_FONT_SIZE,
|
||||||
|
MAX_START_NUMBER,
|
||||||
|
MIN_FONT_SIZE,
|
||||||
|
MIN_START_NUMBER,
|
||||||
|
PAGINATION_FORMATS,
|
||||||
|
buildPaginationLabel,
|
||||||
|
normalizeStartNumber,
|
||||||
|
paginatePdf,
|
||||||
|
type PaginationFormat,
|
||||||
|
type PaginationTextScope
|
||||||
|
} from '$lib/pdf-pagination';
|
||||||
|
import {
|
||||||
|
PDF_PREVIEW_SCALE,
|
||||||
|
loadPdfPageRenderer,
|
||||||
|
type PdfPagePreview,
|
||||||
|
type PdfPageRenderer
|
||||||
|
} from '$lib/pdf-thumbnails';
|
||||||
|
import { takePendingFiles } from '$lib/pending-files';
|
||||||
|
|
||||||
|
const PAGE_NUMBER_TEXT_COLOR = '#1a1a1a';
|
||||||
|
|
||||||
|
let file = $state<File | null>(null);
|
||||||
|
let sourceBytes = $state<Uint8Array | null>(null);
|
||||||
|
let renderer = $state<PdfPageRenderer | null>(null);
|
||||||
|
let preview = $state<PdfPagePreview | null>(null);
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let startNumber = $state(1);
|
||||||
|
let freeText = $state('');
|
||||||
|
let textScope = $state<PaginationTextScope>('all');
|
||||||
|
let numberFormat = $state<PaginationFormat>('blatt');
|
||||||
|
let position = $state<OverlayPosition>('bottom-center');
|
||||||
|
let fontSize = $state(11);
|
||||||
|
let loadGeneration = 0;
|
||||||
|
|
||||||
|
const pageCount = $derived(renderer?.pageCount ?? 0);
|
||||||
|
const labelOptions = $derived({
|
||||||
|
startNumber: normalizeStartNumber(startNumber),
|
||||||
|
freeText,
|
||||||
|
textScope,
|
||||||
|
format: numberFormat
|
||||||
|
});
|
||||||
|
const previewLabel = $derived(buildPaginationLabel(currentPage - 1, labelOptions));
|
||||||
|
const exampleLabel = $derived(buildPaginationLabel(0, labelOptions));
|
||||||
|
const previewFontSize = $derived(
|
||||||
|
preview ? ((fontSize * PDF_PREVIEW_SCALE) / preview.width) * 100 : 0
|
||||||
|
);
|
||||||
|
const canPaginate = $derived(sourceBytes !== null && !isProcessing && !isLoading);
|
||||||
|
const freeTextHint = $derived(
|
||||||
|
textScope === 'all'
|
||||||
|
? 'Der Zusatztext erscheint auf jeder Seite.'
|
||||||
|
: 'Der Zusatztext erscheint nur auf der ersten Seite.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const showPage = (pageNumber: number) => {
|
||||||
|
if (!renderer) return;
|
||||||
|
currentPage = Math.min(Math.max(1, Math.round(pageNumber || 1)), renderer.pageCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageJump = (event: Event) => {
|
||||||
|
showPage(Number((event.currentTarget as HTMLInputElement).value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncPageField = (event: Event) => {
|
||||||
|
(event.currentTarget as HTMLInputElement).value = String(currentPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartNumberInput = (event: Event) => {
|
||||||
|
startNumber = normalizeStartNumber(Number((event.currentTarget as HTMLInputElement).value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncStartNumberField = (event: Event) => {
|
||||||
|
(event.currentTarget as HTMLInputElement).value = String(startNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFile = async (nextFile: File) => {
|
||||||
|
const generation = ++loadGeneration;
|
||||||
|
|
||||||
|
renderer?.destroy();
|
||||||
|
renderer = null;
|
||||||
|
preview = null;
|
||||||
|
file = nextFile;
|
||||||
|
sourceBytes = null;
|
||||||
|
currentPage = 1;
|
||||||
|
error = null;
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await nextFile.arrayBuffer());
|
||||||
|
const pageRenderer = await loadPdfPageRenderer(bytes);
|
||||||
|
if (generation !== loadGeneration) {
|
||||||
|
pageRenderer.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceBytes = bytes;
|
||||||
|
renderer = pageRenderer;
|
||||||
|
} catch (loadError) {
|
||||||
|
console.error('Failed to load PDF for pagination:', loadError);
|
||||||
|
if (generation === loadGeneration) {
|
||||||
|
file = null;
|
||||||
|
error = pdfLoadErrorMessage(loadError);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (generation === loadGeneration) isLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
const [nextFile] = files;
|
||||||
|
if (nextFile) void loadFile(nextFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const paginate = async () => {
|
||||||
|
const bytes = sourceBytes;
|
||||||
|
const currentFile = file;
|
||||||
|
if (!bytes || !currentFile) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await paginatePdf(bytes, {
|
||||||
|
startNumber: normalizeStartNumber(startNumber),
|
||||||
|
freeText,
|
||||||
|
textScope,
|
||||||
|
format: numberFormat,
|
||||||
|
position,
|
||||||
|
fontSize
|
||||||
|
});
|
||||||
|
downloadBlob(output, `${currentFile.name.replace(/\.pdf$/i, '')}_paginiert.pdf`);
|
||||||
|
} catch (processingError) {
|
||||||
|
console.error('Pagination failed:', processingError);
|
||||||
|
error = isEncryptedPdfError(processingError)
|
||||||
|
? pdfLoadErrorMessage(processingError)
|
||||||
|
: processingError instanceof Error && processingError.message.startsWith('Der Text')
|
||||||
|
? processingError.message
|
||||||
|
: 'Das PDF konnte nicht paginiert werden. Prüfen Sie die Datei.';
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeRenderer = renderer;
|
||||||
|
const pageNumber = currentPage;
|
||||||
|
if (!activeRenderer) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void activeRenderer
|
||||||
|
.render(pageNumber)
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) preview = result;
|
||||||
|
})
|
||||||
|
.catch((renderError) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error('Failed to render pagination preview:', renderError);
|
||||||
|
preview = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const [pending] = takePendingFiles().filter(
|
||||||
|
(candidate) => candidate.type === 'application/pdf' || /\.pdf$/i.test(candidate.name)
|
||||||
|
);
|
||||||
|
if (pending) void loadFile(pending);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
loadGeneration += 1;
|
||||||
|
renderer?.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>PDF paginieren — beA-Edit</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-5xl flex-col gap-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-secondary">
|
||||||
|
<ListOrdered class="size-5" aria-hidden="true" />
|
||||||
|
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Seitenzählung</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Paginieren</h1>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm text-primary">
|
||||||
|
Versehen Sie alle Seiten mit fortlaufenden Blattnummern — auf Wunsch kombiniert mit einem
|
||||||
|
Aktenzeichen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
label={file ? 'Andere PDF wählen' : 'PDF zum Paginieren hinzufügen'}
|
||||||
|
sublabel={isLoading
|
||||||
|
? 'PDF wird geladen …'
|
||||||
|
: file
|
||||||
|
? `${file.name}${pageCount > 0 ? ` · ${pageCount} Seite${pageCount === 1 ? '' : 'n'}` : ''}`
|
||||||
|
: 'PDF hier ablegen oder auswählen'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if file && renderer}
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="pagination-options-title"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 id="pagination-options-title" class="text-lg font-semibold text-foreground">
|
||||||
|
Nummerierung einstellen
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Die Nummern laufen über das gesamte Dokument und werden für schmale Seiten automatisch
|
||||||
|
verkleinert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-start">
|
||||||
|
Startnummer
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pagination-start"
|
||||||
|
type="number"
|
||||||
|
min={MIN_START_NUMBER}
|
||||||
|
max={MAX_START_NUMBER}
|
||||||
|
value={startNumber}
|
||||||
|
oninput={handleStartNumberInput}
|
||||||
|
onblur={syncStartNumberField}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Seite 1 erhält diese Nummer, danach zählt das Dokument fortlaufend weiter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-text">
|
||||||
|
Zusatztext (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pagination-text"
|
||||||
|
type="text"
|
||||||
|
bind:value={freeText}
|
||||||
|
maxlength="60"
|
||||||
|
placeholder="z. B. 123 Ja 60/26"
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Höchstens 60 Zeichen, zum Beispiel ein Aktenzeichen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<legend class="text-sm font-medium text-foreground">Zusatztext anzeigen</legend>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="pagination-text-scope"
|
||||||
|
value="all"
|
||||||
|
checked={textScope === 'all'}
|
||||||
|
onchange={() => (textScope = 'all')}
|
||||||
|
/>
|
||||||
|
Auf jeder Seite
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="pagination-text-scope"
|
||||||
|
value="first"
|
||||||
|
checked={textScope === 'first'}
|
||||||
|
onchange={() => (textScope = 'first')}
|
||||||
|
/>
|
||||||
|
Nur auf der ersten Seite
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-muted-foreground">{freeTextHint}</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-format">
|
||||||
|
Zahlenformat
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="pagination-format"
|
||||||
|
bind:value={numberFormat}
|
||||||
|
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{#each PAGINATION_FORMATS as format}
|
||||||
|
<option value={format.value}>{format.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-position">
|
||||||
|
Position
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="pagination-position"
|
||||||
|
bind:value={position}
|
||||||
|
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="top-left">Oben links</option>
|
||||||
|
<option value="top-center">Oben mittig</option>
|
||||||
|
<option value="top-right">Oben rechts</option>
|
||||||
|
<option value="center">Zentriert</option>
|
||||||
|
<option value="bottom-left">Unten links</option>
|
||||||
|
<option value="bottom-center">Unten mittig</option>
|
||||||
|
<option value="bottom-right">Unten rechts</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="pagination-font-size">
|
||||||
|
Schriftgröße: {fontSize} pt
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pagination-font-size"
|
||||||
|
type="range"
|
||||||
|
min={MIN_FONT_SIZE}
|
||||||
|
max={MAX_FONT_SIZE}
|
||||||
|
bind:value={fontSize}
|
||||||
|
class="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-foreground"
|
||||||
|
data-testid="pagination-example"
|
||||||
|
>
|
||||||
|
Beispiel: {exampleLabel}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="pagination-preview-title"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="pagination-preview-title" class="text-lg font-semibold text-foreground">
|
||||||
|
Vorschau
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold text-primary">Seite {currentPage} von {pageCount}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if preview}
|
||||||
|
<div
|
||||||
|
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||||
|
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={preview.imageUrl}
|
||||||
|
alt="Vorschau von Seite {currentPage}"
|
||||||
|
class="block h-full w-full"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute max-w-[92%] overflow-hidden px-1 leading-tight whitespace-nowrap"
|
||||||
|
style="{getPreviewPositionStyle(
|
||||||
|
position
|
||||||
|
)} font-size: {previewFontSize}cqw; color: {PAGE_NUMBER_TEXT_COLOR};"
|
||||||
|
data-testid="pagination-preview"
|
||||||
|
>
|
||||||
|
{previewLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Vorschau wird geladen …
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pageCount > 1}
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={() => showPage(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Vorherige Seite"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={() => showPage(currentPage + 1)}
|
||||||
|
disabled={currentPage === pageCount}
|
||||||
|
aria-label="Nächste Seite"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="pagination-page">
|
||||||
|
<span class="text-xs text-muted-foreground">Seite</span>
|
||||||
|
<input
|
||||||
|
id="pagination-page"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={pageCount}
|
||||||
|
value={currentPage}
|
||||||
|
oninput={handlePageJump}
|
||||||
|
onblur={syncPageField}
|
||||||
|
aria-label="Springe zu Seite"
|
||||||
|
class="w-20 rounded-lg border border-input bg-background px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end gap-3">
|
||||||
|
{#if error}
|
||||||
|
<p
|
||||||
|
class="w-full rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<Button size="large" onclick={paginate} disabled={!canPaginate}>
|
||||||
|
{isProcessing ? 'Wird paginiert …' : 'Paginieren & herunterladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+278
-282
@@ -1,12 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
|
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
|
||||||
import { degrees, PDFDocument, StandardFonts, type PDFFont } from 'pdf-lib';
|
import { degrees, PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
import { getPositionCoords, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
|
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||||
|
import { getPreviewPositionStyle, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
|
||||||
|
import { drawTextOverlay, getRotatedBoxPlacement } from '$lib/pdf-text-overlay';
|
||||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
|
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
|
||||||
@@ -24,7 +28,7 @@
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let pdfFile: File | null = $state(null);
|
let items = $state<BatchItem[]>([]);
|
||||||
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||||
let stampInput = $state('BEGLAUBIGT');
|
let stampInput = $state('BEGLAUBIGT');
|
||||||
let withDate = $state(false);
|
let withDate = $state(false);
|
||||||
@@ -40,7 +44,7 @@
|
|||||||
let selectedPages = $state(new Set<number>());
|
let selectedPages = $state(new Set<number>());
|
||||||
let currentPage = $state(1);
|
let currentPage = $state(1);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isProcessing = $state(false);
|
let isRunning = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let handleGeneration = 0;
|
let handleGeneration = 0;
|
||||||
|
|
||||||
@@ -58,53 +62,53 @@
|
|||||||
const selectedPreset = $derived(
|
const selectedPreset = $derived(
|
||||||
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
|
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
|
||||||
);
|
);
|
||||||
const canProcess = $derived(
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
pdfFile !== null &&
|
const stampReady = $derived(stampImage !== null || previewText.length > 0);
|
||||||
pdfHandle !== null &&
|
const pageSelectionMissing = $derived(
|
||||||
(stampImage !== null || previewText.length > 0) &&
|
items.length === 1 && pdfHandle !== null && !applyToAll && selectedPages.size === 0
|
||||||
!isLoading &&
|
);
|
||||||
!isLoadingImage &&
|
const canRun = $derived(
|
||||||
!isProcessing &&
|
!isRunning && pendingCount > 0 && stampReady && !isLoadingImage && !pageSelectionMissing
|
||||||
(applyToAll || selectedPages.size > 0)
|
|
||||||
);
|
);
|
||||||
const previewFontSize = $derived(
|
const previewFontSize = $derived(
|
||||||
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
|
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
|
||||||
);
|
);
|
||||||
|
|
||||||
const isEncryptedPdfError = (loadError: unknown) => {
|
const handleFilesSelected = async (files: File[]) => {
|
||||||
const message =
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
|
const additions = files
|
||||||
return /password|encrypted|encryption|verschl/i.test(message);
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
};
|
.map((file) => createBatchItem(file));
|
||||||
|
if (additions.length === 0) return;
|
||||||
|
|
||||||
const loadErrorMessage = (loadError: unknown) =>
|
items = [...items, ...additions];
|
||||||
isEncryptedPdfError(loadError)
|
|
||||||
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
|
|
||||||
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
|
|
||||||
|
|
||||||
const handleFileSelected = async (files: File[]) => {
|
|
||||||
const file = files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const generation = ++handleGeneration;
|
|
||||||
pdfHandle?.destroy();
|
|
||||||
pdfHandle = null;
|
|
||||||
pdfFile = file;
|
|
||||||
selectedPages = new Set();
|
selectedPages = new Set();
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
error = null;
|
error = null;
|
||||||
isLoading = true;
|
|
||||||
|
|
||||||
|
// Thumbnails and page selection are only available for a single file so
|
||||||
|
// memory stays flat during multi-file runs.
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
const generation = ++handleGeneration;
|
||||||
|
|
||||||
|
if (items.length !== 1) {
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = items;
|
||||||
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const handle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
|
const handle = await loadPdfDocument(new Uint8Array(await item.file.arrayBuffer()));
|
||||||
if (generation !== handleGeneration) {
|
if (generation !== handleGeneration || items.length !== 1) {
|
||||||
handle.destroy();
|
handle.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pdfHandle = handle;
|
pdfHandle = handle;
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
console.error('Failed to load PDF for stamping:', loadError);
|
console.error('Failed to load PDF for stamping:', loadError);
|
||||||
if (generation === handleGeneration) error = loadErrorMessage(loadError);
|
if (generation === handleGeneration) error = pdfLoadErrorMessage(loadError);
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === handleGeneration) isLoading = false;
|
if (generation === handleGeneration) isLoading = false;
|
||||||
}
|
}
|
||||||
@@ -189,108 +193,16 @@
|
|||||||
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
|
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPreviewPositionStyle = (previewPosition: OverlayPosition) => {
|
|
||||||
switch (previewPosition) {
|
|
||||||
case 'center':
|
|
||||||
return 'left: 50%; top: 50%; transform: translate(-50%, -50%);';
|
|
||||||
case 'top-left':
|
|
||||||
return 'left: 5%; top: 5%;';
|
|
||||||
case 'top-center':
|
|
||||||
return 'left: 50%; top: 5%; transform: translateX(-50%);';
|
|
||||||
case 'top-right':
|
|
||||||
return 'right: 5%; top: 5%;';
|
|
||||||
case 'bottom-left':
|
|
||||||
return 'bottom: 5%; left: 5%;';
|
|
||||||
case 'bottom-center':
|
|
||||||
return 'bottom: 5%; left: 50%; transform: translateX(-50%);';
|
|
||||||
case 'bottom-right':
|
|
||||||
return 'bottom: 5%; right: 5%;';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRotatedStampPlacement = (
|
|
||||||
pageWidth: number,
|
|
||||||
pageHeight: number,
|
|
||||||
boxWidth: number,
|
|
||||||
boxHeight: number,
|
|
||||||
stampPosition: OverlayPosition,
|
|
||||||
angle: number
|
|
||||||
) => {
|
|
||||||
const radians = (angle * Math.PI) / 180;
|
|
||||||
const cosine = Math.cos(radians);
|
|
||||||
const sine = Math.sin(radians);
|
|
||||||
const rotatedCorners = [
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: boxWidth * cosine, y: boxWidth * sine },
|
|
||||||
{ x: -boxHeight * sine, y: boxHeight * cosine },
|
|
||||||
{
|
|
||||||
x: boxWidth * cosine - boxHeight * sine,
|
|
||||||
y: boxWidth * sine + boxHeight * cosine
|
|
||||||
}
|
|
||||||
];
|
|
||||||
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
|
|
||||||
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
|
|
||||||
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
|
|
||||||
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
|
|
||||||
const boundingCoordinates = getPositionCoords(
|
|
||||||
pageWidth,
|
|
||||||
pageHeight,
|
|
||||||
maxX - minX,
|
|
||||||
maxY - minY,
|
|
||||||
stampPosition,
|
|
||||||
PAGE_MARGIN
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: boundingCoordinates.x - minX,
|
|
||||||
y: boundingCoordinates.y - minY,
|
|
||||||
cosine,
|
|
||||||
sine
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const fitStampToPage = (
|
|
||||||
font: PDFFont,
|
|
||||||
text: string,
|
|
||||||
requestedSize: number,
|
|
||||||
pageWidth: number,
|
|
||||||
pageHeight: number
|
|
||||||
) => {
|
|
||||||
const availableWidth = pageWidth - PAGE_MARGIN * 2;
|
|
||||||
const availableHeight = pageHeight - PAGE_MARGIN * 2;
|
|
||||||
let size = requestedSize;
|
|
||||||
|
|
||||||
while (size > MIN_EXPORT_FONT_SIZE) {
|
|
||||||
const width = font.widthOfTextAtSize(text, size) + PADDING * 2;
|
|
||||||
const height = font.heightAtSize(size) + PADDING * 2;
|
|
||||||
if (width <= availableWidth && height <= availableHeight) break;
|
|
||||||
size = Math.max(MIN_EXPORT_FONT_SIZE, size - 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
const textWidth = font.widthOfTextAtSize(text, size);
|
|
||||||
const textHeight = font.heightAtSize(size);
|
|
||||||
if (textWidth + PADDING * 2 > availableWidth || textHeight + PADDING * 2 > availableHeight) {
|
|
||||||
throw new Error(
|
|
||||||
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
size,
|
|
||||||
textHeight,
|
|
||||||
boxWidth: textWidth + PADDING * 2,
|
|
||||||
boxHeight: textHeight + PADDING * 2
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getImageStampBox = (
|
const getImageStampBox = (
|
||||||
image: { width: number; height: number },
|
image: { width: number; height: number },
|
||||||
pageWidth: number,
|
pageWidth: number,
|
||||||
pageHeight: number
|
pageHeight: number,
|
||||||
|
requestedSize: number,
|
||||||
|
withFrame: boolean
|
||||||
) => {
|
) => {
|
||||||
const padding = withBorder ? PADDING : 0;
|
const padding = withFrame ? PADDING : 0;
|
||||||
const aspectRatio = image.width / image.height;
|
const aspectRatio = image.width / image.height;
|
||||||
let drawHeight = Number(imageSize);
|
let drawHeight = requestedSize;
|
||||||
let drawWidth = drawHeight * aspectRatio;
|
let drawWidth = drawHeight * aspectRatio;
|
||||||
|
|
||||||
const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2;
|
const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2;
|
||||||
@@ -314,46 +226,50 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyStamp = async () => {
|
const processFile = async (file: File) => {
|
||||||
if (!pdfFile || !pdfHandle || !canProcess) return;
|
// Options are read per file so a change during a run only affects the
|
||||||
|
// still pending files. With multiple files the stamp is applied to all
|
||||||
|
// pages of every file.
|
||||||
|
const stampText = buildStampText(stampInput, withDate, new Date());
|
||||||
|
const numericFontSize = Number(fontSize);
|
||||||
|
const numericImageSize = Number(imageSize);
|
||||||
|
const withFrame = withBorder;
|
||||||
|
const angle = Number(rotation);
|
||||||
|
const stampPosition = position;
|
||||||
|
const color = parseHexColor(stampColor);
|
||||||
|
const currentStampImage = stampImage;
|
||||||
|
|
||||||
isProcessing = true;
|
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
|
||||||
error = null;
|
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||||
|
const embeddedImage = currentStampImage
|
||||||
|
? currentStampImage.mimeType === 'image/png'
|
||||||
|
? await pdfDoc.embedPng(currentStampImage.bytes)
|
||||||
|
: await pdfDoc.embedJpg(currentStampImage.bytes)
|
||||||
|
: null;
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const useAllPages = items.length > 1 || applyToAll;
|
||||||
|
const pageIndexes = useAllPages
|
||||||
|
? pages.map((_, index) => index)
|
||||||
|
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
|
||||||
|
|
||||||
try {
|
for (const pageIndex of pageIndexes) {
|
||||||
const stampText = buildStampText(stampInput, withDate, new Date());
|
const page = pages[pageIndex];
|
||||||
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
|
if (!page) continue;
|
||||||
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
|
||||||
const embeddedImage = stampImage
|
|
||||||
? stampImage.mimeType === 'image/png'
|
|
||||||
? await pdfDoc.embedPng(stampImage.bytes)
|
|
||||||
: await pdfDoc.embedJpg(stampImage.bytes)
|
|
||||||
: null;
|
|
||||||
const pages = pdfDoc.getPages();
|
|
||||||
const pageIndexes = applyToAll
|
|
||||||
? pages.map((_, index) => index)
|
|
||||||
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
|
|
||||||
const color = parseHexColor(stampColor);
|
|
||||||
|
|
||||||
for (const pageIndex of pageIndexes) {
|
|
||||||
const page = pages[pageIndex];
|
|
||||||
if (!page) continue;
|
|
||||||
|
|
||||||
|
if (embeddedImage) {
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
const angle = Number(rotation);
|
const box = getImageStampBox(embeddedImage, width, height, numericImageSize, withFrame);
|
||||||
const box = embeddedImage
|
const placement = getRotatedBoxPlacement(
|
||||||
? getImageStampBox(embeddedImage, width, height)
|
|
||||||
: fitStampToPage(font, stampText, Number(fontSize), width, height);
|
|
||||||
const placement = getRotatedStampPlacement(
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
box.boxWidth,
|
box.boxWidth,
|
||||||
box.boxHeight,
|
box.boxHeight,
|
||||||
position,
|
stampPosition,
|
||||||
angle
|
angle,
|
||||||
|
PAGE_MARGIN
|
||||||
);
|
);
|
||||||
|
|
||||||
if (embeddedImage ? withBorder : true) {
|
if (withFrame) {
|
||||||
page.drawRectangle({
|
page.drawRectangle({
|
||||||
x: placement.x,
|
x: placement.x,
|
||||||
y: placement.y,
|
y: placement.y,
|
||||||
@@ -365,44 +281,94 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (embeddedImage) {
|
page.drawImage(embeddedImage, {
|
||||||
const imageBox = box as { drawWidth: number; drawHeight: number; padding: number };
|
x: placement.x + box.padding * placement.cosine - box.padding * placement.sine,
|
||||||
const offsetX = imageBox.padding;
|
y: placement.y + box.padding * placement.sine + box.padding * placement.cosine,
|
||||||
const offsetY = imageBox.padding;
|
width: box.drawWidth,
|
||||||
page.drawImage(embeddedImage, {
|
height: box.drawHeight,
|
||||||
x: placement.x + offsetX * placement.cosine - offsetY * placement.sine,
|
rotate: degrees(angle)
|
||||||
y: placement.y + offsetX * placement.sine + offsetY * placement.cosine,
|
});
|
||||||
width: imageBox.drawWidth,
|
continue;
|
||||||
height: imageBox.drawHeight,
|
|
||||||
rotate: degrees(angle)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const textOffsetX = PADDING;
|
|
||||||
const textOffsetY = PADDING + (box as { textHeight: number }).textHeight * 0.2;
|
|
||||||
page.drawText(stampText, {
|
|
||||||
x: placement.x + textOffsetX * placement.cosine - textOffsetY * placement.sine,
|
|
||||||
y: placement.y + textOffsetX * placement.sine + textOffsetY * placement.cosine,
|
|
||||||
size: (box as { size: number }).size,
|
|
||||||
font,
|
|
||||||
color,
|
|
||||||
rotate: degrees(angle)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultBytes = await pdfDoc.save();
|
drawTextOverlay(page, {
|
||||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
font,
|
||||||
downloadBlob(resultBytes, `${baseName}_stempel.pdf`);
|
text: stampText,
|
||||||
} catch (processingError) {
|
size: numericFontSize,
|
||||||
console.error('Failed to stamp PDF:', processingError);
|
color,
|
||||||
error = isEncryptedPdfError(processingError)
|
position: stampPosition,
|
||||||
? loadErrorMessage(processingError)
|
margin: PAGE_MARGIN,
|
||||||
: processingError instanceof Error && processingError.message.startsWith('Der Stempeltext')
|
padding: PADDING,
|
||||||
? processingError.message
|
framed: true,
|
||||||
: 'Der Stempel konnte nicht angewendet werden. Prüfen Sie die PDF-Datei.';
|
rotation: angle,
|
||||||
} finally {
|
minSize: MIN_EXPORT_FONT_SIZE,
|
||||||
isProcessing = false;
|
tooLongMessage:
|
||||||
|
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bytes = await pdfDoc.save();
|
||||||
|
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_stempel.pdf` };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stampErrorMessage = (processingError: unknown) =>
|
||||||
|
pdfProcessingErrorMessage(
|
||||||
|
processingError,
|
||||||
|
'Diese PDF konnte nicht gestempelt werden. Prüfen Sie die Datei.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning || !canRun) return;
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch(queue, processFile, updateItem, {
|
||||||
|
errorMessage: stampErrorMessage,
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage: stampErrorMessage
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
const removingPreviewedFile = items.length === 1 && items[0]?.id === id;
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
if (removingPreviewedFile) {
|
||||||
|
handleGeneration += 1;
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
selectedPages = new Set();
|
||||||
|
currentPage = 1;
|
||||||
|
error = null;
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -429,15 +395,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileDropzone
|
<FileDropzone
|
||||||
onFilesSelected={handleFileSelected}
|
onFilesSelected={handleFilesSelected}
|
||||||
label={pdfHandle ? 'Anderes PDF ablegen' : 'PDF zum Stempeln hinzufügen'}
|
multiple
|
||||||
|
label={items.length === 0 ? 'PDFs zum Stempeln hinzufügen' : 'Weitere PDFs hinzufügen'}
|
||||||
sublabel={isLoading
|
sublabel={isLoading
|
||||||
? 'Vorschau wird geladen …'
|
? 'Vorschau wird geladen …'
|
||||||
: (pdfFile?.name ?? 'PDF hier ablegen oder auswählen')}
|
: items.length === 1
|
||||||
|
? items[0].file.name
|
||||||
|
: items.length === 0
|
||||||
|
? 'PDFs hier ablegen oder auswählen'
|
||||||
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if pdfHandle}
|
{#if items.length > 0}
|
||||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
<div class="grid gap-6 {pdfHandle ? 'lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]' : ''}">
|
||||||
<section
|
<section
|
||||||
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
|
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
|
||||||
aria-labelledby="stamp-options-title"
|
aria-labelledby="stamp-options-title"
|
||||||
@@ -447,7 +418,8 @@
|
|||||||
Stempel einstellen
|
Stempel einstellen
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
Lange Texte werden für kleine Seiten automatisch verkleinert.
|
Lange Texte werden für kleine Seiten automatisch verkleinert. Die Einstellungen gelten
|
||||||
|
für alle Dateien der Liste.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -590,92 +562,108 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
|
{#if items.length > 1}
|
||||||
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
|
<p class="text-sm text-primary" role="note">
|
||||||
Auf alle Seiten anwenden
|
Der Stempel wird auf alle Seiten aller Dateien angewendet.
|
||||||
</label>
|
</p>
|
||||||
|
{:else}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
|
||||||
|
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
|
||||||
|
Auf alle Seiten anwenden
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isRunning}
|
||||||
|
<p class="text-xs text-primary" role="note">
|
||||||
|
Änderungen an den Einstellungen gelten für die noch ausstehenden Dateien.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
{#if pdfHandle}
|
||||||
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
<section
|
||||||
aria-labelledby="stamp-preview-title"
|
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||||
>
|
aria-labelledby="stamp-preview-title"
|
||||||
<div class="flex items-center justify-between gap-3">
|
>
|
||||||
<div>
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
|
<div>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
|
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">
|
||||||
</div>
|
Vorschau
|
||||||
<span class="text-xs font-semibold text-primary"
|
</h2>
|
||||||
>{currentPage} / {pdfHandle.pageCount}</span
|
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if pdfHandle.thumbnails[currentPage - 1]}
|
|
||||||
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
|
|
||||||
<div
|
|
||||||
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={currentThumbnail.imageUrl}
|
|
||||||
alt="Vorschau von Seite {currentPage}"
|
|
||||||
class="block w-full"
|
|
||||||
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
|
|
||||||
/>
|
|
||||||
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
|
|
||||||
{#if stampImage}
|
|
||||||
<div
|
|
||||||
data-testid="stamp-preview"
|
|
||||||
class={withBorder ? 'border-2' : ''}
|
|
||||||
style="border-color: {stampColor}; padding: {withBorder
|
|
||||||
? '2px'
|
|
||||||
: '0'}; transform: rotate({rotation}deg);"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={stampImage.objectUrl}
|
|
||||||
alt="Stempelbild"
|
|
||||||
class="block"
|
|
||||||
style="width: {imageSize * 0.5}px; max-width: 100%;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
data-testid="stamp-preview"
|
|
||||||
class="border-2 px-2 py-1 font-sans font-extrabold leading-tight whitespace-nowrap"
|
|
||||||
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
|
|
||||||
>
|
|
||||||
{previewText}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-xs font-semibold text-primary"
|
||||||
|
>{currentPage} / {pdfHandle.pageCount}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if pdfHandle.pageCount > 1}
|
{#if pdfHandle.thumbnails[currentPage - 1]}
|
||||||
<div class="flex items-center justify-center gap-3">
|
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
|
||||||
<Button
|
<div
|
||||||
variant="tertiary"
|
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||||
class="min-w-0 px-3"
|
|
||||||
onclick={showPreviousPage}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
aria-label="Vorherige Seite"
|
|
||||||
>
|
>
|
||||||
<ChevronLeft aria-hidden="true" />
|
<img
|
||||||
</Button>
|
src={currentThumbnail.imageUrl}
|
||||||
<Button
|
alt="Vorschau von Seite {currentPage}"
|
||||||
variant="tertiary"
|
class="block w-full"
|
||||||
class="min-w-0 px-3"
|
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
|
||||||
onclick={showNextPage}
|
/>
|
||||||
disabled={currentPage === pdfHandle.pageCount}
|
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
|
||||||
aria-label="Nächste Seite"
|
{#if stampImage}
|
||||||
>
|
<div
|
||||||
<ChevronRight aria-hidden="true" />
|
data-testid="stamp-preview"
|
||||||
</Button>
|
class={withBorder ? 'border-2' : ''}
|
||||||
</div>
|
style="border-color: {stampColor}; padding: {withBorder
|
||||||
{/if}
|
? '2px'
|
||||||
</section>
|
: '0'}; transform: rotate({rotation}deg);"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={stampImage.objectUrl}
|
||||||
|
alt="Stempelbild"
|
||||||
|
class="block"
|
||||||
|
style="width: {imageSize * 0.5}px; max-width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="stamp-preview"
|
||||||
|
class="border-2 px-2 py-1 font-sans leading-tight font-extrabold whitespace-nowrap"
|
||||||
|
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
|
||||||
|
>
|
||||||
|
{previewText}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pdfHandle.pageCount > 1}
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={showPreviousPage}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Vorherige Seite"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={showNextPage}
|
||||||
|
disabled={currentPage === pdfHandle.pageCount}
|
||||||
|
aria-label="Nächste Seite"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !applyToAll}
|
{#if pdfHandle && !applyToAll}
|
||||||
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
|
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
|
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
|
||||||
@@ -689,6 +677,14 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<BatchFileList
|
||||||
|
{items}
|
||||||
|
zipBaseName="stempel"
|
||||||
|
onDownload={downloadItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col items-end gap-3">
|
<div class="flex flex-col items-end gap-3">
|
||||||
{#if error}
|
{#if error}
|
||||||
<p
|
<p
|
||||||
@@ -698,8 +694,8 @@
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<Button size="large" onclick={applyStamp} disabled={!canProcess}>
|
<Button size="large" onclick={startBatch} disabled={!canRun}>
|
||||||
{isProcessing ? 'Stempel wird angewendet …' : 'Stempel anwenden & herunterladen'}
|
{isRunning ? 'Stempel wird angewendet …' : 'Stempel anwenden'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
|
|||||||
@@ -123,6 +123,27 @@ export const getPageContentText = (document: PDFDocument, pageIndex: number) =>
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded operands of all `Tj` operators. pdf-lib writes drawn text as hex
|
||||||
|
* strings (`<426C2E2031> Tj`), so the raw content stream is not readable.
|
||||||
|
*/
|
||||||
|
export const getPageTextValues = (document: PDFDocument, pageIndex: number) => {
|
||||||
|
const content = getPageContentText(document, pageIndex);
|
||||||
|
const values: string[] = [];
|
||||||
|
|
||||||
|
for (const match of content.matchAll(/<([0-9a-fA-F]+)>\s*Tj/g)) {
|
||||||
|
values.push(Buffer.from(match[1], 'hex').toString('latin1'));
|
||||||
|
}
|
||||||
|
for (const match of content.matchAll(/\(((?:\\.|[^()\\])*)\)\s*Tj/g)) {
|
||||||
|
values.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPageText = (document: PDFDocument, pageIndex: number) =>
|
||||||
|
getPageTextValues(document, pageIndex).join('\n');
|
||||||
|
|
||||||
export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
|
export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
|
||||||
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
|
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
|
||||||
document.encrypt({
|
document.encrypt({
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import {
|
||||||
|
createEncryptedPdf,
|
||||||
|
createPdf,
|
||||||
|
createPreRotatedPdf,
|
||||||
|
dropFiles,
|
||||||
|
expectPdfDownload,
|
||||||
|
getPageContentText,
|
||||||
|
getPageText,
|
||||||
|
pdfDropzone,
|
||||||
|
trackPageErrors
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
const downloadPaginated = async (page: Page, fileName: RegExp) => {
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Paginieren & herunterladen' }).click();
|
||||||
|
return expectPdfDownload(await downloadPromise, fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('pagination stamps every page with an Aktenzeichen and the configured start number', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Seite 1 von 3')).toBeVisible();
|
||||||
|
await page.locator('#pagination-start').fill('26');
|
||||||
|
await page.locator('#pagination-text').fill('123 Ja 60/26');
|
||||||
|
await expect(page.getByTestId('pagination-example')).toContainText(
|
||||||
|
'Beispiel: 123 Ja 60/26, Bl. 26'
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Nächste Seite' }).click();
|
||||||
|
await expect(page.getByText('Seite 2 von 3')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('pagination-preview')).toContainText('123 Ja 60/26, Bl. 27');
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^akte_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
expect(getPageText(output, 0)).toContain('123 Ja 60/26, Bl. 26');
|
||||||
|
expect(getPageText(output, 1)).toContain('123 Ja 60/26, Bl. 27');
|
||||||
|
expect(getPageText(output, 2)).toContain('123 Ja 60/26, Bl. 28');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination can keep the additional text on the first page only', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.locator('#pagination-start').fill('7');
|
||||||
|
await page.locator('#pagination-text').fill('AZ 7/26');
|
||||||
|
await page.locator('#pagination-format').selectOption('s');
|
||||||
|
await page.getByLabel('Nur auf der ersten Seite').check();
|
||||||
|
await expect(page.getByTestId('pagination-example')).toContainText('AZ 7/26, S. 7');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Nächste Seite' }).click();
|
||||||
|
await expect(page.getByTestId('pagination-preview')).toHaveText('S. 8');
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^akte_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
expect(getPageText(output, 0)).toContain('AZ 7/26, S. 7');
|
||||||
|
expect(getPageText(output, 1)).toContain('S. 8');
|
||||||
|
expect(getPageText(output, 1)).not.toContain('AZ 7/26');
|
||||||
|
expect(getPageText(output, 2)).toContain('S. 9');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination supports number-only labels and jumps to a page', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'nummern.pdf', mimeType: 'application/pdf', buffer: await createPdf(11, 'Nummern') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.locator('#pagination-start').fill('42');
|
||||||
|
await page.locator('#pagination-format').selectOption('nummer');
|
||||||
|
await expect(page.getByTestId('pagination-example')).toHaveText('Beispiel: 42');
|
||||||
|
|
||||||
|
await page.getByLabel('Springe zu Seite').fill('11');
|
||||||
|
await expect(page.getByText('Seite 11 von 11')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('pagination-preview')).toHaveText('52');
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^nummern_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(11);
|
||||||
|
expect(getPageText(output, 0)).toContain('42');
|
||||||
|
expect(getPageText(output, 10)).toContain('52');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination keeps the page number upright on rotated scans', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'gedreht.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createPreRotatedPdf(2, 'Gedreht')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^gedreht_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(2);
|
||||||
|
|
||||||
|
// Page 1 is stored with /Rotate 90: the text is counter-rotated so it reads
|
||||||
|
// horizontally on screen instead of sideways.
|
||||||
|
const rotatedContent = getPageContentText(output, 0);
|
||||||
|
expect(getPageText(output, 0)).toContain('Bl. 1');
|
||||||
|
const rotatedMatrix =
|
||||||
|
/([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) Tm\n<426C2E2031> Tj/.exec(
|
||||||
|
rotatedContent
|
||||||
|
);
|
||||||
|
expect(rotatedMatrix).not.toBeNull();
|
||||||
|
const [a, b, c, d, x, y] = rotatedMatrix!.slice(1).map(Number);
|
||||||
|
// 90° counter-clockwise in user space cancels the viewer's page rotation.
|
||||||
|
expect(Math.abs(a)).toBeLessThan(0.001);
|
||||||
|
expect(b).toBeCloseTo(1, 3);
|
||||||
|
expect(c).toBeCloseTo(-1, 3);
|
||||||
|
expect(Math.abs(d)).toBeLessThan(0.001);
|
||||||
|
// Visual bottom center of the 594×420 display: 28 pt margin, centered.
|
||||||
|
expect(x).toBeGreaterThan(384);
|
||||||
|
expect(x).toBeLessThan(396);
|
||||||
|
expect(y).toBeGreaterThan(280);
|
||||||
|
expect(y).toBeLessThan(292);
|
||||||
|
|
||||||
|
const plainContent = getPageContentText(output, 1);
|
||||||
|
expect(getPageText(output, 1)).toContain('Bl. 2');
|
||||||
|
expect(plainContent).toMatch(/1 0 0 1 [-\d.]+ [-\d.]+ Tm\n<426C2E2032> Tj/);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination explains how to handle a password-protected PDF', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/pagination');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('alert')).toContainText('passwortgeschützt');
|
||||||
|
await expect(page.getByRole('alert')).toContainText('Passwort entfernen');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge hands the merged PDF over to pagination', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/merge');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Zusammenfügen & paginieren' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/tools\/pagination$/);
|
||||||
|
await expect(page.getByText('Seite 1 von 3')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const bytes = await downloadPaginated(page, /^zusammengefuegt_paginiert\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
expect(getPageText(output, 0)).toContain('Bl. 1');
|
||||||
|
expect(getPageText(output, 1)).toContain('Bl. 2');
|
||||||
|
expect(getPageText(output, 2)).toContain('Bl. 3');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
+61
-4
@@ -1,4 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import {
|
import {
|
||||||
createEncryptedPdf,
|
createEncryptedPdf,
|
||||||
@@ -29,8 +31,10 @@ test('stamp presets fill the editable text and EILT is applied to every page', a
|
|||||||
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
|
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
|
||||||
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
|
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const downloadPromise = page.waitForEvent('download');
|
const downloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'akte.pdf herunterladen' }).click();
|
||||||
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
|
||||||
const output = await PDFDocument.load(outputBytes);
|
const output = await PDFDocument.load(outputBytes);
|
||||||
expect(output.getPageCount()).toBe(3);
|
expect(output.getPageCount()).toBe(3);
|
||||||
@@ -58,8 +62,10 @@ test('stamp custom text adds a German date and only changes selected pages', asy
|
|||||||
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
|
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
|
||||||
await page.getByRole('button', { name: /Seite 2/ }).click();
|
await page.getByRole('button', { name: /Seite 2/ }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const downloadPromise = page.waitForEvent('download');
|
const downloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'auswahl.pdf herunterladen' }).click();
|
||||||
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
|
||||||
const output = await PDFDocument.load(outputBytes);
|
const output = await PDFDocument.load(outputBytes);
|
||||||
expect(output.getPageCount()).toBe(3);
|
expect(output.getPageCount()).toBe(3);
|
||||||
@@ -89,8 +95,10 @@ test('stamp uses an image instead of text and makes the border optional', async
|
|||||||
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
|
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
|
||||||
).not.toBeChecked();
|
).not.toBeChecked();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const downloadPromise = page.waitForEvent('download');
|
const downloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'bildakte.pdf herunterladen' }).click();
|
||||||
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
|
||||||
const output = await PDFDocument.load(outputBytes);
|
const output = await PDFDocument.load(outputBytes);
|
||||||
expect(output.getPageCount()).toBe(2);
|
expect(output.getPageCount()).toBe(2);
|
||||||
@@ -103,8 +111,18 @@ test('stamp uses an image instead of text and makes the border optional', async
|
|||||||
|
|
||||||
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
|
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
|
||||||
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
|
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
|
||||||
|
|
||||||
|
// Re-add the file so the changed option applies to a fresh pending row.
|
||||||
|
await page.getByRole('button', { name: 'bildakte.pdf entfernen' }).click();
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') }
|
||||||
|
]);
|
||||||
|
await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const borderedDownloadPromise = page.waitForEvent('download');
|
const borderedDownloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'bildakte.pdf herunterladen' }).click();
|
||||||
const borderedBytes = await expectPdfDownload(
|
const borderedBytes = await expectPdfDownload(
|
||||||
await borderedDownloadPromise,
|
await borderedDownloadPromise,
|
||||||
/^bildakte_stempel\.pdf$/
|
/^bildakte_stempel\.pdf$/
|
||||||
@@ -120,6 +138,45 @@ test('stamp uses an image instead of text and makes the border optional', async
|
|||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stamp batch applies to every file and bundles results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') },
|
||||||
|
{
|
||||||
|
name: 'kaputt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: Buffer.from('kein gueltiges PDF')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (4)')).toBeVisible();
|
||||||
|
// With several files the stamp is applied to every page and no preview is loaded.
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Vorschau' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 });
|
||||||
|
await expect(page.getByText('Fehler')).toHaveCount(2);
|
||||||
|
await expect(page.getByText('Dieses PDF ist passwortgeschützt')).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^stempel_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual(['eins_stempel.pdf', 'zwei_stempel.pdf']);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
|
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
|
||||||
const pageErrors = trackPageErrors(page);
|
const pageErrors = trackPageErrors(page);
|
||||||
await page.goto('/tools/stamp');
|
await page.goto('/tools/stamp');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ test('start page exposes only working tools and forwards a dropped ZIP to beA',
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: /Stempeln/ })).toBeVisible();
|
await expect(page.getByRole('link', { name: /Stempeln/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /Paginieren/ })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
|
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
|
||||||
|
|
||||||
await dropFiles(page, 'window', [
|
await dropFiles(page, 'window', [
|
||||||
|
|||||||
Reference in new issue
Block a user