diff --git a/.claude/rules/layout-engine.md b/.claude/rules/layout-engine.md index 0cb3fd9f..1739f9b4 100644 --- a/.claude/rules/layout-engine.md +++ b/.claude/rules/layout-engine.md @@ -59,6 +59,7 @@ The wasm API between Rust (`model.rs`, `wasm.rs`) and TypeScript (`flatten.ts`, - A `toc` item (one position) is a table of contents: `title`, `depth` and its `entries` (`{level, text}`, the listed headings up to `depth`, which `flatten.ts` gives it). Its n-th entry is the n-th listed heading up to `depth`, so it holds no positions that could go stale. `items/toc.rs` lays out the title and the entries, as extras (not text of the document), leaving a column for the page numbers as wide as the widest number a page shows (`format_number` of the settings' style and start, up to page 999), so the numbers never change its lines. After paginating, `engine/toc.rs` (`set_toc_numbers`, next to the chapters) works out each entry's page label, as the footer's `{page}` writes it, and gives a new body version only to the pages showing an entry whose number changed. `display.rs` sets in the numbers, the leader dots up to them, and a link `#toc:` per entry (`TOC_LINK`), which the PDF makes a link to the heading (`toc_target`) and the editor follows on Ctrl+Click. The PDF tags it `TOC` > `TOCI` > `Link`. - A table of contents depends on other blocks than itself, which `flattenBlocks` otherwise never assumes: `pageSync` (`tocBlocks` in `src/editor/plugins/pageView.ts`) flattens the tables of contents again whenever `headingsSignature` (the listed headings' levels and texts, not their positions) changes. - An `image` item's `share` (0 to 1) makes it that share of the room's width, larger too; without it, it keeps its own size, at most the room's. Its `caption` is laid out below it in the caption style, aligned as the picture is (`caption_box` in `items/mod.rs`, which a table's caption above it uses too) as a unit of extras kept with the picture's, which ↑/↓ step over, tagged `Caption` in the PDF's `Figure`. A cell's image has `share` of the cell. The picture and its caption fit one page together. + - A `stamp` item is a stamp placed on a PDF's page (`items/stamp.rs`): an image (`src`, with `width` and `height` for its shape) contained in the box, centred, and `lines` under it in the body font at `size` pt, `align`ed left or `center`. It's drawn as an artifact, not text of the document. Only stamp engines (`src/pdf/stampEngine.ts`, one per stamp that looks different, `sharing()` the page view's fonts, with the user's marks as `blank:signature` (`SIGNATURE_IMAGE`, as in a signed export)/`blank:initials`) lay it out, on settings with `stamp: true`: the page is the stamp's box, from `MIN_STAMP` (2 pt), without margins or bands. `boxes(0, 1)` gives what it shows (`Laid::extent`): its natural size, laid out on a 600 × 200 pt page. - A table cell has `paragraphs`, or `blocks` when it holds more: `text` blocks with their own `indent`, `marker` and `bars` (lists, quotes, code) and `image` blocks, fitted to the cell's width. - The engine clamps what it gets (sizes, margins, start numbers, columns) and never panics on odd input; a panic that is left is written to `console.error` before the instance traps. - **Pages:** `pageCount()`, `versions()`, and `bodyVersions()`/`bandVersions()`, one per page, which change only when a page's body or its header and footer change, so only those are painted again. `pageBody(page)` and `pageBands(page)` are the display lists (JSON strings of drawing ops), `page(page)` both; `bands(page)`, `bottoms()`, `glyphPath(font, glyph)` and `unitsPerEm(font)`. A glyph run's `font` is a font index: below the number of faces a face of a file, from `INSTANCE_BASE` (2^20) on an instance of a variable face at other coordinates (e.g. the bold of Noto Emoji). An index never changes its meaning, so a cache keyed by it stays right; the `fontFile*` calls count files, never instances. diff --git a/.claude/rules/pdf.md b/.claude/rules/pdf.md index 7ea247c4..4444fe8f 100644 --- a/.claude/rules/pdf.md +++ b/.claude/rules/pdf.md @@ -3,6 +3,13 @@ paths: - "src/pdf/**" - "src/ui/Pdf*.vue" - "src/ui/pdf*.ts" + - "src/ui/Stamp*.vue" + - "src/ui/stamp*.ts" + - "src/ui/Signing*.vue" + - "src/ui/signing*.ts" + - "src/state/stamps.ts" + - "src/ui/PlaceDialog.vue" + - "src/ui/placeDialogModel.ts" - "src/ui/Signature*.vue" - "src/ui/signature*.ts" - "src/ui/SignaturesStatus.vue" @@ -21,7 +28,7 @@ paths: # PDF tabs -Blank opens PDFs in tabs of their own, shows their pages as MuPDF draws them, and checks their signatures. It never changes a PDF in a tab (stamps, form fills and signing received PDFs come with PR 3b of the e-signing plan). +Blank opens PDFs in tabs of their own, shows their pages as MuPDF draws them, and checks their signatures. It never changes a PDF in a tab: what the user fills in and places on its pages stays in Blank until they sign it, which saves a new file (see "Stamps", "Places and fills" and `signing.md`). ## The helper (`src-tauri/pdf`) @@ -31,14 +38,14 @@ Blank opens PDFs in tabs of their own, shows their pages as MuPDF draws them, an - **AGPL:** MuPDF is AGPL-3.0, like Blank. The helper's corresponding source is Artifex's tag plus our glue and shim in this repository (`src-tauri/pdf`); the FAQ says so. - **The shim** (`helper/shim/blank_mupdf.c`) is the only code that calls MuPDF: every entry point is inside `fz_try`, so an exception is a status, never an abort. It has its own `fz_context` with a counting allocator (the memory cap: past `--memory-limit-mb`, 1536 MB, an allocation fails and the request answers `tooComplex`), a cookie to cancel a render, our font loader, and writes the form's fields and signatures as JSON (`blank_form_json`, its walk capped in fields, depth and visits). A cancel is `cancelled` even after a refused allocation; a refused allocation, MuPDF's out of memory or its limits are `tooComplex`, and so is a page whose drawing swallowed an error after a refused allocation (`cookie.errors`), rather than showing it half drawn. Rust calls it through `helper/src/mupdf.rs` only, never mupdf-sys's bindings. - **Fonts:** a font a PDF names but doesn't embed comes from the system through `blank-system-fonts` (`src-tauri/system-fonts`, the lookup the app's `fallback_fonts` uses too): a family by name, or the CJK font of an Adobe ordering by script and language. Only fonts a PDF may embed (`embeddable`). -- **The protocol** (`src-tauri/pdf/ipc`, crate `blank-pdf-ipc`, no MuPDF, no Tauri): frames `"BPF1" | header len u32 LE | body len u64 LE | JSON header | body` (`frame.rs`) over the helper's stdin/stdout; version 1 (`protocol.rs`): the helper's first frame is a hello with `protocol` and `ops`; ops `open` (body: the PDF), `pages` (at most `PAGES_PER_REQUEST`, 4096, a request: `Supervisor::pages` batches them, since one answer for ~16k pages would outgrow the 1 MB header), `render` (answer body: RGBA, opaque, at most `MAX_PIXELS`; `serve` and the client both check the size against the pixels), `fields`, `signatures`, `cancel`, `ping`. `fields` and `signatures` (and the shim's form JSON) aren't asked by the app in this step: they are there for filling in forms and placing signature fields next, and the signature check reads the PDF itself (`signing/verify.rs`). An answer too big for a frame is sent as `tooComplex`, and `write_frame` refuses to write one. A breaking change raises `PROTOCOL` and the magic; a new op is optional within v1 (`unknownOp`); the app doesn't read the hello's `ops` yet. Positions are display points: from the top left of the crop box after `/Rotate`, UserUnit applied. `engine.rs` is the `PdfEngine`/`PdfDoc` traits MuPDF implements (a PDFium one would fit behind the same traits), `serve.rs` the helper's loop (a reader thread that takes `cancel` at once, the engine on the main thread), `client.rs` the app's side. +- **The protocol** (`src-tauri/pdf/ipc`, crate `blank-pdf-ipc`, no MuPDF, no Tauri): frames `"BPF1" | header len u32 LE | body len u64 LE | JSON header | body` (`frame.rs`) over the helper's stdin/stdout; version 1 (`protocol.rs`): the helper's first frame is a hello with `protocol` and `ops`; ops `open` (body: the PDF), `pages` (at most `PAGES_PER_REQUEST`, 4096, a request: `Supervisor::pages` batches them, since one answer for ~16k pages would outgrow the 1 MB header), `render` (answer body: RGBA, opaque, at most `MAX_PIXELS`; the page as `fz_run_page` draws it but without unsigned signature fields, over which MuPDF draws an orange "SIGN" arrow in place of their appearance, `run_page` in the shim: Blank's target shows there, and the user's signature then covers it; `serve` and the client both check the size against the pixels), `text` (`PageText`: the words of the page's contents and annotations, not the widgets' values, each box spanning at most 0.8 em above the baseline and 0.25 below, as MuPDF's quads span the font's line gap too; and `rules`, drawn thin horizontals ≥ 40 pt wide, ≤ 2 pt high, not white), `fields` (a choice's `options` and `value` by their display texts, of `[export display]` pairs too; the signature check reads the PDF itself, `signing/verify.rs`, so there's no op for signatures), `setFills` (stateless: the whole set, on a document opened afresh from the bytes, appearances regenerated by MuPDF with `pdf_update_page` on the pages loaded together; answers the pages whose fields' values changed; a refused set keeps the last, and its failure names the field of the fill that failed, `Failure::field`), `save` (an incremental update of the filled document, from a document opened afresh, since MuPDF counts what it wrote as saved; refused `repaired`, also for bytes before the header, whose offsets `pdf_write_document` gets wrong, and `encrypted`), `cancel`, `ping`. **Fills** (`Fill`): a checkbox `true`/`false`, a radio group the `onValue` of a widget or "Off", a choice one of its options by what it shows (its export value is set) or any text in an editable combo box, a text field its text (within `/MaxLen`, one line unless multiline; a tab is a space, `filled` in mupdf.rs). MuPDF draws every field's value in the base 14 font its `/DA` names (else Helvetica) with Windows-1252, and anything else in fonts it doesn't embed or as a replacement character, so a value with characters outside that encoding or font is refused `missingGlyphs` (`shows` in the shim), never set. It's checked on what MuPDF draws: a text field's value, a combo box's export value (MuPDF draws `/V`, not the display text), every option of a list box (it draws them all). `choices.pdf` has such pairs. Read-only, push button and signature fields, unknown names and a name that only starts a field's are `badRequest`. The supervisor keeps the fills that held (`Supervisor::set_fills`, `fills`; a refusal is `Refused { error, field }`) and gives a new helper them again after `open`; `save`'s answer may be as big as the PDF and a page's pixels (`max_answer`). An answer too big for a frame is sent as `tooComplex`, and `write_frame` refuses to write one. A breaking change raises `PROTOCOL` and the magic; a new op is optional within v1: the client asks no op the hello's `ops` lacks (`unknownOp`), and an engine without one answers `unknownOp` (the traits' defaults). Positions are display points: from the top left of the crop box after `/Rotate`, UserUnit applied. `engine.rs` is the `PdfEngine`/`PdfDoc` traits MuPDF implements (a PDFium one would fit behind the same traits), `serve.rs` the helper's loop (a reader thread that takes `cancel` at once, the engine on the main thread), `client.rs` the app's side. - **The helper's stdout carries frames only:** at start it dups fd 1 to a private fd for the frames and points fd 1 at stderr (`main.rs`). Its stderr is null in release builds (MuPDF's warnings may quote the PDF), inherited in debug builds. -- **Limits** (`client.rs`, `limits.rs`): the counting allocator first; then Linux's `RLIMIT_AS` of 4 GB (no `PR_SET_PDEATHSIG`: it fires when the *thread* that spawned the helper ends, and tokio's blocking threads retire when idle; stdin's end covers Blank going); on macOS a thread that kills a helper whose footprint passes 2.5 GB (it stops, under a lock, before the helper is reaped, so it never kills a reused PID); on Windows a job object (2.5 GB, killed with Blank) and no console window. Timeouts: start 10 s, open 20 s, render and the rest 15 s; then `cancel`, and 1 s later a kill (a hang). stdin's end makes the helper exit. A helper whose stdout closes or stops making sense is a crash; it gets 0.5 s to exit, then it's killed, so the client never waits on a live one. The client reads answers of at most `MAX_PIXELS × 4` bytes through a queue of 4. A PDF over 512 MB (`MAX_BODY`) is `tooComplex` before any helper starts. `idle_since` and `stop` never wait for a request that runs (`try_lock`): a helper in use is neither idle nor stopped. `pdf_open` puts the tab in `PdfTabs` before it opens, so a tab closed meanwhile is seen and its helper stopped. +- **Limits** (`client.rs`, `limits.rs`): the counting allocator first; then Linux's `RLIMIT_AS` of 4 GB (no `PR_SET_PDEATHSIG`: it fires when the *thread* that spawned the helper ends, and tokio's blocking threads retire when idle; stdin's end covers Blank going); on macOS a thread that kills a helper whose footprint passes 2.5 GB (it stops, under a lock, before the helper is reaped, so it never kills a reused PID); on Windows a job object (2.5 GB, killed with Blank) and no console window. Timeouts: start 10 s, open 20 s, `setFills` 10 s, `save` 30 s, render and the rest 15 s; then `cancel`, and 1 s later a kill (a hang). stdin's end makes the helper exit. A helper whose stdout closes or stops making sense is a crash; it gets 0.5 s to exit, then it's killed, so the client never waits on a live one. The client reads answers of at most `MAX_PIXELS × 4` bytes, `save`'s of the PDF opened and that much more, through a queue of 4: each answer is held to its own request's limit (`AnswerLimit`, the request's id and limit, set before it's written), which the reader picks once it read the answer's header (`read_frame_by`), so an answer never gets the limit of the request before or after it. A PDF over 512 MB (`MAX_BODY`) is `tooComplex` before any helper starts. `idle_since` and `stop` never wait for a request that runs (`try_lock`): a helper in use is neither idle nor stopped. `pdf_open` puts the tab in `PdfTabs` before it opens, so a tab closed meanwhile is seen and its helper stopped. - **Restart policy** (`Supervisor`): a crash or a hang is a failure. After the first, the next request starts a new helper, which opens the PDF again (the bytes the supervisor holds, or the file read again and checked against its SHA-256); the request that failed isn't asked again. After the second, `Failed`: the tab shows "Blank can't show this PDF any more", and **Open again** makes a new supervisor. A helper unused for 2 minutes, or past 4 running, least recently used first, is stopped, which isn't a failure. ## The app's side (`src-tauri/src/pdf_view`) -- `PdfTabs` keeps each tab's supervisor, file and SHA-256; `pdf_open` reads the file, starts the helper and returns the pages (`Opened`); `pdf_render` returns the pixels with the width and height as two u32 LE at the end (raw RGBA through `tauri::ipc::Response`, no image protocol); `pdf_cancel`, `pdf_close`; `pdf_verify` reads the file again, checks its SHA-256, and checks its signatures (below); `pdf_print` reads it again (`read_checked`) and hands it to the system's print dialog through `print::prepare`/`print::send`, so a PDF of up to 512 MB never travels through the webview. +- `PdfTabs` keeps each tab's supervisor, file and SHA-256; `pdf_open` reads the file, starts the helper and returns the pages (`Opened`); `pdf_text`, `pdf_fields` and `pdf_set_fills` (src/pdf/ipc.ts: `pageText`, `pdfFields`, `setFills`, types `PageText`, `PdfField`, `Fill`) ask the tab's helper (`pdf_set_fills` fails with `{ kind, field }`: the kind, as the others fail with, and the full name of the field whose fill the helper refused, else null; the name never reaches the log); `filled_bytes` gives the signer the file (read again and checked against its SHA-256: `FileChanged`/`FileMissing`) as it is without fills, else with them as MuPDF's incremental update, which must start with the file's bytes; words and values never reach the log; `pdf_render` returns the pixels with the width and height as two u32 LE at the end (raw RGBA through `tauri::ipc::Response`, no image protocol); `pdf_cancel`, `pdf_close`; `pdf_verify` reads the file again, checks its SHA-256, and checks its signatures (below); `pdf_print` reads it again (`read_checked`) and hands it to the system's print dialog through `print::prepare`/`print::send`, so a PDF of up to 512 MB never travels through the webview. - Failures reach the webview as a kind (`PdfError::kind`) and the log as `PDF: : `; the path of a PDF is logged at info when it opens (`PDF: opening `), never in a warning. Never what a PDF holds. A file read again that isn't the one opened is told apart: `missing` (it went), `changed` (it holds another PDF), `unreadable`: `pdf_render` turns `PdfError::Source` (a helper stopped while idle that can't open it again) into one of them (`file_problem`), `pdf_print` fails with the `PrintError` kinds `missing`/`changed`/`notOpen`, and `pdf_verify` says it in its message. - `helper_path()` finds the helper: on Linux first in `/../lib/blank/blank-pdf` (`/usr/lib/blank` of the packages, `usr/lib/blank` in the AppImage, `$SNAP/usr/lib/blank`; Tauri's `resource_dir` is the same folder), then next to the app (`current_exe().parent()/blank-pdf[.exe]`), where macOS's and Windows' packages have it and every build has it in `target//` (tauri-build copies the sidecar or the resource there); in debug builds `BLANK_PDF_HELPER` may point elsewhere. @@ -55,13 +62,34 @@ Blank opens PDFs in tabs of their own, shows their pages as MuPDF draws them, an ## The webview - **Tabs** (`src/editor/tabs.ts`, see `tabs.md`): `Tab.kind: "pdf"`. A PDF tab's `TabDocument` holds its `pdf` (`PdfTabDocument`, `src/pdf/document.ts`: the path, what opening found, the problem that shows instead of the pages, the pages that failed, the checked signatures) and an empty placeholder state (`src/pdf/placeholder.ts`), whose plugin drops every change of the document and hands the focus the editor gets on to the PDF's pages. The shown tab's PDF is `shownPdf` (`src/state/pdf.ts`); `path` and `importedFrom` are null in a PDF tab, so nothing that saves or exports markdown sees its path. Storage keeps no document of a PDF tab; it reads the PDF again when it shows, in the background (`openingPdfTab`, `openInBackground`, for a restored tab in `loadTab` and for one opened from the command line, the file manager or Open in `pdfTab`): the tab says "Opening …" until the helper has it, so a slow helper holds up neither the start, nor a switch, nor the tabs' queue, and a PDF it can't open says why in its tab. The result lands only while the tab still shows that opening (by identity), so Open again meanwhile wins. `reopenPdf` is Open again and Find it… (it shows "Opening …" too; then the pages take the focus back), `showPdfProblem` puts a tab's problem in place of its pages (printing found the file gone or changed), and `forgetSignatureChecks` has the signatures checked again, counting in `signatureChecks`, so a check in flight when trust changed is done again (`PdfView.vue`). The problems (`PdfProblem`, `problemText`): missing (Find it…), unreadable, changed, damaged (also a PDF without pages), tooComplex (also a timeout or hang), needsPassword, start, stopped (a crash while it opened), failed (two crashes); a render's failure goes through `withRenderFailure`: the tab's problem for `failed`/`missing`/`changed`/`unreadable`/`start`, else "couldn't draw" on that page. A `closed` failure (the tab closed or opened again) isn't logged. -- **Commands** (`src/pdf/commands.ts`): `commandFor` and the keys run every command through `forPdf`; in a PDF tab the commands of the text can't run (`editor.can` is false, so the toolbar shows them disabled, the style menu and Insert ▸ Table included (`formatToolbarModel.ts`), and the tab row's Blocks button with `pdfRefusal` as its tooltip) and their keys announce why (`pdfRefusal`, the one table of reasons, each a sentence: the main menu's disabled items and its search's results show the same sentence as their tooltip, through `MainMenuDeps.refusal`, and Export ▸ itself, with the first export's reason, when every export refuses). Every way to sign (Export signed PDF, Signature block, Your signature…, Your initials…) says `SIGN_RECEIVED_REFUSAL` ("Blank can't sign PDFs you received yet. …") until PR 3b. The window's, the tabs' and the app's commands run as anywhere (`RUN_IN_PDF`: also Print, the zoom, the main menu, Keyboard shortcuts, Guide and About). +- **Commands** (`src/pdf/commands.ts`): `commandFor` and the keys run every command through `forPdf`; in a PDF tab the commands of the text can't run (`editor.can` is false, so the toolbar shows them disabled, the style menu and Insert ▸ Table included (`formatToolbarModel.ts`), and the tab row's Blocks button with `pdfRefusal` as its tooltip) and their keys announce why (`pdfRefusal`, the one table of reasons, each a sentence: the main menu's disabled items and its search's results show the same sentence as their tooltip, through `MainMenuDeps.refusal`, and Export ▸ itself, with the first export's reason, when every export refuses). Every way to sign your own documents (Export signed PDF, Signature block, Your signature…, Your initials…) says `SIGN_RECEIVED_REFUSAL` ("In a PDF, place your signature from the signing toolbar, then choose Sign on it. …"). Save and Save as say `SIGN_TO_KEEP`, but Save's key runs `pdf.sign`; Undo and Redo run the stamps' history while it has a step (`stepStamps` in `placeActions.ts`: it announces what the step changed, `stepChange`, e.g. "Signature removed", "Place back", "Form entry undone", and gives a found place that shows again, or a stamp that's back, the focus), else say there's nothing to undo or redo. The window's, the tabs' and the app's commands run as anywhere (`RUN_IN_PDF`: also Print, the zoom, the main menu, Keyboard shortcuts, Guide and About). - **The view** (`src/ui/PdfView.vue`, `PdfPage.vue`, `pdfViewModel.ts`): in place of `#page-view`, `#pdf-view` places the pages with `pdfDeskLayout` (`src/state/pageView.ts`: `sheetLayout` of `src/engine/frames.ts`, which the markdown pages' "pages" view uses too, at the window's zoom; Fit is one scale for the widest page, each centred), from the view's size and scroll it publishes in `pdfViewport`, mounts those near the view (`useDeskScroll`, shared with the page view, which also brings back the tab's spot and serves the page menu's `pageScrollRequest`), and asks for those without a bitmap through `RenderQueue` (`src/pdf/renderQueue.ts`: one at a time, those in view first, dropped or cancelled once they leave). A page is drawn at the next eighth above the frame's scale × the device pixel ratio, at most 16 MP (`renderScale`, `MAX_PAGE_PIXELS`, matching the helper's `MAX_PIXELS`); its bitmap's key has that scale (`pageKey`), so a zoom draws it again, and while it's drawn, the last bitmap shows stretched. Bitmaps go into `pdfBitmaps` (`src/ui/pageBitmaps.ts`), an LRU of its own (192 MB, so a third of it, the most the cache keeps of one bitmap, holds a page of `MAX_PAGE_PIXELS`: one it skipped would be drawn again and again), so a PDF never evicts the markdown pages' bitmaps; the pages mounted are pinned there (`pin`), so at a large zoom they go over its room for a while rather than drawing each other away forever; `RenderQueue` draws a page again that was cancelled and wanted back before the cancel came back; a closed tab's go (`bootApp` watches the tabs, also while no PDF shows), and a bitmap made for a tab that closed meanwhile is dropped. When the pages unmount with the focus (a markdown tab shows), the text takes it. It holds the focus (`pdfViewFocused` in `uiTakesFocus`), scrolls with the keys (Tab and Shift-Tab move the focus as anywhere, not Indent's refusal), and publishes `pdfPageInView` for "Page N of M". The status bar shows the page, the signatures, the paper of the first page (`pdfPaper`) and the zoom; the word count, language, spelling, view and focus mode items hide. - **Zoom:** a PDF tab follows the window's zoom (`pageZoom`, one for every tab, as in markdown tabs; see `layout-engine.md`, "Zoom"): `sheetLayout` takes it through `sheetScale`, and `zoomFactor` reads Fit's scale from `pdfDeskLayout` in a PDF tab. It needs no engine: the zoom commands, `ZoomControl` and the main menu's Zoom row work in a PDF tab without it. A key or a button keeps the spot in the middle of the view (`middleAnchor` in `frames.ts`, which the page view's `zoomAnchorNow` uses too), Ctrl+wheel the one under the pointer (`useWheelZoom`, shared with the page view), through `zoomAnchor` and `useDeskScroll`'s `zoomed`; wider than the view, `#pdf-view` scrolls across. - **Print:** `file.print` (Mod-P, the main menu, the tab's menu, also without the engine) prints a PDF tab's file itself, unchanged, without Blank's print dialog, whose pages, pages per sheet, scale and preview are the engine's: `printPdfFile` (`src/print/job.ts`) has the native side print it (`printPdfTab`, `pdf_print`), which reads the file, checks it against the SHA-256 the tab opened (so it never prints something other than what the tab shows), and hands it to the system's dialog on its first page's paper, one copy, actual size. A file that changed or went says `PDF_CHANGED`/`PDF_MISSING`, and its tab then shows the problem with Open again or Find it… (`showPdfProblem`). Linux's dialog leaves the pages and scale chosen to Blank, which can't apply them to a foreign PDF: Blank says so before the dialog opens (`PDF_PRINTS_WHOLE_FIRST`), and if they're chosen all the same it prints nothing and says `PDF_PRINTS_WHOLE` (outcome `whole`). Failures notify and announce (`insteadForPdf`: "Open the PDF in another app to print it"), a PDF Blank couldn't open says `PDF_NOT_OPEN`, one still opening `PDF_STILL_OPENING`. E2E's `blankPrintCapture` gets the file as for Blank's documents. +## Stamps (`src/pdf/stamp*.ts`, `src/ui/Stamp*.vue`, `src/ui/SigningToolbar.vue`) + +- **The model** (`src/pdf/stampModel.ts`, pure): a `Stamp` is `{ id, kind: signature | initials | date | place, page (from 0), rect (display points), natural (its size when placed), repeat? (initials only), place? (the found place it fills), spec (its `StampSpec` at the natural size; `scaledSpec` scales the letters with the rect) }`. `clampRect` keeps ≥ `MIN_ON_PAGE` (10 pt) on the page, `nudge`/`moveTo`, `resizeTo`/`resizeBy` (shape kept, 50–300 % of `natural`, from the fixed corner), `withStamp`/`withoutStamp`/`toggleRepeat`, `ghostsOf` (repeated initials on the other pages, `fitInto` them as `stamp.rs`'s `repeat_into` does: made smaller, shape kept, where they don't fit, and as far from the nearer of the left and right edges and of the top and bottom edges as on the page they were placed on, a tie going left and up, never off the page), `defaultSpot` (a signature centred in the lower third of what shows of the page in view, initials in the bottom right corner as a signed export puts them, `initialsRect` of `src/signing/initials.ts`, a date or place `GAP` below the last signature and what stands under it, else as a signature) and `spotAt` (centred on a click). Signature and initials are `markSize` (the picture's shape in 180 × 60 or 56 × 28 pt), dates and places their `naturalSize` from the stamp engine. The history is a `StampBook` of snapshots (`Sheet`: the stamps, the form's `fills` and the `dismissed` found places, so one undo steps back through whichever changed; `changedSheet`/`changed`, `undone`, `redone`, 100 deep; nudges of one stamp within 500 ms are one step; a drag is one step, since it commits once on release). +- **State** (`src/state/stamps.ts`): `stampBooks` (each PDF tab's `{ book, sha256 }` by tab id), `selectedStamp`, `placingStamp` (the tool a click armed), `shownPdfRules`, `signingTabs` (the tabs being signed, whose stamps, fills and places wait: `changeStamps`, `changeSheet`, undo and redo say `SIGNING` and change nothing), and for the places `pdfPlaces`, `pageRevisions`, `fieldProblem`, `lastPlace` (below). Written only through `src/pdf/stampBooks.ts` (`changeStamps`, `undoStamps`/`redoStamps`, `forgetStamps` when the tab closes, `restoreStamps`), which frees the engines of looks no stamp has any more (`releaseStamp`; `isHeld` tells whether one is kept, so the export frees its initials' engine unless a tab holds that look); `src/pdf/stampActions.ts` is what the tools and keys do (`placeStamp`: makes the signature first through `openSignatureDialog` when there's none, places, selects, announces "Signature placed on page 2, use the arrow keys to move it"; `removeStamp` "Signature removed", returning the stamp, and `focusFreed` gives the place it filled the focus once it shows again, else the pages, from Delete on it and Remove on its toolbar alike, `repeatStamp`, `updateStamp` (a stamp moved or resized off the place it filled, its middle outside it, `staysOn`, leaves it, so the place shows again), `cancelPlacing` "Placing canceled"). The date is `todayText` (`src/editor/commands/signing.ts`: the settings' format, in the language), the place `signingPlace`; without one, a place stamp first asks for it (`askPlace`: `PlaceDialog.vue`, request `placeDialog`, "Where do you sign?", remembered in `signingPlace`), from the Place tool and from a found place alike. +- **Unsaved and storage:** a PDF tab with stamps or fills is unsaved (`hasChanges`, `refresh` in `src/editor/tabs.ts`, which follows `stampBooks`). Storage writes `stamps:` = `{ sha256, stamps, fills, dismissed }` with the documents' debounce and removes it when none are left or the tab closes; `loadStamps` reads it (`isStamp` and `isFill` drop what they can't read). Every open of a tab's PDF runs `restoreStamps` (`openInBackground`, also for Open again and Find it…): the first time it brings back what was stored, unless something was placed meanwhile; then what the tab holds is made to fit the file opened (`fitted`): the same SHA-256 as it was, a changed file without the stamps on pages it no longer has, the others moved and made smaller onto smaller pages (`fitOnPage`), and without fills, dismissed places and history (announced when stamps or fills went), a file gone or not open as it was. Closing a PDF tab with stamps asks through `UnsavedDialog` ("Sign “‹name›” before you close it?", Discard · Cancel · Sign…, its `title`/`text`/`saveLabel`/`discardLabel`); Sign… runs `signReceived` and keeps the tab. +- **What may be placed** (`src/pdf/toolRules.ts`, pure, on a `ToolContext`: the PDF opened, its rules, whether the engine runs, whether it's being signed, how many signature stamps it has; `shownContext` in `placesRead.ts` makes the shown tab's): `signing_pdf_rules(path, sha256)` (`signing/commands.rs`, `rules.rs`; `fileChanged`/`fileMissing` when the file isn't the one the tab opened) gives `{ signed, docmdp, encrypted, damaged, locked }`, read once per file the tab shows (`readShownRules`, `src/pdf/sign.ts`, into `shownPdfRules`; a failure reads as no limits, and the signer refuses natively). `blocked` (not open, encrypted, repaired/damaged, P=1: nothing, with the toolbar's note), `toolRefusal` (being signed: `SIGNING`; no engine: `STAMPS_NEED_LAYOUT`, the one sentence for it, in `stamps.ts`; signed or certified: initials, date and place off, each naming itself, and a second signature stamp, `ONE_SIGNATURE`, since only one becomes the signature's appearance), `fieldRefusal` (being signed, rules not read yet, a field an earlier signature locked: `FIELD_LOCKED`), `placeRefusal` (a place as its stamp's tool, else as its field), `signRefusal`, `visibleRefusal` (signed or certified: no signature without a certificate, which would go into the pages' content). `RESTRICTED` and `NO_CHANGES` are the native `Refusal`'s words, which `toolRules.test.ts` compares with `mod.rs`. +- **Signing toolbar** (`SigningToolbar.vue`, `signingToolbarModel.ts`): in place of the formatting toolbar in a PDF tab (`TopArea.vue`), the same F6 stop and Alt-F10 (`useFocusRegion` "toolbar", `useRovingFocus`): Signature · Initials · Date · Place, Sign… (`pdf.sign`, no default key; Save's key runs it in a PDF tab through `forPdf`), Next (below; hidden while the PDF has no places), and a note when no tool works. A tool from the keyboard (a click with `detail` 0) places at the default spot and gives the new stamp the focus; a click arms it (`placingStamp`, `aria-pressed`, the crosshair `#pdf-view.pdf-placing`) and a press on a page (`onDeskPress` in `PdfView.vue`) places it there; Esc or a second click puts it down. A disabled tool is `aria-disabled` with its reason as its tooltip, and a press announces it. `pdf.sign` signs (`signReceived`, see `signing.md`, "Stamps and signing received PDFs"). +- **On the pages** (`StampOverlay.vue`, `stampOverlayModel.ts`, `stampDrag.ts`, `StampToolbar.vue`): `PdfView.vue` puts every stamp of the tab (not only of mounted pages), with the places (below), in `.page-desk` after the pages, in reading order (`readingOrder` of `places.ts`: page, line band, x), so Tab follows it (the sheets and `PdfPage` are `aria-hidden`, the desk isn't); each is `role="group"` labelled `stampLabel` ("Signature on page 2, selected"), `tabindex` 0, `#stamp-`, at `stampBox` (display points × the layout's scale on its page's frame), painted by `paintStamp` at that scale (again on `imagesLoaded`). Focus selects it: the accent ring (`outline-offset: 3px`), four `.stamp-handle` corners, and `StampToolbar` right after it in the DOM (Remove; for initials "Repeat on every page", `aria-pressed`), above it, or below at the top. Keys (`stampKeyAction`): arrows nudge 1 pt (Shift 10), Alt+arrows resize, Delete/Backspace remove (the pages take the focus), Esc deselects; the rest goes on to the page view, whose `commandKeys` run Undo/Redo through `forPdf` on the stamps' history. The mouse: pointer capture, `DRAG_START` 4 px, move or resize from a corner (the opposite one fixed), a preview until release. Ghosts of repeated initials are `aria-hidden`, at 0.6 opacity, without pointer events, on the pages mounted only. A stamp that unmounts with the focus gives it to the pages. + +## Places and fills (`src/pdf/places.ts`, `placeModel.ts`, `placesRead.ts`, `placeActions.ts`, `fills.ts`, `PdfTarget.vue`, `PdfField.vue`) + +- **Reading them** (`readPlaces`, from `PdfView.vue` once a PDF shows): `pdfFields(tab)` first, then `pageText` → `findLabels` (`src/signing/labels.ts`) page by page, publishing after each into `pdfPlaces` (by tab, for the SHA-256 read; once per opened PDF; dropped when the tab closes, `forgetPlaces` in `mount.ts`). Failures are logged by kind only, never words or values. `shownPlaces` (a computed) is `placesOf` of the shown tab with `shownPdfRules`' `locked` (such a field is a place with `locked`, shown disabled, never open: `isOpen`, so Next, the Sign dialog's count and Next's count pass it) and the book's `dismissed`; `shownStamps` the stamps on the pages the PDF has; `shownFilled` (`filledOf`: a stamp with `place` = its id, or a field holding a value) and `shownNext` (`nextButton` from `lastPlace`, the place focused last, which a tab switch resets in `bootApp`). Place ids are `fieldPlaceId(name)` and `found:::`; `fieldOfPlace` reads a field's name back. Places, stamps and the overlays go in one `readingOrder` (`places.ts`: page, line band, x). +- **Targets** (`PdfTarget.vue`, `button.pdf-target#place-`, a dashed accent box labelled `placeLabel`, e.g. "Signature · Mieter, page 2"): found places and unsigned `/Sig` fields; a target a stamp fills gives way to the stamp, and none show on a PDF `blocked` refuses (`shownBlocked` in `placesRead.ts`: not open, encrypted, damaged, P=1), where Next hides too and says the reason. Enter, Space or a click is `fillPlace` (`fillOf`): the signature or initials (made first through `openSignatureDialog`) `fitMark`ed into it (shape kept, centred, in a field's middle, or standing on a found line and at least `MARK_ON_LINE` tall there, 36 pt for a signature, rising above the place while it fits the line's width, which `staysOn` allows for; its natural size is the fitted one), a date or place (a place-and-date line: "‹place›, ‹date›", as a place stamp) `fitLines`d at its left on the line, in the letters of the line's label (`lettersOn`: `FoundPlace.size`, the label's word height / 1.05, at least `LEAST_LETTERS`, 9 pt), smaller only when it's wider than the line; attached (`stamp.place`), selected and focused; `placeRefusal` holds (as the tools' for a stamp), and a target it refuses is `aria-disabled` with the reason as its tooltip. Delete or Backspace on a found place is `dismissShownPlace` ("Place dismissed", one step of the history, no longer in Next); fields can't be dismissed. +- **Fields** (`PdfField.vue`, `pdfFieldModel.ts`): a text field is an input (a textarea when multiline) over the widget, transparent until it has the focus (`.editing`), so the bitmap shows MuPDF's appearance; on focus it takes the field's value, else `prefillText(prefillOf(field))` (the user's name, today's date, `signingPlace`, or both), selected, which only Enter or typing fills in (Tab passing through leaves the field as it was); `maxLen` is its `maxlength`; Enter (Mod-Enter in a multiline one), Tab or blur commit (`commitFill`), Esc drops what was typed. Its keys stay in it (`fieldKeyStays`: all but Tab, Alt and Ctrl/Cmd keys other than select all, copy, cut, paste, undo, redo), so Save's key still signs. A checkbox is `role="checkbox"` (toggles), a radio group `role="radiogroup"` of `role="radio"` buttons on its widgets (`radioBoxes`, each choosing its `onValue`), a combo or list box a `MenuButton class="select pdf-choice"` of its options. Fields and targets have their name (`placeName`) as their tooltip, since a page may not show it. A field `placeRefusal` refuses (`PdfView.vue` hands each its `refusal`) is `aria-disabled` with the reason as its tooltip instead, an input `readonly`, and typing or pressing it says why. +- **Fills** (`fills.ts`): one after another per tab (a queue: each works on the set the one before left, and a value the field holds already is no step, e.g. Enter then blur); `commitFill` asks `fieldRefusal` first, then sends the whole set with the new value (`setFills`); held, it's one step of the book (`changeSheet`) and the pages it returns draw again (`pageRevisions`, `:` → a count that's part of the bitmap's key, `pageKey(slot, scale, revision)`, so the old bitmap shows stretched meanwhile); refused `missingGlyphs` (`missingGlyphs` in `placeModel.ts`: "This field's font can't show “Ł”.", the first character of the field's value outside Windows-1252, `unshown`, as the shim's `shows` checks it; else "This field's font can't show some of these characters.", e.g. a list box's options) or `badRequest` it sets `fieldProblem`, shown above the field, where it covers no other field (`.pdf-field-problem`, the input `aria-invalid`), and the last set stays; another failure says "Blank couldn't fill in this field". `syncFills` (a watcher in `PdfView.vue` on the tab, its opened PDF and `stampBooks`) sends the book's set whenever it isn't the one the helper holds (`sent`, by tab and opened PDF): after undo or redo, once restored, and to a helper opened afresh (Open again: a new `opened`); what the helper holds is recorded once it took it, and a set it didn't take is said (`FORM_FAILED`) and sent again with the next change. `forgetFills` drops a closed tab's, its page revisions too. +- **Next** (`pdf.next`, bindable, no default key, `nextShownPlace`; the toolbar's `.signing-next`, "Next · i of n", "All places filled" and `aria-disabled`): `goNext` focuses the next unfilled place after `lastPlace`, scrolls it to the middle (`scrollIntoView`) and announces `nextMessage` ("Signature on page 3. Press Enter to fill it in.", "… Type to fill it in." for a text field). + ## Signatures (`src-tauri/src/signing/trust.rs`, `src/ui/Signature*.vue`) - `verify.rs` checks each signature (see `signing.md`); `Coverage` says how much of the file it covers. A timestamp token that can't be read leaves `timestamp_unreadable` set and isn't a failing check; one that doesn't hold is. `timestamp_authority` is the certificate in the token: Blank checks that the token holds, not who made it. `weak` names SHA-1 when the digest, the signature algorithm or the SubFilter relies on it. The form's fields come from `form_fields` (`signing/pdf/mod.rs`, which signing uses too): each field once (a set of the ids seen), capped in visits, depth, kids per field and the full name's length, and the pages' widgets are looked at once per page, capped too, so a hostile form can't loop or run out of memory. The check still runs in Blank's own process (lopdf reads the whole PDF there); moving it into the helper, or a process of its own, is the next step if lopdf itself turns out to be attackable. -- `trust.rs`: `classify` → `own` (the user's `certificate.cer`), `trusted` (`signing/trusted/.cer`, written by `signing_trust` after the user confirmed the fingerprint, removed by `signing_untrust`), `issuedBy` (not self-signed: Blank checks neither certificate authorities nor revocation), `unknown`; `status` → `valid` (whole file, or only validation data later), `signedAgainLater`, `changedAfterSigning`, `invalid`; `checked_all` makes the `CheckedSignature`s the webview shows (the time from the timestamp, else `/M`, in ISO 8601, without an offset when `/M` has none; the timestamp's authority by name, `timestampTrusted` only when its certificate is the user's or one they trust; `weak`; for `signedAgainLater`, `laterSigner`, who signed next). +- `trust.rs`: `classify` → `own` (the user's `certificate.cer`), `trusted` (`signing/trusted/.cer`, written by `signing_trust` after the user confirmed the fingerprint, removed by `signing_untrust`), `issuedBy` (not self-signed: Blank checks neither certificate authorities nor revocation), `unknown`; `status` → `valid` (whole file, or only validation data later, or later revisions that only signed again: `Coverage::SignedOnlyAgain`, `only_signatures_after` in `verify.rs`, new signature fields, widgets, appearances and values, empty signature fields filled in, the pages' /Annots and the form's /Fields grown by them only, /SigFlags; its `laterSigner` is set too), `signedAgainLater` (later revisions changed more and were signed), `changedAfterSigning`, `invalid`; `checked_all` makes the `CheckedSignature`s the webview shows (the time from the timestamp, else `/M`, in ISO 8601, without an offset when `/M` has none; the timestamp's authority by name, `timestampTrusted` only when its certificate is the user's or one they trust; `weak`; for `signedAgainLater`, `laterSigner`, who signed next). - The webview: `signaturesModel.ts` says it in words: own and trusted are "Valid", the others only "Intact · says it's issued by …" (the issuer is only what the certificate claims) or "Intact · signer unknown"; the status bar's item (`SignaturesStatus.vue`, command `view.signatures`, no key) sums them up ("Not signed", "Signed", "Signed by 2", "Signed, signer unknown", "Changed and signed again", "Weak signature", "Changed after signing", "Signature invalid", and "Signatures not checked" when the check failed, with ": the file changed" or ": the file is gone" when `pdf_verify` says `fileChanged`/`fileMissing` (`SignaturesUnchecked`; the popover then offers Open again for a changed file); nothing while it runs, and `showSignatures` then only announces it; a result that isn't "ok" is announced, quietly, once it's in). A signature signed again later is never "unchanged": it's "Valid (or Intact) for the version it signed; changed and signed again later by ‹laterSigner›"; a weak one is "Weak: SHA-1 can be forged"; a timestamp is "timestamped by ‹authority› (not checked)" unless trusted; `SignaturesPopover.vue` (request `signaturesPopover`) lists them, `SignatureDetailsDialog.vue` (request `signatureDetails`) shows one, with "Blank doesn't check certificate authorities or revocation." only in its note, and **Trust this certificate…** asks with the fingerprint (`confirmTrust`, `src/signing/trusted.ts`). Settings ▸ Signing ▸ Trusted certificates is `TrustedRows.vue`. + +## Known limits + +- **Found places come back on a signed result:** a signature or date Blank drew into the page content as a picture stands on its line, but `findLabels` sees only the page's words and rules, not images, so opening the signed PDF finds those lines again and Next counts them ("Next · 1 of 2"). Telling them apart needs the images' rects from the helper's `text` op (a line with a picture over it isn't free); deferred. diff --git a/.claude/rules/signing.md b/.claude/rules/signing.md index f166d074..9bfb4643 100644 --- a/.claude/rules/signing.md +++ b/.claude/rules/signing.md @@ -1,6 +1,8 @@ --- paths: - "src/signing/**" + - "src/pdf/sign.ts" + - "src/pdf/receivedPlan.ts" - "src/editor/commands/signing.ts" - "src/ui/Sign*.vue" - "src/ui/sign*Model.ts" @@ -18,7 +20,7 @@ paths: # Signing -Blank signs what the user writes by exporting a signed PDF; a markdown file never holds a signature. It also checks the signatures of the PDFs it opens in PDF tabs, and keeps the certificates the user trusts (`trust.rs`, see `pdf.md`). The plan and its later parts (certificates, PAdES, signing received PDFs) are in the e-signing plan; this is what is built. +Blank signs what the user writes by exporting a signed PDF; a markdown file never holds a signature. It also fills in and signs the PDFs the user received, in PDF tabs, checks the signatures of the PDFs it opens, and keeps the certificates the user trusts (`trust.rs`, see `pdf.md`). The plan is the e-signing plan; this is what is built. ## The user's signature and initials @@ -39,13 +41,31 @@ Blank signs what the user writes by exporting a signed PDF; a markdown file neve ## Certificates and digital signatures (native, `src-tauri/src/signing/`) -- Ported from the spike (`spike/signer` on `spike/signing`, with its SPIKE.md): `keys` (a P-256 key and a self-signed certificate made with rcgen, for document signing, in a PKCS#12 file of 600,000 PBKDF2 rounds, returned with its identity so it isn't read back; any .p12 read, NSS's BER ones through a shim that lets p12-keystore check the MAC on the original bytes and then reads the DER copy without one; `OpenError` says why one doesn't open: `WrongPassphrase`, `Unsupported`, `Malformed`), `cms` (CAdES-BES SignedData over the byte ranges, ESS signing-certificate-v2, no signing-time; a timestamp token as an unsigned attribute), `tsa` (RFC 3161 over reqwest, only the signature value's SHA-256 sent), `pdf` (lopdf's incremental update with our guards: the old trailer cleaned, reconstructed xrefs, encrypted files and DocMDP P=1 refused; a /Sig field and widget with an empty appearance, since the block is in the page content and Blank draws no frame), `verify` (every signature, against USF and wrapping attacks; `valid()` doesn't cover later bytes, `covers_the_whole_file()` does), `certificate` (`CertificateInfo`, the one summary of a certificate, and `common_name` from the name's parts), `commands` (what the webview asks for). HTTP clients come from `src-tauri/src/http.rs`, which installs rustls' provider. -- The key and certificate are `key.p12` and `certificate.cer` in the app config's `signing` folder. The key never goes to the webview: the passphrase comes once per signing (`signing_check_passphrase` before anything is exported, then `signing_sign_pdf` with the PDF as the raw body and the options in the `blank-signing` header), and a signed PDF is checked with `verify` before it's returned (`check_signed`: valid and covering the whole file, and with a timestamp server a timestamp that holds, else `TimestampUnusable`). `key.p12` is written 0600 on unix, both files through temp files, under one lock (`FILES`) with removing and exporting them; a key there is replaced only when the call says `replace` (else `keyExists`, which the webview turns into `ConfirmDialog`'s "Replace your key?"), and if the certificate can't be put in place the old key comes back (`keep_files`). An email that isn't ASCII is refused with a sentence (rcgen can't hold it), in `certificateDialogModel.ts` and natively. The signature names the certificate's holder (its common name) as /Name, never the block's name. -- Errors are typed, never matched by their text: `keys::OpenError` and `signing::Refusal` (`Restricted`, `Certified`, `NoTimestamp`, `TimestampRefused`, `TimestampUnusable`, whose text is the user's sentence). They're logged by context and kind only (`forTheLog`), never their text, which crypto crates may fill with what they got; the user gets fixed sentences (`SigningError`: `wrongPassphrase`, `noCertificate`, `keyExists`, `failed`). Passphrases are `Zeroizing`. +- Ported from the spike (`spike/signer` on `spike/signing`, with its SPIKE.md): `keys` (a P-256 key and a self-signed certificate made with rcgen, for document signing, in a PKCS#12 file of 600,000 PBKDF2 rounds, returned with its identity so it isn't read back; any .p12 read, NSS's BER ones through a shim that lets p12-keystore check the MAC on the original bytes and then reads the DER copy without one; `OpenError` says why one doesn't open: `WrongPassphrase`, `Unsupported`, `Malformed`), `cms` (CAdES-BES SignedData over the byte ranges, ESS signing-certificate-v2, no signing-time; a timestamp token as an unsigned attribute), `tsa` (RFC 3161 over reqwest, only the signature value's SHA-256 sent), `pdf` (lopdf's incremental update with our guards: the old trailer cleaned, reconstructed xrefs, encrypted files and DocMDP P=1 refused; a PDF Blank didn't write is read with `load_bounded`, each object or cross-reference stream decompressed within 128 MB, there and in `verify`; a /Sig field and widget with an empty appearance, since the block is in the page content and Blank draws no frame), `verify` (every signature, against USF and wrapping attacks; `valid()` doesn't cover later bytes, `covers_the_whole_file()` does), `certificate` (`CertificateInfo`, the one summary of a certificate, and `common_name` from the name's parts), `commands` (what the webview asks for). HTTP clients come from `src-tauri/src/http.rs`, which installs rustls' provider. +- The key and certificate are `key.p12` and `certificate.cer` in the app config's `signing` folder. The key never goes to the webview: the passphrase comes once per signing (`signing_check_passphrase` before anything is exported, then `signing_sign_pdf`, see below), and a signed PDF is checked with `verify` before it's returned (`check_signed`: valid and covering the whole file, and with a timestamp server a timestamp that holds, else `TimestampUnusable`). `key.p12` is written 0600 on unix, both files through temp files, under one lock (`FILES`) with removing and exporting them; a key there is replaced only when the call says `replace` (else `keyExists`, which the webview turns into `ConfirmDialog`'s "Replace your key?"), and if the certificate can't be put in place the old key comes back (`keep_files`). An email that isn't ASCII is refused with a sentence (rcgen can't hold it), in `certificateDialogModel.ts` and natively. The signature names the certificate's holder (its common name) as /Name, never the block's name. +- Errors are typed, never matched by their text: `keys::OpenError` and `signing::Refusal` (`Restricted`, `Certified`, `SignedPages`, `NoSuchField`, `FieldSigned`, `FieldLocked`, `FilledLocked`, `NoTimestamp`, `TimestampRefused`, `TimestampUnusable`, whose text is the user's sentence). They're logged by context and kind only (`kind_of` in `commands.rs`, `forTheLog`/`signingKind` in the webview), never their text, which crypto crates may fill with what they got; the user gets fixed sentences (`SigningError`: `wrongPassphrase`, `noCertificate`, `keyExists`, `timestamp`, `fileChanged`, `fileMissing`, `failed`; "Blank couldn't sign the PDF", or for an update without a passphrase "Blank couldn't add to the PDF"). Passphrases are `Zeroizing`. - The crypto crates are optimized in debug builds (the dev profile in `Cargo.toml`), or opening a key takes minutes; the tests make keys with few rounds (`create_certificate_with`). -- Webview: `src/signing/ipc.ts` (every call; `signingMessage(error, fallback)` with a fallback per action, `signingKind` for the log), `src/signing/certificate.ts` (`signingCertificate`, read once; made, imported, exported, removed after `confirmRemoval`'s question in `ConfirmDialog.vue`, which offers Back up key… first; failures through `reportSigningFailure`), `CertificateDialog.vue` (request `certificateDialog`), `CertificateRows.vue` in Settings ▸ Signing, and the Sign dialog's choice of a digital signature with the passphrase. +- Webview: `src/signing/ipc.ts` (every call; `signingMessage(error, fallback)` with a fallback per action, `signingKind` for the log), `src/signing/certificate.ts` (`signingCertificate`, read once; made, imported, exported, removed after `confirmRemoval`'s question in `ConfirmDialog.vue`, which offers Back up key… first; failures through `reportSigningFailure`), `CertificateDialog.vue` (request `certificateDialog`), `CertificateRows.vue` in Settings ▸ Signing, and the Sign dialog's Sign as with the passphrase. - Making a key or checking a passphrase takes seconds: the Certificate dialog says so ("Creating…", a note in a live region) and the Sign dialog ("Checking…"), and neither can be cancelled meanwhile (Cancel and Esc wait), since the certificate or the PDF would still come. Removing the signature or initials asks nothing: they're made again in a moment. ## Export signed PDF -`exportSigned` (`export.signed_pdf`, `Mod-Alt-Shift-p`): no block, a notification; no signature yet, the signature dialog first; then `SignDialog.vue` (request `signDialog`, closed by `closeRequests` before a tab switch: which block (`targetsOf`/`chosenOf`, `src/signing/targets.ts`), the place, digital or a picture only and the passphrase, the Word warning for imported documents; a certificate made from it switches it to digital), then `runExport` (`exportAs.ts`, the save dialog first, " signed" before the extension of the file captured when the command ran) on the editor's state when the dialog closes, the block still at its place or else the one of its number, never another. It signs the tab that showed when it ran (`activeTabId`, checked before the Sign dialog opens and when it's submitted), and nothing once another shows; the Sign dialog stops after its passphrase check when its request was closed meanwhile. The signature and certificate dialogs stay open across a tab switch: they work on the user's profile, not the document. The PDF's /Name is the certificate's name, else Settings ▸ Signing's, never the block's (the dialog warns when the chosen block names someone else, `namedNote`); the place is the one the page shows, the block's own (the dialog then shows `placeNote` instead of the field) else the one typed, and that is what /Location gets and what is remembered. For a digital signature, its `finish` signs the exported PDF (`signExport`), the field over the block's box, which the export's engine gives (`signatureBox`, `PdfJob.signedPos`); no box or a failure writes nothing, and says so (`signingFailure`, with how to sign without a timestamp while a server is set). +`exportSigned` (`export.signed_pdf`, `Mod-Alt-Shift-p`): no block, a notification; no signature yet, the signature dialog first; then `SignDialog.vue` (request `signDialog` with `mode: "export"`, see below), then `runExport` (`exportAs.ts`, the save dialog first, " signed" before the extension of the file captured when the command ran) on the editor's state when the dialog closes, the block still at its place or else the one of its number, never another. It signs the tab that showed when it ran (`activeTabId`, checked before the Sign dialog opens and when it's submitted), and nothing once another shows; the Sign dialog stops after its passphrase check when its request was closed meanwhile. The signature and certificate dialogs stay open across a tab switch: they work on the user's profile, not the document. The PDF's /Name is the certificate's name, else Settings ▸ Signing's, never the block's (the dialog warns when the chosen block names someone else, `namedNote`); the place is the one the page shows, the block's own (the dialog then shows `placeNote` instead of the field) else the one typed, and that is what /Location gets and what is remembered, with the initials' choice (`signingInitials`, localforage like `signingPlace`, written once a signed PDF is). + +- **The boxes:** the export's engine gives the filled in block's box (`ExportResult.signatureBox`, `PdfJob.signedPos`) and those of the other blocks (`ExportContext.others` → `otherBoxes`, `PdfJob.otherPos`; null for one not on the pages), display points from the page's top left, read before the engine is freed, on the shared engine and in the worker alike. +- **The update** (`finishExport`, the plan from `exportRequest` in `src/signing/exportPlan.ts`, one `signing_sign_pdf` in body mode): `others` an empty field per other block named after its name, else its role; the initials (`src/signing/initials.ts`: `INITIALS_STAMP`, 56 × 28 pt, `INITIALS_MARGIN` 36 pt from the bottom and the side chosen, `initialsRect` on the layout's page size) one repeated stamp as part 1, made with `stampPdf` after `loadMarks`, whose engine is freed then unless a PDF tab's stamps look the same (`isHeld`); `initialsRect` also places a PDF tab's initials, from the keyboard and from the Sign dialog; and for a digital signature the field over the block (no box, nothing written). Without a passphrase it's the stamps-only update, and only with others or initials: else the PDF is written as krilla made it. A failure writes nothing, and says so (`signingFailure`, with how to sign without a timestamp while a server is set). + +## The Sign dialog (`SignDialog.vue`, `signDialogModel.ts`) + +One dialog for both flows, `SignDialogRequest.mode` `"export"` or `"pdf"` (a PDF tab), closed by `closeRequests` before a tab switch; it hands `SignChoices` (`pos`, null in a PDF tab, `place`, `reason`, `passphrase`, null for no digital signature, `initials`, `open`) to `submit`. Rows: **Sign as** (`#sign-dialog-how`, a `MenuButton` select: "‹name› · your certificate", "No digital signature (only the visible signature)", then Create your certificate… without one and Manage…, which closes the dialog for Settings ▸ Signing; its menu gives the focus to the passphrase when there is one; a certificate made from it switches to it), **Sign in** (export, several blocks), **Initials** (`#sign-dialog-initials`, On every page, with the corner below it; disabled with `initialsProblem`: the request's `initials.reason`, e.g. a PDF others signed, else no saved initials, with Add your initials…; a PDF tab follows it at once through `setInitials`), **Place**, **Reason** (`plan.reason`), **Passphrase** (selected on errors). Notes: the time source, a PDF tab's `stampNote` (an invisible signature without a signature stamp, or for no digital signature "Place your signature on the page first…", and Sign… waits) and `emptyFieldsNote`, the others' fields in the export. `confirmPages` (an imported Word document) adds "I checked every page", which Sign… waits for (`canSign`); a PDF tab has "Open the signed PDF" (on). + +## Stamps and signing received PDFs (native, `signing/pdf/`) + +- One command, `signing_sign_pdf`: the header `blank-signing` is percent-encoded JSON `{ passphrase, source, plan }` (`SignRequest` in `src/signing/ipc.ts`, `Header` in `commands.rs`), the body the parts (`encodeParts`: a little-endian u32 count, then each part as a little-endian u64 length and its bytes). `source` is `{kind:"body"}` (the export flow: part 0 is the PDF) or `{kind:"tab", tab, path, sha256, fills}` (read again, `fileChanged`/`fileMissing` when it isn't the file the tab opened, `read_for_signing`; the fills come from the tab's helper as their own revision through `pdf_view::filled_bytes`, on the blocking thread, and a fill of a field an earlier signature locked is refused, `FilledLocked`). `sign` reads the PDF once (`load_for_update`) for the signatures it had (`verify_doc`; one that can't be checked fails the update) and the update. `plan.stamps[].part` and the signature's `stamp` index the parts. A `null` passphrase writes the stamps and fields alone, no signature. Pages are from 1, rects display points (`PageGeometry` converts). +- `xobject.rs`: a stamp's one-page PDF (from Blank's engine) becomes a Form XObject of w × h at the origin (its `/BBox` is the page's box, `[x0 y0 x1 y1]`, and a `/Matrix` moves it there when it doesn't start at 0 0): loaded with bounded `LoadOptions`, every object its resources reach copied once with new ids (through a queue, so cycles end; page tree and structure objects become null), `/StructParent(s)` dropped, marked content with an `/MCID` turned into `/Artifact BMC`. For a PDF/A-1 target (`pdfa_part`, the XMP's `pdfaid:part`) the transparency goes: no `/Group`, soft masks of images drawn over white, graphics states opaque. +- `stamp.rs`: `draw_on_page` wraps the page's content in `q … Q` and draws its stamps after it (`cm` from `PageGeometry::placement`, so /Rotate, the crop box's origin and UserUnit hold), with a direct copy of the page's effective (maybe inherited) `/Resources`. A repeated stamp is one XObject drawn on every page, as far from the nearer of the left and right edges, and of the top and bottom edges, as on the page it was placed on, made smaller (shape kept) where it doesn't fit, never off the page (`repeat_into`). `appearance` is a widget's appearance: empty, or a stamp fitted into it. +- `sign.rs` (`update_pdf`): the stamps, then empty `/Sig` fields for the others who sign ("Signature of ‹name›", unique, no periods), then the signature: an existing empty field keeps its name and gets `/V` and the stamp as its `/AP /N`; a new `SignatureN` shows its stamp in the page content on a PDF nobody signed yet and as its appearance on signed or certified ones; or invisible. Without a signer, a signature's stamp goes into the page content. +- `rules.rs` (`Rules::read`, once per update): DocMDP P, signed, FieldMDP locks. `signing_pdf_rules(path, sha256)` (`pdf_rules`, the file read with `read_for_signing`) gives the PDF tab's signing tools the same as `PdfRules { signed, docmdp, encrypted, damaged, locked }` (`load_for_update`'s `Restricted` is `encrypted`, its other failures `damaged`; `locked` the full names an earlier signature locked), read on a blocking thread; see `pdf.md`, "Stamps". Refused natively (`Refusal`): anything on P=1 (`Certified`), stamps in the pages' content of signed or certified PDFs (`SignedPages`), a field that isn't there, is signed or locked, fills of a locked field (`FilledLocked`), encrypted PDFs (`Restricted`, `load_for_update`). +- After the update, `verify_pdf` must find exactly one new signature, valid and covering the whole file (none for a stamps-only update), and every earlier one, known by its byte range, as valid as it was; else the generic sentence and nothing is returned. +- **The webview's side** (`signReceived`, `src/pdf/sign.ts`: Sign…, Mod-S in a PDF tab, the close question's Sign…): `loadMarks`, `loadCertificate`, then the Sign dialog in mode `"pdf"` (its request's shared part from `signDialogBase` in `src/editor/commands/signing.ts`, as the export's; `stamped`: a signature stamp is placed on a page the PDF has; `visibleRefusal` on a signed or certified PDF, which disables "No digital signature" with its reason and makes Sign… wait for a certificate; `emptyFields`: the places still open; `initials` from a repeated initials stamp, its `reason` `toolRefusal("initials")`; `setInitials` (`initialsSetter`) repeats the initials placed, else adds them in the corner chosen of the first page, moves them to another corner, and off takes the repeat off, or the stamp it added; Cancel puts the tab back as it was before the row changed it, `returnTo`). On submit the tab is being signed (`signingTabs`, until any outcome: its stamps, fills, places, tools and Sign… say `SIGNING`); `writeSigned`: the save dialog (`suggestPath` with " signed", or `window.blankTestSavePath` where `__TEST_HOOKS__`), then each stamp on the pages the PDF has, its `stampPdf` at its rect's size (`scaledSpec`) after `loadMarks`, then `signPdf` with `source: { kind: "tab", tab, path, sha256, fills }` and `receivedPlan` (`src/pdf/receivedPlan.ts`, pure: the stamps in reading order as the parts; signed digitally, the signature stamp on a `/Sig` field (else the first) is `signature: existing` with its field, or `new` at its spot, the others page content; without a passphrase every stamp is page content and the signature invisible; `others` empty). Then `writeFile`, `signingPlace` and `signingInitials` remembered, `clearSheet` if the tab still holds what was signed (nothing unsaved; `syncFills` empties the helper's form), "Signed and saved as ‹file›" (`savedMessage`; without a digital signature "Saved as ‹file›, your signature shows as a picture", since the status bar then says "Not signed"), and with "Open the signed PDF" `openPaths([dest])` (imported when it runs: the tabs import this module). A failure is a notification with `signingFailure`'s sentence ("… Nothing was saved."), and nothing is written; the passphrase is checked in the dialog first. +- Tests: `commands/received.rs` on the PDF tab's fixtures with stamp PDFs made by lopdf; `pdftotext -bbox -cropbox`, `pdfsig -nssdir ` and `qpdf --check` where installed (CI never skips them). diff --git a/.claude/rules/state.md b/.claude/rules/state.md index 3de4330e..42da55df 100644 --- a/.claude/rules/state.md +++ b/.claude/rules/state.md @@ -25,12 +25,13 @@ Blank's modules share state through Vue refs in `src/state/`, instead of importi | `appearance.ts` | `themes`, `theme`, `isTheme`, and `colorMode` (accent or mono, see `design.md`) | | `language.ts` | `language`, `languagePicker` | | `spellcheck.ts` | `spellcheck`, `spellcheckStatus`, `spellchecker` | -| `dialogs.ts` | the requests of open dialogs (`linkDialog`, `imageDialog`, `pageSetup`, `unsavedDialog`, `settingsDialog`, `printDialog`, `signatureDialog`, `signDialog`, `certificateDialog`, `confirmDialog`, `signatureDetails`) and popovers (`tocPopover`, `diagramPopover`, `signaturePopover`, `signaturesPopover`) and of the open header or footer strip (`bandEditor`, with `bandEditorDone`, which closes it), `focusTakingDialogs`, those of them that take the focus, and `closeDialog` | +| `dialogs.ts` | the requests of open dialogs (`linkDialog`, `imageDialog`, `pageSetup`, `unsavedDialog`, `settingsDialog`, `printDialog`, `signatureDialog`, `signDialog`, `placeDialog`, `certificateDialog`, `confirmDialog`, `signatureDetails`) and popovers (`tocPopover`, `diagramPopover`, `signaturePopover`, `signaturesPopover`) and of the open header or footer strip (`bandEditor`, with `bandEditorDone`, which closes it), `focusTakingDialogs`, those of them that take the focus, and `closeDialog` | | `drawings.ts` | counters of what modules drew for the pages: `vectorPictures` (a diagram's picture is ready to paint) and `drawnVectors` (the diagram module drew one), which the pages and the page view follow | | `blocksPane.ts` | the blocks pane: `blocksPaneOpen` (persisted), `blocksPaneFocused` (part of `uiTakesFocus`), `blockChoices` (the blocks it offers) and `focusBlocksSearch()`, which asks it for the focus in its search | | `popups.ts` | `tablePicker`, `tableToolbar`, `tableHandles` (with `Point`, `Span`), `blockToolbar`, `ToolbarItem` (with the `command` whose shortcut its tooltip shows), `BoxAnchor` (an `Anchor` with its right end), `contextMenu` (its `owner`, the button that opened it, if a button did), `MenuItem` (with an optional `icon`, `detail` and `look`, a class that draws it like what it makes), `Anchor`, `tooltipsSuppressed` (keeps the controls' tooltips hidden), and `wordCountCard` (whether the word count's card shows) | | `messages.ts` | `announcement` and `spellcheckMessage`, written through `announce()` (`quiet` for what only screen readers hear) and `flashSpellcheckMessage()`; `bootMessages()` clears each after a moment | | `page.ts` | `frontmatter` (of the document, notifying only when it changes), `pageLayout` (its `resolveLayout` over `config`'s defaults, resolved again only when either changes) and `pageFields` (what the placeholders of headers and footers show, the same object while they stay the same) | +| `stamps.ts` | the stamps placed on PDF tabs: `stampBooks` (each tab's stamps, their history and the SHA-256 they were placed on, written through `src/pdf/stampBooks.ts`), `selectedStamp`, `placingStamp` (the tool waiting for a press on a page) and `shownPdfRules` (what the PDF shown allows); the places: `pdfPlaces` (each tab's fields and found places), `pageRevisions` (pages filled in anew), `fieldProblem` (why a field refused what was typed) and `lastPlace` (where Next goes on from); see `pdf.md`, "Stamps" and "Places and fills" | | `pdf.ts` | the PDF tab shown: `shownPdf` (its `PdfTabDocument`, null in a markdown tab; the page view of PDFs writes what it learns into it), `pdfPageInView` (for "Page N of M"), `pdfViewAnchor` (the spot it shows, which `currentViewAnchor` gives storage and a switch in a PDF tab), `pdfViewFocused` (part of `uiTakesFocus`) and `pdfViewport` (its size and scroll, which `pdfDeskLayout` in `pageView.ts` places the pages by); see `pdf.md` | | `pageView.ts` | the page view: `pageView` (the view chosen), `pageLayoutState` (the pages as laid out), `pageCaret`, `pageSelection`, `pageHeadBox` (where the selection's head is painted, with its affinity, e.g. for the IME), `pageDropCaret` (where dragged text would drop), `pageDropGap` (where a dragged block would drop), `pageHoverBlock` (the content block under the pointer), `engineMissing` (true once the editor shows the text itself), `pageScrollRequest`, and `pageViewport` (where the view is and how far it scrolled, down and across, for `src/engine/geometry.ts`; written at most once per frame, and only when it changed); the zoom: `pageZoom` (persisted), `zoomAnchor`, `zoomFactor`, `zoomLabel`, and `viewFrames`/`deskLayout`, the one source of where the pages are shown (see `layout-engine.md`, "Zoom"), and `pdfDeskLayout`, a PDF tab's | | `print.ts` | `printSettings`, what the print dialog remembers (persisted, see `print.md`) | diff --git a/.claude/rules/tabs.md b/.claude/rules/tabs.md index 49762e99..4028047b 100644 --- a/.claude/rules/tabs.md +++ b/.claude/rules/tabs.md @@ -49,7 +49,7 @@ Blank keeps several documents open, one tab each, in the one ProseMirror view: e ## Storage (`src/storage.ts`) -- `session` holds the order, the active tab and each tab without its document; `tab:` holds each tab's `doc.toJSON()`, a markdown tab's only: a PDF tab's PDF is its file. The session is `version: 2` while a PDF tab is open (`SESSION_VERSION`, `sessionVersion`) and 1 otherwise, so an older Blank, which reads only 1, still reads a session without PDF tabs; it keeps a session of 2 as `session-backup` and starts afresh. This Blank reads 1 and 2. Only changed documents are written, at most a second after a change; the documents first, then the session, then the closed tabs' keys are removed, so the session never lists a document that isn't stored. +- `session` holds the order, the active tab and each tab without its document; `tab:` holds each tab's `doc.toJSON()`, a markdown tab's only: a PDF tab's PDF is its file, and what was placed and filled in on it is `stamps:` (`{ sha256, stamps, fills, dismissed }`, see `pdf.md`, "Stamps"); stamps and fills make it unsaved, and closing it then asks whether to sign it first. The session is `version: 2` while a PDF tab is open (`SESSION_VERSION`, `sessionVersion`) and 1 otherwise, so an older Blank, which reads only 1, still reads a session without PDF tabs; it keeps a session of 2 as `session-backup` and starts afresh. This Blank reads 1 and 2. Only changed documents are written, at most a second after a change; the documents first, then the session, then the closed tabs' keys are removed, so the session never lists a document that isn't stored. - Every tab has its document stored, a saved file's too: it is what shows when the file has gone. A tab without unsaved changes reads its file again when it's first shown; one with changes keeps them and reads the file in the background as its baseline. - Restored tabs load when first shown, except the active one. - An earlier Blank's `doc`, `path` and `importedFrom` become the first tab (`migrate`), marked unsaved until its file says otherwise. A stored document that can't be read is kept as `tab-backup:`, a session that can't be read (e.g. a newer Blank's) as `session-backup`. diff --git a/.claude/rules/ui-components.md b/.claude/rules/ui-components.md index f35974c0..e2c60f27 100644 --- a/.claude/rules/ui-components.md +++ b/.claude/rules/ui-components.md @@ -34,7 +34,7 @@ All of the UI around the editor is Vue 3.5. `bootUI` (`src/ui.ts`) runs after `b - **What the keys move between** is found with `shownIn(root, selectors)` (`src/dom.ts`), which leaves out what's inside `[hidden]`: the dialog's Tab trap, the page setup's ↑↓ and the band strip's Tab use it. - **Everything renders through `App.vue`**, into `#ui-app` inside `#ui`, never into `document.body` directly: `#ui` comes after the editor, which is what lets the UI paint above it and take its clicks. A popup of a bar item, whose bar is a stacking context of its own, goes there with `` (the word count's card). Classes on the body come from `useBodyClass`. - **Keep every id, class, role, `data-*` and aria attribute** that E2E, the docs shots and the tests use. -- **Stacking:** `#ui` comes after `.ProseMirror`. The z-index layers are set in `src/scss/main.scss` (e.g. the page view 1, the top area (both rows), the bottom bar and the outline 2, table handles 4, toolbar 5, the floating blocks pane 6 (above the toolbars it may cover; docked, it covers nothing), the header or footer being edited 8 (its strip and slots), dialog backdrop 10, picker and menus 20, the tooltip 30); keep a new part in line with them, below the backdrop unless it is a modal or a menu. `#page-view` (`PageView.vue`), or in a PDF tab `#pdf-view` (`PdfView.vue`) in its place, is fixed between the top area and the status bar at 1, above the hidden editor and below everything else; its page canvases (`PageFrame.vue`), marks and overlay (`PageOverlay.vue`: caret, selection, composition) are positioned inside it and have no z-index of their own. `BlockMarks.vue` (the drop line and the hover hairline of content blocks) is fixed at 1 too, after it in `App.vue`, so it paints over the pages and under the bars. +- **Stacking:** `#ui` comes after `.ProseMirror`. The z-index layers are set in `src/scss/main.scss` (e.g. the page view 1, the top area (both rows), the bottom bar and the outline 2, table handles 4, toolbar 5, the floating blocks pane 6 (above the toolbars it may cover; docked, it covers nothing), the header or footer being edited 8 (its strip and slots), dialog backdrop 10, picker and menus 20, the tooltip 30); keep a new part in line with them, below the backdrop unless it is a modal or a menu. `#page-view` (`PageView.vue`), or in a PDF tab `#pdf-view` (`PdfView.vue`) in its place, is fixed between the top area and the status bar at 1, above the hidden editor and below everything else; its page canvases (`PageFrame.vue`), marks and overlay (`PageOverlay.vue`: caret, selection, composition) are positioned inside it and have no z-index of their own, nor have a PDF's stamps, its places' targets and fields and the selected stamp's toolbar, which come after the pages in `.page-desk`, in reading order. `BlockMarks.vue` (the drop line and the hover hairline of content blocks) is fixed at 1 too, after it in `App.vue`, so it paints over the pages and under the bars. ## Components @@ -68,7 +68,8 @@ All of the UI around the editor is Vue 3.5. `bootUI` (`src/ui.ts`) runs after `b | `BlockMarks.vue` | over the pages: the line where a dragged block drops, the hairline around the content block under the pointer (`blockMarksModel.ts`) | | `TocPopover.vue`, `DiagramPopover.vue`, `SignaturePopover.vue`, `components/BlockPopover.vue` | the settings of a table of contents (`tocPopoverModel.ts`), a diagram (widths from `widthOptions.ts`) and a signature block (`signaturePopoverModel.ts`) below the block toolbar, applied at once; `BlockPopover` places them, focuses the first control, and closes on Esc, Enter in a text field, a press elsewhere or a resize | | `SignatureDialog.vue`, `SignaturePad.vue` | the user's signature or initials: drawn on the pad, typed in a signature font, or from a picture (`signatureDialogModel.ts`, see `signing.md`) | -| `SignDialog.vue` | Export signed PDF's dialog: the block, the place, digital or a picture only (`signDialogModel.ts`) | +| `SignDialog.vue` | the Sign dialog of both flows, Export signed PDF and a PDF tab's Sign… (`mode`): Sign as, the block, the initials, the place, the reason, the passphrase (`signDialogModel.ts`; see `signing.md`) | +| `PlaceDialog.vue` | asks where the user signs, before a place stamp when Blank doesn't know it (`placeDialog`, remembered in `signingPlace`) | | `CertificateDialog.vue` | makes or imports the user's certificate (`certificateDialogModel.ts`), busy while the key is made | | `ConfirmDialog.vue` | the question before something that can't be undone (request `confirmDialog`), with Cancel focused and an optional secondary action that leaves it open | | `components/SidePaneHead.vue` | the head of a side pane (the `side-pane` mixin): its title and the button that hides it | @@ -87,6 +88,9 @@ All of the UI around the editor is Vue 3.5. `bootUI` (`src/ui.ts`) runs after `b | `components/IconButton.vue` | a button that shows only an icon: its label for screen readers and the tooltip, a command's shortcut, `pressed`, `disabled` (aria-disabled), `focusable` | | `settings/SettingsDialog.vue` and the rest of `settings/` | the settings (`Mod-,`): the sections, an inner page in place of its section (`InnerPage.vue`), `SwitchRow.vue` (a setting row with a switch), `EditRow.vue` and `PagedSection.vue` (a setting edited on an inner page), `EntryList.vue` and `AddRow.vue` (a list with Remove and a filter, and its fields), `ThemeCards.vue`, `SigningSection.vue` and `CertificateRows.vue` (Signing); see `settings.md` | | `PdfView.vue`, `PdfPage.vue` | a PDF tab's pages in place of the page view, or why it can't show them (Open again, Find it…, Close tab); one page's canvas; the logic in `pdfViewModel.ts` (see `pdf.md`) | +| `SigningToolbar.vue` | a PDF tab's signing tools in the formatting toolbar's row: Signature, Initials, Date, Place, Sign…, Next (hidden while the PDF has no places) and a note when no tool works (`signingToolbarModel.ts`, on `src/pdf/toolRules.ts`; see `pdf.md`, "Stamps") | +| `PdfTarget.vue`, `PdfField.vue` | a PDF's places on its pages: a target to sign or date (a dashed box), and a field of its form to type into, tick or choose from (`pdfFieldModel.ts`), each disabled with its reason while it can't be filled in (`placeRefusal`); see `pdf.md`, "Places and fills" | +| `StampOverlay.vue`, `StampToolbar.vue` | a stamp on a PDF's page (a group with its canvas, the ring and corners while selected, its keys and drags in `stampOverlayModel.ts` and `stampDrag.ts`), or a repeated stamp's ghost; the selected stamp's toolbar after it (Remove, Repeat on every page) | | `SignaturesStatus.vue`, `SignaturesPopover.vue`, `SignatureDetailsDialog.vue`, `settings/TrustedRows.vue` | a PDF's signatures: the status bar's item, the list above it, one's details with Trust this certificate…, and Settings ▸ Signing ▸ Trusted certificates; the words in `signaturesModel.ts` (see `pdf.md`) | | `FocusModeButton.vue` | focus mode's button at the end of the status bar; see `focus-mode.md` | | `UiTooltip.vue` | the shared tooltip of controls, mounted once; what it shows and when in `tooltipModel.ts` (`tipAttrs`, `watchTips`), its timer in `hoverIntent.ts` | diff --git a/.github/actions/webkit-deps/action.yml b/.github/actions/webkit-deps/action.yml index 26951857..693193b1 100644 --- a/.github/actions/webkit-deps/action.yml +++ b/.github/actions/webkit-deps/action.yml @@ -10,8 +10,9 @@ runs: using: composite steps: # `webkit2gtk-driver` (`webkitgtk-webdriver` from Ubuntu 26.04 on) provides - # WebKitWebDriver, `xvfb` a virtual display, and `xclip` the X selection the - # middle-click paste spec sets + # WebKitWebDriver, `xvfb` a virtual display, `xclip` the X selection the + # middle-click paste spec sets, and `poppler-utils` the pdftotext the PDF + # spec reads a signed PDF's text with - name: install system dependencies shell: bash run: | @@ -32,6 +33,7 @@ runs: "$driver" \ xclip \ xvfb \ + poppler-utils \ ${{ inputs.ffmpeg == 'true' && 'ffmpeg' || '' }} # the desktop portal is registered with D-Bus but never comes up on the runner, so WebKitGTK # and tao wait for its timeouts (~30s) on every app start. Without the service file, the diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 403304e0..f389c557 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -53,7 +53,9 @@ jobs: libxdo-dev \ libssl-dev \ libayatana-appindicator3-dev \ - librsvg2-dev + librsvg2-dev \ + poppler-utils \ + qpdf # tauri-action builds the frontend and the PDF helper through # beforeBuildCommand @@ -64,9 +66,10 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # the app crate (the spell check engine and the system fonts) on every - # platform; the layout engine is tested by the engine job of test.yml, - # with poppler. tauri-action has built the frontend (dist/), which the + # the app crate (the spell check engine, the system fonts and the + # signer) on every platform, the signer's PDFs checked with poppler and + # qpdf on Linux; the layout engine is tested by the engine job of + # test.yml, with poppler. tauri-action has built the frontend (dist/), which the # Rust side needs to compile - name: run rust tests run: cargo test --manifest-path src-tauri/Cargo.toml -p blank diff --git a/CLAUDE.md b/CLAUDE.md index 4c4f5d84..0c84fcb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ Blank is a keyboard-first markdown editor: a Tauri 2 desktop app with a TypeScri - Every spec file gets a fresh app with a temporary profile: `wdio.conf.ts` sets `XDG_DATA_HOME`/`XDG_CONFIG_HOME`/`XDG_CACHE_HOME` for `tauri-driver`. Specs start from the welcome document and the default keymap, and never touch the developer's real data. `restartApp()` in `e2e/helpers.ts` relaunches the app within the same profile, optionally with CLI args. - Type text with `type()` from `e2e/helpers.ts`, never `browser.keys("text")`: WebKitWebDriver drops repeated characters within one key action ("ll" becomes "l"). Use `pressMod()` for `Mod-` shortcuts. Click into editor text with `clickInto()` before typing: the editor learns where a click put the cursor from a later `selectionchange` event, and keys sent before it go where the cursor was. - The hooks tests call in the app (`window.blankGeometry`, `window.blankBootTimes`, …) are compiled in only in `bun run dev` (`import.meta.env.DEV`) and where `__TEST_HOOKS__` is true: a Vite `define` (`vite.config.ts`) that is true in debug builds (`TAURI_ENV_DEBUG`), which E2E runs. Use it for test hooks only, never for a switch users see. -- Name specs `*.e2e.ts`, so vitest doesn't collect them. Native dialogs (open, save as, export) can't be automated, so test file IO by passing a path as a CLI arg, which makes Ctrl+S save without a dialog. +- Name specs `*.e2e.ts`, so vitest doesn't collect them. Native dialogs (open, save as, export) can't be automated, so test file IO by passing a path as a CLI arg, which makes Ctrl+S save without a dialog. Signing a PDF tab asks where to save it: a spec sets `window.blankTestSavePath` (a `__TEST_HOOKS__` hook) to the path it's saved to instead. ## Architecture diff --git a/blank.json b/blank.json index b8138984..e1f421df 100644 --- a/blank.json +++ b/blank.json @@ -74,7 +74,9 @@ "view.zoom_out": "Mod--", "view.zoom_fit": "", "menu.main": "Mod-k", - "edit.find": "Mod-f" + "edit.find": "Mod-f", + "pdf.sign": "", + "pdf.next": "" }, "autocorrect": { "arrows": true, diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 3ee00945..d981d5b5 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -36,7 +36,7 @@ You can open it with `Mod` `O`: Blank turns it into a markdown document and leav ## What is `blank-pdf`? -The program that draws the pages of the [PDFs](./pdf) you open in Blank, with [MuPDF](https://mupdf.com). It comes with Blank: on Linux it's installed in `/usr/lib/blank`, on macOS and Windows next to Blank's own program. It runs once for each PDF tab, so a PDF that crashes it takes down only its tab. It never sends anything anywhere. +The program that draws the pages of the [PDFs](./pdf) you open in Blank, with [MuPDF](https://mupdf.com). It comes with Blank: on Linux it's installed in `/usr/lib/blank`, on macOS and Windows next to Blank's own program. Each PDF tab has its own, so a PDF that crashes it takes down only its tab; Blank stops those of tabs you haven't used for a while, and starts them again when you go back. It never sends anything anywhere. ## Which open source software does Blank use? diff --git a/docs/guide/pdf.md b/docs/guide/pdf.md index fe2ac57c..97079921 100644 --- a/docs/guide/pdf.md +++ b/docs/guide/pdf.md @@ -10,11 +10,75 @@ The pages show as they print: one below the other, white in every theme, as wide To see the pages larger or smaller, [zoom](./pages#zoom) as in your documents: `Mod` `=` and `Mod` `-`, `Ctrl` and the mouse wheel, or − and + in the status bar, where a click on the zoom fits the pages to the window again. The zoom is the same in every tab, and the spot under the pointer, or in the middle of the window, stays where it is. -A PDF tab only shows the PDF: Blank never changes the file. So saving, exporting, formatting, searching and the page setup aren't there: their shortcuts say so, and in the [main menu](./menu) they're greyed out, saying why when you point at them. Close the tab with `Mod` `W` as any other; when you start Blank again, it opens the PDF again from its file. A large PDF opens in the background: its tab says _Opening …_ meanwhile, and you can go on in your other tabs. +Blank never changes the file itself. What you [fill in and place on its pages](#fill-in) stays in Blank until you sign, which saves a new file; so exporting, formatting, searching and the page setup aren't there: their shortcuts say so, and in the [main menu](./menu) they're greyed out, saying why when you point at them. Close the tab with `Mod` `W` as any other; when you start Blank again, it opens the PDF again from its file. A large PDF opens in the background: its tab says _Opening …_ meanwhile, and you can go on in your other tabs. -## Sign a PDF you received {#sign-received} +## Fill in and sign a PDF you received {#fill-in} -Blank can't sign PDFs you received yet: signing them, and filling in their forms, comes with a later version. Until then, sign them in your PDF app, or print, sign and scan them. Your own documents you sign in Blank with [Export signed PDF](./signing#export). +A contract, a form, a lease: Blank shows you where the PDF wants your signature, a date or your details, lets you fill it in from the keyboard or with the mouse, and saves it signed as a new file next to the one you received. + +### The places to fill in {#places} + +When a PDF opens, Blank looks for the places to fill in and marks each with a dashed box: + +- **The PDF's form fields:** text fields, checkboxes, radio buttons, lists, and empty signature fields. Read-only fields stay as they are. A field an earlier signer locked shows, but greyed out: pointing at it says _An earlier signer locked this field, so it can't change any more_, and **Next** passes it. +- **Lines with a label,** in PDFs without a form: a line of underscores, dots or a drawn line next to _Signature_, _Date_, _Place_, _Place, date_ or _Initials_, in English or German (_Unterschrift_, _Datum_, _Ort, Datum_, _Kürzel_, …), and _Unterschrift Mieter_ as _Signature · Mieter_. + +Once Blank found places in a PDF, **Next** in the signing toolbar goes to the next place you haven't filled in yet, in reading order, and says what it is, e.g. _Signature on page 3. Press Enter to fill it in._ It reads _Next · 2 of 5_ while places are left, and _All places filled_ once you're done. Give it a key of your own in **Settings ▸ Shortcuts** (_Next place to sign_). `Tab` and `Shift` `Tab` go through the places and what you placed, as they're read. + +| To | Mouse | Keys | +| -------------------------------------- | -------------- | ------------------------------------------------------- | +| Sign a signature field or line | click it | `Enter` or `Space` | +| Date a date line, place a place line | click it | `Enter` or `Space` | +| Fill in a text field | click it, type | type, then `Enter` or `Tab`; `Esc` drops what you typed | +| Tick a checkbox, choose a radio button | click it | `Space` or `Enter` | +| Choose from a list | click it, pick | `Enter`, `↑` `↓`, `Enter` | +| Leave out a line Blank found | | `Delete` (undo brings it back) | +| Undo, redo | | `Mod` `Z`, `Mod` `Shift` `Z` | + +Your signature goes into a signature field as large as it fits. On a line, it stands on the line, large enough to read, as wide as the line allows. A date or place line gets today's date or your place, in letters as large as the line's label. A text field that asks for your name, the date or the place starts with it: press `Enter` to keep it, or type over it. `Tab` past it leaves it empty. Remove a signature from a place with `Delete` or **Remove**, and the place is back, selected. Move a signature off its place, and the place shows again too: the signature is then one of its own, where you put it. + +The text you type into a form field shows in the field's own font, which the PDF chooses, so it takes Western European letters only: Greek, Cyrillic, Chinese or a Polish _Ł_ say so above the field, naming the first letter it can't show, e.g. _This field's font can't show “Ł”_, and the field keeps what it had. Write it with letters the font has, e.g. _L_ for _Ł_, or ask the sender for a form with another font. + +### Place your signature, initials, date and place {#stamps} + +In a PDF tab, the toolbar above the pages has the signing tools: **Signature**, **Initials**, **Date** and **Place**, then **Sign…** and **Next**. They place a stamp anywhere on a page, also where Blank found no place. + +- **With the mouse:** click a tool, then click the page where it goes: it's placed there, centred on the click. While a tool waits for the click, the pointer is a crosshair; `Esc` or a second click on the tool puts it down again. +- **With the keyboard:** press `Alt` `F10` (or `F6` until you're in the toolbar), go to a tool with `←` `→` and press `Enter` or `Space`. It's placed on the page you're reading: a signature in the lower third, centred; initials at the bottom right; a date or place below your last signature. + +The first time you use **Signature** or **Initials**, Blank asks you to [make them](./signing#your-signature), then places them. The date is today's, written as **Settings ▸ Signing ▸ Date** says, in the language you last chose in a document's status bar; the place is where you signed last. The first time you place a place, Blank asks where you sign, and remembers it. + +What you placed is selected, with a ring and four corners, and a small toolbar above it: + +| To | Mouse | Keys | +| ------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------- | +| Move it | drag it | `←` `→` `↑` `↓` by 1 pt, with `Shift` 10 pt | +| Make it larger or smaller (its shape stays) | drag a corner | `Alt` `→` or `↑` larger, `Alt` `←` or `↓` smaller, with `Shift` 10 pt | +| Remove it | **Remove** on its toolbar | `Delete` or `Backspace` | +| Put initials on every page | **Repeat on every page** on its toolbar | `Tab` to the toolbar, then `Enter` | +| Undo, redo | | `Mod` `Z`, `Mod` `Shift` `Z` | +| Leave it | click elsewhere on the pages | `Esc` | + +It stays on its page, with at least a little of it showing, and grows to at most three times its size and shrinks to half. Repeated initials show faded on the other pages, at the same spot, moved into smaller pages. A screen reader names each stamp, e.g. _Signature on page 2_. + +A PDF tab you filled in or placed something on has a dot, as a document with unsaved changes has. Blank keeps it when you close Blank, and when you start it again, it's back on the pages, as long as the file is the same. If the file changed, when Blank starts or when you open it again with **Open again** or **Find it…**, what was on pages it no longer has is gone, what no longer fits on a smaller page moves onto it, and so are its form's entries and the lines you left out, and Blank says so. Closing the tab asks whether to **Sign…** it first; **Discard** drops what you did. + +### Sign it {#sign-received} + +**Sign…**, or `Mod` `S` in a PDF tab, opens the Sign dialog: + +- **Sign as:** your certificate, which [signs it digitally](./signing#digital) with your passphrase, or **No digital signature (only the visible signature)**, which only shows your signature on the page. Without a signature on a page, a digital signature is invisible (PDF apps list it in their signature panel), and **No digital signature** needs one. A PDF others signed takes only a digital signature: **No digital signature** is greyed out there, since your signature would change pages they signed. +- **Initials: On every page** puts your initials on every page, at the bottom right or left, or takes the repeated initials off again. +- **Place** and **Reason** go into the digital signature. +- It says how many places are still to fill in, and **Open the signed PDF** opens the result in a tab of its own, whose status bar then says _Signed_. + +Then choose where to save it: Blank suggests the PDF's name with _signed_ after it, next to it, e.g. _lease signed.pdf_, so the PDF you received stays as it is. The signed PDF holds what you typed into its form, your stamps, and your digital signature over all of it, added after what the PDF had, so earlier signatures stay intact. While Blank signs, the tab's fields, places and tools wait: they say _Blank is signing this PDF_. Once it's saved, the tab is as you received it again, with nothing left unsaved. If anything goes wrong, Blank says why and writes nothing; a wrong passphrase you just type again. + +Some PDFs don't allow everything, and the tools say why when you point at them: + +- **Signed or certified already:** you can still fill in its fields and sign it digitally, but only one signature of yours can go on its pages, as the appearance of a signature field. Initials, a date or a place would change pages the earlier signers signed, so their tools are off, and so is **Signature** once one is placed. Fields an earlier signer locked are greyed out. +- **Allows no changes** (certified so by its author), **restricted** by its author, or **damaged:** nothing can be filled in, added or signed, and the toolbar says why. +- When Blank's page layout didn't start, the stamps are off, and the toolbar says so. ## Print a PDF {#print} @@ -32,7 +96,7 @@ Instead of the pages, the tab then says why, with what you can do about it: - **Moved or deleted:** the file isn't where it was, also when you start Blank again. **Find it…** picks where it went. - **Changed:** another app changed the file after Blank opened it. **Open again** shows it as it is now. - **Damaged, too large or complex:** Blank can't read it, or it would take too much memory or time. Open it in another app. -- **Needs a password:** Blank can't open PDFs with a password yet. +- **Needs a password:** Blank can't open PDFs that need a password. Blank draws a PDF's pages with a separate program, `blank-pdf`, one for each PDF tab. A PDF that takes it down only takes down its tab: Blank starts it again once, and a page it couldn't draw says _Blank couldn't draw this page_. If it stops while it opens the PDF, or a second time, the tab says so, with **Open again** to try once more and **Close tab**. Everything else in Blank goes on as before. @@ -40,15 +104,15 @@ Blank draws a PDF's pages with a separate program, `blank-pdf`, one for each PDF The status bar of a PDF tab says what the PDF's digital signatures say: -| Status bar | What it means | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Not signed | The PDF has no digital signatures. | -| Signed, Signed by 2 | Nothing changed since it was signed, and each certificate is yours or one you trust. | -| Signed, signer unknown | Nothing changed since it was signed, but Blank doesn't know a certificate: see below how to trust it. | -| Changed and signed again | A signature is valid for the version it signed, but the PDF was changed after it and signed again later; its list says by whom. Check who made the changes. | -| Weak signature | A signature relies on SHA-1, an old algorithm whose signatures can be forged. Don't rely on it. | -| Changed after signing | Someone changed the PDF after it was signed. | -| Signature invalid | A signature doesn't match the PDF. | +| Status bar | What it means | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Not signed | The PDF has no digital signatures. | +| Signed, Signed by 2 | Nothing changed since it was signed, and each certificate is yours or one you trust. Signatures others added later, and nothing else, don't count as a change. | +| Signed, signer unknown | Nothing changed since it was signed, but Blank doesn't know a certificate: see below how to trust it. | +| Changed and signed again | A signature is valid for the version it signed, but the PDF was changed after it, by more than adding signatures, and signed again later; its list says by whom. Check who made the changes. | +| Weak signature | A signature relies on SHA-1, an old algorithm whose signatures can be forged. Don't rely on it. | +| Changed after signing | Someone changed the PDF after it was signed. | +| Signature invalid | A signature doesn't match the PDF. | | Signatures not checked | Blank couldn't read the PDF's signatures. Open the PDF in your PDF app to check them. If the file changed or was moved after Blank opened it, the status says so, and the list offers **Open again** for a changed file, to check it as it is now. | When the check finds something wrong or doubtful, a screen reader reads it out. diff --git a/docs/guide/settings.md b/docs/guide/settings.md index bdfe2c72..d322f5c0 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -63,4 +63,4 @@ Blank's version, its license, links to the website and the source code, and the ## Where the settings are kept -The theme, the language, whether spell check is on and where you signed last are kept with your documents. Your signature and initials are in the `signing` folder next to blank.json. Everything else is written to [blank.json](./configuration), which only holds what you changed. You can still edit that file by hand; then restart Blank to use what you wrote. +The theme, the language, whether spell check is on, where you signed last and whether you put your initials on every page are kept with your documents, and so is what you filled in and placed on a PDF you haven't signed yet. Your signature and initials are in the `signing` folder next to blank.json. Everything else is written to [blank.json](./configuration), which only holds what you changed. You can still edit that file by hand; then restart Blank to use what you wrote. diff --git a/docs/guide/shortcuts.md b/docs/guide/shortcuts.md index 6bf0880b..a53092ad 100644 --- a/docs/guide/shortcuts.md +++ b/docs/guide/shortcuts.md @@ -19,33 +19,35 @@ On keyboard layouts that type characters with `AltGr` (Polish, German and many m ## Files -| Command | Shortcut | -| --------------------------------------------------- | ----------------------- | -| New document | `Mod` `N` | -| Open file | `Mod` `O` | -| Save | `Mod` `S` | -| Save as | `Mod` `Shift` `S` | -| [Print](./print) | `Mod` `P` | -| Export as PDF | `Mod` `Alt` `P` | -| Export as Word | `Mod` `Alt` `W` | -| [Export signed PDF](./signing#export) | `Mod` `Alt` `Shift` `P` | -| [Your signature](./signing#your-signature) | (none) | -| [Your initials](./signing#your-signature) | (none) | -| Page setup | `Mod` `Alt` `U` | -| Edit header | `Mod` `Alt` `H` | -| Edit footer | `Mod` `Alt` `F` | -| [Pages or page ends](./pages#on-the-screen) | `Mod` `Alt` `V` | -| [Zoom in](./pages#zoom) | `Mod` `=` or `Mod` `+` | -| [Zoom out](./pages#zoom) | `Mod` `-` | -| [Fit to window](./pages#zoom) | (none) | -| [Outline](./pages#outline) | `Mod` `Alt` `O` | -| [Word count](./writing#the-status-bar) | `Mod` `Alt` `C` | -| [Signatures of the PDF](./pdf#check-the-signatures) | (none) | -| Cycle themes | `Mod` `Alt` `T` | -| Choose language | `Mod` `Alt` `L` | -| [Focus mode](./writing#focus-mode) | `Mod` `Shift` `F` | -| [Settings](./settings) | `Mod` `,` | -| [Main menu](./menu) | `Mod` `K` | +| Command | Shortcut | +| --------------------------------------------------- | ------------------------------ | +| New document | `Mod` `N` | +| Open file | `Mod` `O` | +| Save | `Mod` `S` | +| Save as | `Mod` `Shift` `S` | +| [Print](./print) | `Mod` `P` | +| Export as PDF | `Mod` `Alt` `P` | +| Export as Word | `Mod` `Alt` `W` | +| [Export signed PDF](./signing#export) | `Mod` `Alt` `Shift` `P` | +| [Your signature](./signing#your-signature) | (none) | +| [Your initials](./signing#your-signature) | (none) | +| Page setup | `Mod` `Alt` `U` | +| Edit header | `Mod` `Alt` `H` | +| Edit footer | `Mod` `Alt` `F` | +| [Pages or page ends](./pages#on-the-screen) | `Mod` `Alt` `V` | +| [Zoom in](./pages#zoom) | `Mod` `=` or `Mod` `+` | +| [Zoom out](./pages#zoom) | `Mod` `-` | +| [Fit to window](./pages#zoom) | (none) | +| [Outline](./pages#outline) | `Mod` `Alt` `O` | +| [Word count](./writing#the-status-bar) | `Mod` `Alt` `C` | +| [Signatures of the PDF](./pdf#check-the-signatures) | (none) | +| [Sign… a PDF you received](./pdf#sign-received) | (none), `Mod` `S` in a PDF tab | +| [Next place to sign](./pdf#places) | (none) | +| Cycle themes | `Mod` `Alt` `T` | +| Choose language | `Mod` `Alt` `L` | +| [Focus mode](./writing#focus-mode) | `Mod` `Shift` `F` | +| [Settings](./settings) | `Mod` `,` | +| [Main menu](./menu) | `Mod` `K` | ## Tabs and moving around @@ -125,6 +127,26 @@ In your text, `Tab` never takes you out of it: `F6` does that. See [Lists](./wri In table mode, the arrow keys insert rows and columns, `Shift` + arrows move them, and letters align (`L` `C` `R`), sort (`S`), merge (`M`) and switch headers (`H`). See [Change a table](./tables#change) for all keys. +## PDF tabs + +| Command | Shortcut | +| ------------------------------------------------- | ----------------------------------- | +| Go to the signing tools | `Alt` `F10` | +| [Next place to sign](./pdf#places) | (none), **Next** on the toolbar | +| Fill in or sign the place | `Enter` or `Space` | +| Leave out a place Blank found | `Delete` on it | +| Place the tool's stamp on the page you're reading | `Enter` or `Space` on the tool | +| Stop placing with the mouse | `Esc` | +| Next / previous place or stamp | `Tab` / `Shift` `Tab` | +| Move the selected stamp | arrow keys, with `Shift` 10 pt | +| Make it larger / smaller | `Alt` `→` or `↑` / `Alt` `←` or `↓` | +| Remove it | `Delete` or `Backspace` | +| Leave it | `Esc` | +| Undo / redo what you placed | `Mod` `Z` / `Mod` `Shift` `Z` | +| [Sign… a PDF you received](./pdf#sign-received) | (none), `Mod` `S` in a PDF tab | + +See [Fill in and sign a PDF you received](./pdf#fill-in). + ## Text | Command | Shortcut | diff --git a/docs/guide/signing.md b/docs/guide/signing.md index a551544f..b2a0ea21 100644 --- a/docs/guide/signing.md +++ b/docs/guide/signing.md @@ -33,30 +33,40 @@ A contract for two people has two blocks, one for each. On the pages and in a pr **Export signed PDF…** (`Mod` `Alt` `Shift` `P`) signs your document: 1. If you haven't made your signature yet, Blank asks for it first. -2. Choose which block is yours, if the document has several (the one with your name is chosen for you), and where you sign. Blank remembers the place for the next time. A block that gives its own place is signed there, and the dialog says so; a block with someone else's name gets a warning. The dialog also shows the date that goes under your signature: it's written in the document's language, which you choose in the status bar. +2. In the sign dialog: + - **Sign as:** your certificate, for a [digital signature](#digital), or **No digital signature (only the visible signature)**. + - **Sign in:** which block is yours, if the document has several (the one with your name is chosen for you). A block with someone else's name gets a warning. + - **Initials: On every page** puts your initials in a bottom corner of every page, **Bottom right** or **Bottom left**, as many contracts ask. Make your initials first ([above](#your-signature)), or with **Add your initials…** right there. + - **Place:** where you sign. A block that gives its own place is signed there, and the dialog says so. + - **Reason**, if you like, e.g. _I agree to these terms_: PDF apps show it with a digital signature. + + Blank remembers the place and the initials for the next time. The dialog also shows the date that goes under your signature: it's written in the document's language, which you choose in the status bar. + 3. Choose where to save the PDF. Blank suggests the document's name with _signed_ after it (_Untitled signed.pdf_ for a document you haven't saved yet), so the unsigned file stays as it is. The block shows your signature, your name and the place and today's date, written as **Settings ▸ Signing ▸ Date** says: the long form in the document's language (e.g. _8 October 2026_ in British English), its short form, or _2026-10-08_. The markdown file itself never holds your signature. ### Two or more signers {#others} -You sign only your own block; the other blocks keep their empty lines. Send the signed PDF to the others, and they sign it one of two ways: +You sign only your own block. Every other block keeps its empty line and gets an empty signature field over it, named after who signs there (e.g. _Signature of Ben Sample_, or _Signature of Landlord_ for a block with a role only). Send the signed PDF to the others, and they sign it: +- **In any PDF app, or in Blank:** they click their field and sign with a signature of their own. Opened in Blank, the PDF shows their field as the place to sign, and [Sign…](./pdf#sign-received) signs it. If you signed digitally, PDF apps then show that something was added after you signed, and your signature stays intact; an app that rewrites the whole file breaks it. - **On paper:** they print it, sign on their line by hand, and scan it or send it back. -- **In their PDF app**, with a signature of their own. If you signed digitally, PDF apps then show that something was added after you signed; an app that rewrites the whole file breaks your digital signature. -Blank can't sign PDFs you received yet, so the others can't sign your PDF in Blank. +A Word document you opened in Blank is signed the same way. Blank lays the pages out a little differently from Word, so check every page before you send it, or ask the sender for a PDF: **Sign…** waits until you switch on **I checked every page**. + +## Sign a PDF you received {#received} -A Word document you opened in Blank is signed the same way. Blank lays the pages out a little differently from Word, so check every page before you send it, or ask the sender for a PDF. +A PDF someone sent you, a contract or a form, you fill in and sign in its own tab: Blank marks where it wants your signature, a date or your details, **Next** takes you from one to the next, and **Sign…** saves it signed as a new file. See [Fill in and sign a PDF you received](./pdf#fill-in). ## Digital signatures {#digital} A picture of a signature is easy to copy. A digital signature isn't: your certificate signs the PDF, so PDF apps show whether anything changed after you signed. That it was you, they show once the reader trusts your certificate, e.g. after comparing its fingerprint with you (see [below](#check)). -1. **Create your certificate** in **Settings ▸ Signing ▸ Certificate**, with your name and a passphrase that protects it (at least 8 characters), or from the sign dialog with **Create certificate…**. Creating the key takes a few seconds. Or **Import key file…** to use a key file (`.p12` or `.pfx`) you already have, e.g. from your employer or a certificate authority. -2. When you sign, choose **Digital, with your certificate** and type your passphrase. Blank checks it, then signs the PDF after exporting it; the signature block shows your signature as before. The PDF names you as on your certificate. +1. **Create your certificate** in **Settings ▸ Signing ▸ Certificate**, with your name and a passphrase that protects it (at least 8 characters), or from the sign dialog with **Sign as ▸ Create your certificate…**. Creating the key takes a few seconds. Or **Import key file…** to use a key file (`.p12` or `.pfx`) you already have, e.g. from your employer or a certificate authority. +2. When you sign, choose your certificate under **Sign as** (_‹your name› · your certificate_, chosen for you once you have one) and type your passphrase. Blank checks it, then signs the PDF after exporting it; the signature block shows your signature as before. The PDF names you as on your certificate. -If you forgot your passphrase, sign with **Picture only**, or **Remove** your certificate and create a new one. If anything goes wrong while signing, nothing is saved. +If you forgot your passphrase, sign with **No digital signature**, or **Remove** your certificate and create a new one. If anything goes wrong while signing, nothing is saved. Your key never leaves your computer and is kept in the `signing` folder, protected by your passphrase. Blank can't recover a forgotten passphrase: use **Back up key…** to keep a copy somewhere safe. **Save certificate…** saves your certificate alone (a `.cer` file), which you can send to people who should trust your signatures. **Remove** asks first, since Blank can't bring a removed key back: signatures you made with it stay valid, but you can't sign with it again. Back up your key before, if you may need it. diff --git a/e2e/specs/pdf.e2e.ts b/e2e/specs/pdf.e2e.ts index 0bf26194..26306af6 100644 --- a/e2e/specs/pdf.e2e.ts +++ b/e2e/specs/pdf.e2e.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -5,7 +6,13 @@ import { fileURLToPath } from "node:url"; import { browser, $, expect } from "@wdio/globals"; -import { expectActiveTab, Key, pressMod, restartApp } from "../helpers.ts"; +import { + expectActiveTab, + Key, + pressMod, + restartApp, + type, +} from "../helpers.ts"; // a page turned by /Rotate 90 with a black box on it, and an A5 page, see // scripts/build-pdf-fixtures.ts @@ -13,6 +20,12 @@ const FIXTURE = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../src-tauri/pdf/fixtures/rotated.pdf", ); +// a form of every kind of field, with a signature field and "Ort, Datum: +// ____" on the page, see scripts/build-pdf-fixtures.ts +const FORM = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../src-tauri/pdf/fixtures/form.pdf", +); /** * the dark pixels of the first page's canvas, which the PDF helper drew @@ -95,7 +108,7 @@ describe("PDF tabs", () => { await expect(save).toHaveAttribute("aria-disabled", "true"); await expect(save).toHaveAttribute( "data-tip", - "Blank doesn't change PDFs, so there's nothing to save", + "Sign the PDF to keep what you placed on it", ); await expect($('#main-menu [data-id="file.print"]')).not.toHaveAttribute( "aria-disabled", @@ -105,6 +118,48 @@ describe("PDF tabs", () => { await expect($("#main-menu")).not.toExist(); }); + it("places a stamp from the signing toolbar, moves it by keys and undoes it", async () => { + await $("#pdf-view").click(); + // Alt-F10 goes to the toolbar's first tool; the date needs no signature + await browser.keys([Key.Alt, Key.F10]); + await expect($('#signing-toolbar [data-tool="signature"]')).toBeFocused(); + await browser.keys(Key.ArrowRight); + await browser.keys(Key.ArrowRight); + await expect($('#signing-toolbar [data-tool="date"]')).toBeFocused(); + await browser.keys(Key.Enter); + const stamp = $('#pdf-view .pdf-stamp[role="group"]'); + await expect(stamp).toBeFocused(); + await expect(stamp).toHaveAttribute( + "aria-label", + "Date on page 1, selected", + ); + await expect($("#ui-announcement")).toHaveText( + "Date placed on page 1, use the arrow keys to move it", + ); + const left = () => + browser.execute(() => + parseFloat( + document.querySelector( + '#pdf-view .pdf-stamp[role="group"]', + )?.style.left ?? "NaN", + ), + ); + const placed = await left(); + // 10 pt to the right, then back with undo + await browser.keys([Key.Shift, Key.ArrowRight]); + await browser.waitUntil(async () => (await left()) > placed, { + timeoutMsg: "the stamp didn't move", + }); + await pressMod("z"); + await browser.waitUntil(async () => (await left()) === placed, { + timeoutMsg: "undo didn't move the stamp back", + }); + // and once more takes it off the page, so the tab has nothing unsaved + await pressMod("z"); + await expect(stamp).not.toExist(); + await expect($("#pdf-view")).toBeFocused(); + }); + it("prints the PDF file itself", async () => { await browser.execute(() => { window.blankPrintCapture = { sent: [] }; @@ -126,3 +181,90 @@ describe("PDF tabs", () => { await waitForPages(); }); }); + +describe("signing a PDF received", () => { + let dir: string; + let formPath: string; + let signedPath: string; + + before(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "blank-e2e-form-")); + formPath = path.join(dir, "form.pdf"); + signedPath = path.join(dir, "form signed.pdf"); + fs.copyFileSync(FORM, formPath); + await restartApp([formPath]); + }); + + after(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("goes to its first place with Next and fills the field in", async () => { + await expectActiveTab("form", formPath); + // its places are read once its pages show + await expect($("#pdf-view .page-canvas")).toBeExisting(); + const next = $("#signing-toolbar .signing-next"); + await expect(next).toHaveText(/^Next · 1 of \d+$/); + const open = Number(/of (\d+)$/.exec(await next.getText())![1]); + await next.click(); + const name = $('[id="place-field%3Aname"]'); + await expect(name).toBeFocused(); + await expect($("#ui-announcement")).toHaveText( + "Your name on page 1. Type to fill it in.", + ); + await type("ada"); + await browser.keys(Key.Enter); + // filled in: Next counts one place less, and the tab has changes + await expect(next).toHaveText(new RegExp(`^Next · \\d+ of ${open - 1}$`)); + await expect( + $('#tab-row [role="tab"][aria-selected="true"]'), + ).toHaveElementClass("unsaved"); + }); + + it("signs the signature field with a signature typed for it", async () => { + await $('[id="place-field%3Asignature"]').click(); + // no signature yet: it's made first + await expect($("#signature-dialog")).toExist(); + // Type, clicked as the user does + const tabs = await $$('#signature-dialog [role="tab"]'); + let typed = false; + for (const tab of tabs) + if (!typed && (await tab.getText()).trim() === "Type") { + await tab.click(); + typed = true; + } + expect(typed).toBe(true); + await expect( + $('#signature-dialog [role="tab"][aria-selected="true"]'), + ).toHaveText("Type"); + await $("#signature-dialog-name").click(); + await type("ada"); + await $('#signature-dialog button[type="submit"]').click(); + await expect($("#signature-dialog")).not.toExist(); + const stamp = $('#pdf-view .pdf-stamp[role="group"]'); + await expect(stamp).toHaveAttribute( + "aria-label", + "Signature on page 1, selected", + ); + }); + + it("saves it signed with no digital signature, the field filled in", async () => { + await browser.execute((to) => { + window.blankTestSavePath = to; + }, signedPath); + await $("#signing-toolbar .signing-sign").click(); + await expect($("#sign-dialog")).toExist(); + await expect($("#sign-dialog-how")).toHaveText(/No digital signature/); + await $('#sign-dialog button[type="submit"]').click(); + // the signed PDF opens in a tab of its own once it's written whole + await expectActiveTab("form signed", signedPath); + const written = fs.readFileSync(signedPath); + expect(written.subarray(0, 5).toString()).toBe("%PDF-"); + expect(written.subarray(-6).toString()).toContain("%%EOF"); + const text = spawnSync("pdftotext", [signedPath, "-"], { + encoding: "utf8", + }); + if (text.error && !process.env.CI) return; + expect(text.stdout).toContain("ada"); + }); +}); diff --git a/e2e/testHooks.d.ts b/e2e/testHooks.d.ts index 54eb42ca..69096c62 100644 --- a/e2e/testHooks.d.ts +++ b/e2e/testHooks.d.ts @@ -2,7 +2,8 @@ // `blankGeometry` (exposeGeometry in src/engine/geometry.ts), // `blankPageViewPerf` (src/engine/perf.ts), `blankBootTimes`, // `blankBreakEngine` and `blankSetTheme` (src/state/appearance.ts), and -// `blankPrintCapture` (src/print/job.ts), which a test sets itself. Written +// `blankPrintCapture` (src/print/job.ts) and `blankTestSavePath` +// (src/pdf/sign.ts), which a test sets itself. Written // out here, since E2E's type check can't compile the app's sources; keep them // in step. @@ -55,4 +56,7 @@ interface Window { // set by a test: what Print hands to the system's print dialog lands // here instead, since that dialog can't be automated blankPrintCapture?: { sent: { sheets: number; pdf: string }[] }; + // set by a test: where signing a PDF tab saves the signed PDF, since the + // save dialog can't be automated + blankTestSavePath?: string; } diff --git a/scripts/build-label-fixtures.ts b/scripts/build-label-fixtures.ts new file mode 100644 index 00000000..5981939a --- /dev/null +++ b/scripts/build-label-fixtures.ts @@ -0,0 +1,435 @@ +// Writes the pages the found places' test reads (src/signing/__fixtures__/ +// labels/), each with the places it has: +// +// - form-pdf.json: src-tauri/pdf/fixtures/form.pdf's words where +// `pdftotext -bbox` finds them, and its rule, which pdftotext doesn't give; +// - the others: hand-made forms and traps, their words laid out with +// Helvetica's widths, roughly. +// +// Every word's box is as the helper's `text` op gives it: from 0.8 em above +// the baseline to 0.25 em below (pdftotext's, 0.718 and 0.207 em of +// Helvetica, are made into those). +// +// The places expected are where src/signing/labels.ts puts them (see +// .claude/rules/pdf.md, "Places and fills"): on a line a label stands left +// of, 1.6 label heights tall; 36 pt (a signature or initials) or 18 pt above +// a line a caption stands under; between a label and the line below it. +// +// Run with `bun scripts/build-label-fixtures.ts` (needs poppler-utils) and +// commit the result. +import { spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +type Rect = [number, number, number, number]; +interface Page { + words: { text: string; rect: Rect }[]; + rules: Rect[]; +} + +const root = join(import.meta.dir, ".."); +const out = join(root, "src", "signing", "__fixtures__", "labels"); +mkdirSync(out, { recursive: true }); + +const round = (n: number) => Math.round(n * 100) / 100; +// how far a word's box reaches above and below its baseline, in em, as the +// helper gives it, and as pdftotext does +const ASCENT = 0.8; +const DESCENT = 0.25; +const PDFTOTEXT_ASCENT = 0.718; +const PDFTOTEXT_DESCENT = 0.207; +// the box of a word `width` wide at `x`, `size` large on `baseline` +const box = ( + x: number, + baseline: number, + width: number, + size: number, +): Rect => [ + round(x), + round(baseline - ASCENT * size), + round(width), + round((ASCENT + DESCENT) * size), +]; +const bottom = (rect: Rect) => rect[1] + rect[3]; +const em = (c: string) => + c === "_" + ? 0.556 + : " .,:".includes(c) + ? 0.278 + : "()".includes(c) + ? 0.333 + : 0.52; + +const write = ( + name: string, + about: string, + text: Page, + expected: { kind: string; rect: Rect }[], +) => + writeFileSync( + join(out, `${name}.json`), + JSON.stringify({ about, text, expected }, null, 2) + "\n", + ); + +// form.pdf, as pdftotext reads it +{ + const { stdout } = spawnSync("pdftotext", [ + "-bbox", + join(root, "src-tauri", "pdf", "fixtures", "form.pdf"), + "-", + ]); + const words = [ + ...stdout + .toString() + .matchAll( + /xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">([^<]*) { + const size = (+y1 - +y0) / (PDFTOTEXT_ASCENT + PDFTOTEXT_DESCENT); + return { + text, + rect: box(+x0, +y0 + PDFTOTEXT_ASCENT * size, +x1 - +x0, size), + }; + }); + const run = words.find((word) => word.text.startsWith("___"))!.rect; + const label = 1.6 * run[3]; + write( + "form-pdf", + "src-tauri/pdf/fixtures/form.pdf: its words where pdftotext -bbox finds them, in boxes as the helper gives them, and its rule (0.5 w 100 490 m 300 490 l S) drawn by hand", + // 842 − 490, half the stroke's width up + { words, rules: [[100, 351.75, 200, 0.5]] }, + [ + { kind: "signature", rect: [100, 315.75, 200, 36] }, + { + kind: "placeAndDate", + rect: [run[0], round(bottom(run) - label), run[2], round(label)], + }, + ], + ); +} + +let page: Page; +// writes `string` from x on `baseline`, returning its words' rects +const text = (x: number, baseline: number, string: string, size = 10) => + string.split(" ").flatMap((word) => { + const width = [...word].reduce((sum, c) => sum + em(c), 0) * size; + const rect = box(x, baseline, width, size); + x += width + 0.278 * size; + if (!word) return []; + page.words.push({ text: word, rect }); + return [rect]; + }); +// draws a rule of half a point at y +const rule = (x: number, y: number, width: number): Rect => { + const rect: Rect = [x, y - 0.25, width, 0.5]; + page.rules.push(rect); + return rect; +}; +// a run of characters is a line at its word's bottom +const run = (word: Rect): Rect => [word[0], bottom(word) - 1, word[2], 1]; +// the part of a word from its character `from` of `chars` +const part = (word: Rect, chars: number, from: number): Rect => [ + word[0] + (word[2] * from) / chars, + word[1], + (word[2] * (chars - from)) / chars, + word[3], +]; +const onLine = (line: Rect, label: Rect): Rect => { + const height = 1.6 * label[3]; + return [ + round(line[0]), + round(bottom(line) - height), + round(line[2]), + round(height), + ]; +}; +const above = (line: Rect, height: number): Rect => [ + round(line[0]), + round(line[1] - height), + round(line[2]), + height, +]; +const between = (label: Rect, line: Rect): Rect => [ + round(line[0]), + round(bottom(label)), + round(line[2]), + round(line[1] - bottom(label)), +]; +// the baseline of 9 pt words just below a rule at y +const under = (y: number) => y + 10.4; +// writes the page `build` makes +const fixture = ( + name: string, + about: string, + build: () => { kind: string; rect: Rect }[], +) => { + page = { words: [], rules: [] }; + const expected = build(); + write(name, about, page, expected); +}; + +fixture( + "en-colon-underscores", + "English labels with a colon, left of underscores, in single-spaced lines; Name gets no place", + () => { + text(72, 100, "Application for a parking permit", 14); + text(72, 600, "Name: ____________________"); + const signature = text(72, 612, "Signature: ____________________"); + const date = text(72, 624, "Date: ____________"); + return [ + { kind: "signature", rect: onLine(run(signature[1]), signature[0]) }, + { kind: "date", rect: onLine(run(date[1]), date[0]) }, + ]; + }, +); + +fixture( + "de-captions-under-rules", + "German captions just below drawn rules: Ort, Datum and Unterschrift", + () => { + text( + 72, + 600, + "Berlin, den 1. Oktober 2026. Beide Seiten erhalten eine Ausfertigung.", + ); + const a = rule(72, 700, 180); + const b = rule(320, 700, 200); + text(72, 710.43, "Ort, Datum"); + text(320, 710.43, "Unterschrift"); + return [ + { kind: "placeAndDate", rect: above(a, 18) }, + { kind: "signature", rect: above(b, 36) }, + ]; + }, +); + +fixture( + "en-parens-under-underscores", + "English captions in brackets below runs of underscores", + () => { + const [a] = text(72, 650, "________________________________"); + const [b] = text(330, 650, "______________________"); + text(120, 662, "(signature)", 8); + text(380, 662, "(date)", 8); + return [ + { kind: "signature", rect: above(run(a), 36) }, + { kind: "date", rect: above(run(b), 18) }, + ]; + }, +); + +fixture( + "en-prose-traps", + "Labels in running text, a heading under a separator rule: no places", + () => { + text(72, 100, "Notes on the agreement", 14); + text( + 72, + 130, + "The parties agree that the start date of this contract is the date it is", + ); + text( + 72, + 142, + "signed by both of them. Signed copies are kept by both parties, and the", + ); + text( + 72, + 154, + "signature of a witness is not needed. The place of signing doesn't matter.", + ); + rule(72, 170, 450); + text(72, 182.25, "Place of performance", 12); + text( + 72, + 200, + "Date and place are given in the header. Sign here only if you agree.", + ); + text( + 72, + 230, + "Initials: see the appendix for the initials of every clerk.", + ); + rule(72, 260, 450); + text(72, 270.4, "Payment is due upon signature of the agreement.", 9); + return []; + }, +); + +fixture( + "de-prose-traps", + "German running text about signatures, under and over drawn rules: no places", + () => { + text(72, 100, "§ 12 Schlussbestimmungen", 14); + rule(72, 120, 450); + text(72, 130.4, "Die Unterschrift des Mieters ist erforderlich.", 9); + text( + 72, + 160, + "Mit der Unterschrift erkennt der Mieter die Hausordnung an. Ort und", + ); + text( + 72, + 172, + "Datum der Übergabe stehen im Protokoll. Unterschriften Dritter sind", + ); + text(72, 184, "nicht nötig; ein Stempel und eine Unterschrift genügen."); + rule(72, 230, 450); + return []; + }, +); + +fixture( + "en-table-trap", + "A table whose header says Date, between drawn borders: no places", + () => { + text(72, 270, "Payments received so far:"); + rule(72, 300, 400); + text(76, 312, "Date"); + text(276, 312, "Amount"); + rule(72, 318, 400); + text(76, 330, "2026-01-05"); + text(276, 330, "100 EUR"); + rule(72, 336, 400); + text(76, 348, "Datum"); + rule(72, 354, 200); + return []; + }, +); + +fixture( + "de-dots-one-line", + "German labels left of dot runs, both on one line", + () => { + const w = text( + 72, + 720, + "Ort, Datum ...................................... Unterschrift ..........................................", + ); + return [ + { kind: "placeAndDate", rect: onLine(run(w[2]), w[1]) }, + { kind: "signature", rect: onLine(run(w[4]), w[3]) }, + ]; + }, +); + +fixture( + "glued-labels", + "Labels written into the same word as their underscores", + () => { + const [s] = text(72, 500, "Signature:______________________________"); + const [d] = text(72, 540, "Datum:____________________"); + return [ + { kind: "signature", rect: onLine(run(part(s, 40, 10)), s) }, + { kind: "date", rect: onLine(run(part(d, 26, 6)), d) }, + ]; + }, +); + +fixture( + "initials", + "Initials at the foot of a page, in English, German and French-style German", + () => { + const a = text(420, 800, "Initials: __________"); + const b = text(72, 800, "Kürzel ________"); + const c = text(230, 800, "Paraphe: _________"); + return [a, b, c].map((w) => ({ + kind: "initials", + rect: onLine(run(w[1]), w[0]), + })); + }, +); + +fixture( + "labels-above-lines", + "Labels above drawn rules, and Sign here left of underscores", + () => { + const [s] = text(72, 500, "Signature"); + const a = rule(72, 540, 200); + const [d] = text(320, 500, "Datum"); + const b = rule(320, 525, 150); + const h = text(72, 600, "Sign here: ____________________"); + return [ + { kind: "signature", rect: between(s, a) }, + { kind: "date", rect: between(d, b) }, + { kind: "signature", rect: onLine(run(h[2]), h[1]) }, + ]; + }, +); + +fixture("whose-signature", "Signature labels that say whose they are", () => { + const a = rule(72, 650, 200); + const b = rule(320, 650, 200); + text(72, under(650), "Unterschrift des Vermieters", 9); + text(320, under(650), "Signature of the tenant", 9); + const c = text(72, 720, "Signed by: ______________________"); + return [ + { kind: "signature", rect: above(a, 36) }, + { kind: "signature", rect: above(b, 36) }, + { kind: "signature", rect: onLine(run(c[2]), c[0]) }, + ]; +}); + +fixture( + "de-roles", + "German signature captions with the signer's role before or after them", + () => { + const rules = [72, 260, 440].map((x) => rule(x, 650, 160)); + text(72, under(650), "Unterschrift Mieter", 9); + text(260, under(650), "Unterschrift Vermieter/in", 9); + text(440, under(650), "Mieter/in (Unterschrift)", 9); + const d = rule(72, 720, 220); + text(72, under(720), "Erziehungsberechtigte(r) (Unterschrift)", 9); + const e = text(72, 770, "Unterschrift Arbeitgeber: ____________________"); + const f = text(320, 770, "Arbeitnehmer (Unterschrift) _______________"); + const g = rule(72, 810, 200); + text(72, under(810), "Unterschriften Auftraggeber, Auftragnehmer", 9); + return [ + ...rules.map((line) => ({ kind: "signature", rect: above(line, 36) })), + { kind: "signature", rect: above(d, 36) }, + { kind: "signature", rect: onLine(run(e[2]), e[1]) }, + { kind: "signature", rect: onLine(run(f[2]), f[1]) }, + { kind: "signature", rect: above(g, 36) }, + ]; + }, +); + +fixture( + "en-roles", + "English signature captions that name the signer's role", + () => { + const a = rule(72, 650, 200); + const b = rule(320, 650, 200); + text(72, under(650), "Signature of Tenant", 9); + text(320, under(650), "Tenant's signature", 9); + const c = text(72, 720, "Landlord signature: ______________________"); + return [ + { kind: "signature", rect: above(a, 36) }, + { kind: "signature", rect: above(b, 36) }, + { kind: "signature", rect: onLine(run(c[2]), c[1]) }, + ]; + }, +); + +fixture( + "stamps-and-dates", + "Signature captions that ask for a stamp too, and the date of signing", + () => { + const stamps = [72, 210, 340, 470].map((x) => rule(x, 600, 110)); + text(72, under(600), "Signatur/Stempel", 9); + text(210, under(600), "Unterschrift/Stempel", 9); + text(340, under(600), "Unterschrift und Stempel", 9); + text(470, under(600), "Stempel und Unterschrift", 9); + const dates = [72, 260].map((x) => rule(x, 680, 160)); + text(72, under(680), "Datum der Unterschrift", 9); + text(260, under(680), "Date of signature", 9); + const c = text(72, 740, "Date signed: ______________"); + const d = text(320, 740, "Ort und Datum: ____________________"); + return [ + ...stamps.map((line) => ({ kind: "signature", rect: above(line, 36) })), + ...dates.map((line) => ({ kind: "date", rect: above(line, 18) })), + { kind: "date", rect: onLine(run(c[2]), c[1]) }, + { kind: "placeAndDate", rect: onLine(run(d[3]), d[2]) }, + ]; + }, +); diff --git a/scripts/build-pdf-fixtures.ts b/scripts/build-pdf-fixtures.ts index c57117da..225c7ba5 100644 --- a/scripts/build-pdf-fixtures.ts +++ b/scripts/build-pdf-fixtures.ts @@ -1,13 +1,16 @@ // Writes the small PDFs the PDF helper's tests open (src-tauri/pdf/fixtures/), // by hand, object by object, so each holds exactly what its test needs: // -// - form.pdf: a form of every kind of field, a nested one, a read-only one -// and an empty signature field, over a label and a line; +// - form.pdf: a form of every kind of field (text, checkbox, combo box, +// radio buttons, list box), a nested one, a read-only one and an empty +// signature field, over a label and a line; // - rotated.pdf: a page turned by /Rotate 90 with an offset crop box and a // box drawn under a field, an A5 page, resources inherited from the pages; // - signature.pdf: a signature field that holds a signature's dictionary // (not a valid signature: the helper only reads where it is and what it says); // - cjk.pdf: Chinese in a font the PDF names but doesn't embed; +// - choices.pdf: choices whose options are [export display] pairs, some +// with characters the field's font lacks; // - repaired.pdf: form.pdf with its cross-reference offsets wrong; // - password.pdf: form.pdf encrypted with the password "secret" (needs qpdf). // @@ -43,11 +46,12 @@ const helvetica = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; // form.pdf: 1 catalog, 2 pages, 3 page, 4 font, 5 content, 6 name, 7 agree, // 8 its on appearance, 9 its off appearance, 10 color, 11 signature, -// 12 id, 13 address, 14 street +// 12 id, 13 address, 14 street, 15 size (radio buttons), 16 and 17 its +// buttons S and L, 18 fruit (a list box) const form = pdf([ - "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R 10 0 R 11 0 R 12 0 R 13 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 4 0 R >> >> >> >>", + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R 10 0 R 11 0 R 12 0 R 13 0 R 15 0 R 18 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 4 0 R >> >> >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /Annots [6 0 R 7 0 R 10 0 R 11 0 R 12 0 R 14 0 R] >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /Annots [6 0 R 7 0 R 10 0 R 11 0 R 12 0 R 14 0 R 16 0 R 17 0 R 18 0 R] >>", helvetica, stream( "BT /F1 12 Tf 100 760 Td (Contract) Tj ET\n" + @@ -64,6 +68,10 @@ const form = pdf([ "<< /FT /Tx /Ff 1 /T (id) /V (A-17) /Type /Annot /Subtype /Widget /Rect [350 700 500 720] /P 3 0 R /F 4 /DA (/Helv 10 Tf 0 g) >>", "<< /FT /Tx /T (address) /Kids [14 0 R] >>", "<< /T (street) /Parent 13 0 R /Type /Annot /Subtype /Widget /Rect [100 550 300 570] /P 3 0 R /F 4 /DA (/Helv 10 Tf 0 g) >>", + "<< /FT /Btn /Ff 49152 /T (size) /V /Off /Kids [16 0 R 17 0 R] >>", + "<< /Parent 15 0 R /Type /Annot /Subtype /Widget /Rect [100 420 115 435] /P 3 0 R /F 4 /AS /Off /AP << /N << /S 8 0 R /Off 9 0 R >> >> >>", + "<< /Parent 15 0 R /Type /Annot /Subtype /Widget /Rect [130 420 145 435] /P 3 0 R /F 4 /AS /Off /AP << /N << /L 8 0 R /Off 9 0 R >> >> >>", + "<< /FT /Ch /T (fruit) /Opt [(Apple) (Pear)] /Type /Annot /Subtype /Widget /Rect [350 600 500 640] /P 3 0 R /F 4 /DA (/Helv 10 Tf 0 g) >>", ]); writeFileSync(join(out, "form.pdf"), form); @@ -111,6 +119,22 @@ writeFileSync( ]), ); +// choices.pdf: 1 catalog, 2 pages, 3 page, 4 font, 5 country (a combo box +// of [export display] pairs: Łódź exports "pl", Omega exports Ω), 6 pets +// (a list box of pairs), 7 letters (a list box with β among its options) +writeFileSync( + join(out, "choices.pdf"), + pdf([ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 4 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Annots [5 0 R 6 0 R 7 0 R] >>", + helvetica, + "<< /FT /Ch /Ff 131072 /T (country) /Opt [[(de) (Germany)] [(pl) ] [ (Omega)]] /V (de) /Type /Annot /Subtype /Widget /Rect [100 700 250 720] /P 3 0 R /F 4 /DA (/Helv 10 Tf 0 g) >>", + "<< /FT /Ch /T (pets) /Opt [[(c) (Cat)] [(d) (Dog)]] /V (d) /Type /Annot /Subtype /Widget /Rect [100 600 250 640] /P 3 0 R /F 4 /DA (/Helv 10 Tf 0 g) >>", + "<< /FT /Ch /T (letters) /Opt [(Alpha) ] /Type /Annot /Subtype /Widget /Rect [300 600 450 640] /P 3 0 R /F 4 /DA (/Helv 10 Tf 0 g) >>", + ]), +); + // repaired.pdf: the offsets of form.pdf's cross-reference table off by one const text = form.toString("latin1"); writeFileSync( diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5cfe0139..00b2d648 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -592,6 +592,7 @@ dependencies = [ "mupdf-sys", "serde", "serde_json", + "tempfile", ] [[package]] diff --git a/src-tauri/layout/src/engine/display.rs b/src-tauri/layout/src/engine/display.rs index 83c57664..587dabd2 100644 --- a/src-tauri/layout/src/engine/display.rs +++ b/src-tauri/layout/src/engine/display.rs @@ -174,6 +174,7 @@ impl Engine { let unit = &laid.units[frag.unit]; let dx = left; let dy = frag.y - unit.top; + let first = ops.len(); for deco in &unit.decos { let part = match deco { Deco::Image { x, y, .. } => match item.content { @@ -346,6 +347,13 @@ impl Engine { } } } + // a stamp isn't text of the document, so all of it is drawn as + // an artifact + if matches!(item.content, Content::Stamp { .. }) { + for (_, part) in &mut ops[first..] { + *part = Part::Decoration; + } + } } ops } diff --git a/src-tauri/layout/src/engine/select.rs b/src-tauri/layout/src/engine/select.rs index 9dfe1288..7be2416a 100644 --- a/src-tauri/layout/src/engine/select.rs +++ b/src-tauri/layout/src/engine/select.rs @@ -106,6 +106,10 @@ impl Engine { let laid = &self.laid[frag.item]; let unit = &laid.units[frag.unit]; let left = self.settings.margins.left; + // a stamp is as wide as what it shows + if let Some((x, w)) = laid.extent { + return (left + x, frag.y, w, unit.height); + } // a signature block is as wide as its line, which its picture and // the lines under it stand within if let Content::Signature { .. } = item.content { diff --git a/src-tauri/layout/src/items/mod.rs b/src-tauri/layout/src/items/mod.rs index cd4e5065..2850a76a 100644 --- a/src-tauri/layout/src/items/mod.rs +++ b/src-tauri/layout/src/items/mod.rs @@ -2,6 +2,7 @@ //! pagination places, which are lines for text and rows for tables. mod signature; +mod stamp; mod table; mod toc; @@ -15,6 +16,7 @@ use crate::style::{MARKER_GAP, RULE}; use crate::text::{align_offset, alignment_of, TextBox}; pub use signature::{SignatureSpec, SIGNATURE_ROOM, SIGNATURE_SHARE}; +pub use stamp::StampSpec; use table::table_units; pub use table::{TableSpec, MAX_COLUMNS}; pub use toc::{leaders, number_text, TocLaid, TocLine}; @@ -158,6 +160,9 @@ pub struct Laid { pub missing: Vec, /// for a table of contents: where its page numbers go pub toc: Option, + /// for a stamp: where what it shows starts, from its left, and how + /// wide it is, which its box is (see `Engine::boxes`) + pub extent: Option<(f32, f32)>, } /// a cell of a table as laid out: where it is in the grid, and what of the @@ -287,6 +292,44 @@ impl Laid { indent, inner, ), + Content::Stamp { + src, + width: image_width, + height, + alt, + lines, + size, + align, + .. + } => { + // the whole page, which is the stamp's box + let mut laid = stamp::stamp_units( + fonts, + StampSpec { + src, + width: *image_width, + height: *height, + alt, + lines, + size: *size, + center: align.as_deref() == Some("center"), + }, + // as small as a stamp's page may be, not the room for a + // line of text + (right - indent).max(0.0), + room, + ); + if let Some((left, _)) = &mut laid.extent { + *left += indent; + } + for deco in &mut laid.units[0].decos { + *deco = deco.moved(indent, 0.0); + } + for (boxed, _) in &mut laid.extras { + boxed.x = indent; + } + laid + } Content::Image { src, width: image_width, diff --git a/src-tauri/layout/src/items/stamp.rs b/src-tauri/layout/src/items/stamp.rs new file mode 100644 index 00000000..daba8a87 --- /dev/null +++ b/src-tauri/layout/src/items/stamp.rs @@ -0,0 +1,217 @@ +//! A stamp placed on a PDF's page: an image (the user's signature or +//! initials) contained in the stamp's box, and the lines under it, e.g. a +//! date or a place. A stamp engine's page is the stamp's box, so the item +//! fills it: the image as large as the room the lines leave, centred +//! however the lines are aligned (at the left or centred). + +use parley::Alignment; + +use super::{picture_scale, Deco, Laid, Role, Unit}; +use crate::fonts::Fonts; +use crate::model::{Text, TextKind}; +use crate::style::BODY; +use crate::text::TextBox; + +/// what a stamp shows, see `Content::Stamp` +pub struct StampSpec<'a> { + pub src: &'a str, + pub width: f32, + pub height: f32, + pub alt: &'a str, + pub lines: &'a [String], + pub size: f32, + pub center: bool, +} + +/// lays out a stamp in a box of `width` × `height` points; its lines are +/// extras, as they aren't text of the document. `Laid::extent` is where +/// what it shows starts and how wide it is, which is the stamp's own +/// width, e.g. its widest line. +pub fn stamp_units(fonts: &mut Fonts, spec: StampSpec, width: f32, height: f32) -> Laid { + let width = width.max(0.0); + let height = height.max(0.0); + let size = if spec.size > 0.0 { spec.size } else { BODY }; + let alignment = if spec.center { + Alignment::Center + } else { + Alignment::Start + }; + let mut extras = vec![]; + let mut lines_height = 0.0; + for line in spec.lines { + let text = Text { + pos: 0, + text: line.clone(), + style: TextKind::P, + size: Some(size), + ..Default::default() + }; + let boxed = TextBox::new(fonts, &text, width, alignment); + lines_height += boxed.height(); + extras.push((boxed, Role::Text)); + } + let mut decos = vec![]; + let mut image_height = 0.0; + // the edges of what it shows, from the left of the box + let (mut left, mut right) = (f32::INFINITY, f32::NEG_INFINITY); + let room = (height - lines_height).max(0.0); + if !spec.src.is_empty() && spec.width > 0.0 && spec.height > 0.0 && room > 0.0 { + // as large as the room allows, its shape kept, in the middle + let scale = picture_scale((spec.width, spec.height), width, room, Some(1.0)); + let (w, h) = (spec.width * scale, spec.height * scale); + let x = (width - w) / 2.0; + // in the middle of the room the lines leave + let y = (room - h) / 2.0; + decos.push(Deco::Image { + src: spec.src.to_string(), + alt: spec.alt.to_string(), + x, + y, + w, + h, + }); + left = left.min(x); + right = right.max(x + w); + // the image's room is the stamp's, but for the lines + image_height = room; + } + let mut top = image_height; + for (boxed, _) in &mut extras { + boxed.y = top; + top += boxed.height(); + let shown = boxed.layout.width(); + let x = if spec.center { + (width - shown) / 2.0 + } else { + 0.0 + }; + left = left.min(x); + right = right.max(x + shown); + } + let extent = if left <= right { + (left, right - left) + } else { + (0.0, 0.0) + }; + Laid { + units: vec![Unit { + height: top, + decos, + extras: 0..extras.len(), + ..Default::default() + }], + extras, + extent: Some(extent), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fonts::repository_fonts; + + fn laid(src: &str, lines: &[String], center: bool, width: f32, height: f32) -> Laid { + let mut fonts = repository_fonts(); + stamp_units( + &mut fonts, + StampSpec { + src, + width: 300.0, + height: 100.0, + alt: "Signature", + lines, + size: 12.0, + center, + }, + width, + height, + ) + } + + fn image(laid: &Laid) -> (f32, f32, f32, f32) { + laid.units[0] + .decos + .iter() + .find_map(|deco| match deco { + Deco::Image { x, y, w, h, .. } => Some((*x, *y, *w, *h)), + _ => None, + }) + .unwrap() + } + + #[test] + fn contains_the_image_in_the_box_centred() { + // a wide box: as high as it, in the middle + let wide = laid("signature.png", &[], false, 600.0, 100.0); + let (x, y, w, h) = image(&wide); + assert_eq!((y, w, h), (0.0, 300.0, 100.0)); + assert!((x - 150.0).abs() < 0.01); + assert_eq!(wide.extent, Some((150.0, 300.0))); + assert!((wide.units[0].height - 100.0).abs() < 0.01); + // a tall box: as wide as it, its shape kept + let tall = laid("signature.png", &[], false, 60.0, 200.0); + let (x, y, w, h) = image(&tall); + assert_eq!((x, w), (0.0, 60.0)); + assert!((h - 20.0).abs() < 0.01); + assert!((y - 90.0).abs() < 0.01); + assert!((tall.units[0].height - 200.0).abs() < 0.01); + } + + #[test] + fn sets_the_lines_in_the_body_font_at_its_size() { + let lines = ["12 October 2026".to_string(), "Berlin".to_string()]; + let laid = laid("", &lines, false, 600.0, 200.0); + assert!(laid.units[0].decos.is_empty()); + assert_eq!(laid.extras.len(), 2); + let (first, second) = (&laid.extras[0].0, &laid.extras[1].0); + // a line each, one under the other, as high as 12 pt body text + assert_eq!(first.line_count(), 1); + let line = 12.0 * crate::style::NATURAL * 1.12; + assert!((first.height() - line).abs() < 0.01); + assert!((second.y - first.height()).abs() < 0.01); + assert!((laid.units[0].height - 2.0 * line).abs() < 0.01); + // as wide as the wider line, from the left + let (left, width) = laid.extent.unwrap(); + assert_eq!(left, 0.0); + let widest = first.layout.width().max(second.layout.width()); + assert!((width - widest).abs() < 0.01); + assert!(width < 200.0); + } + + #[test] + fn centres_the_lines() { + let lines = ["Berlin".to_string()]; + let laid = laid("", &lines, true, 600.0, 200.0); + let (left, width) = laid.extent.unwrap(); + assert!((left - (600.0 - width) / 2.0).abs() < 0.01); + } + + #[test] + fn leaves_the_lines_their_room_under_the_image() { + let lines = ["Ada Lovelace".to_string()]; + let laid = laid("signature.png", &lines, true, 300.0, 100.0); + let (_, y, _, h) = image(&laid); + let text = &laid.extras[0].0; + assert!(y + h <= text.y + 0.01); + assert!((text.y + text.height() - 100.0).abs() < 0.01); + assert!((laid.units[0].height - 100.0).abs() < 0.01); + } + + #[test] + fn centres_the_image_over_lines_at_the_left() { + let lines = ["Ada Lovelace".to_string()]; + let laid = laid("signature.png", &lines, false, 300.0, 100.0); + let (x, _, w, _) = image(&laid); + assert!((x - (300.0 - w) / 2.0).abs() < 0.01); + assert_eq!(laid.extras[0].0.x, 0.0, "the lines at the left"); + } + + #[test] + fn lays_out_in_no_room_instead_of_panicking() { + for (width, height) in [(f32::NAN, 10.0), (-5.0, -5.0), (0.0, 0.0)] { + let laid = laid("signature.png", &["a".into()], true, width, height); + assert_eq!(laid.units.len(), 1); + } + } +} diff --git a/src-tauri/layout/src/model.rs b/src-tauri/layout/src/model.rs index 5c630d24..2de289d9 100644 --- a/src-tauri/layout/src/model.rs +++ b/src-tauri/layout/src/model.rs @@ -60,6 +60,10 @@ pub struct Text { /// center, right or justify, none for left (see `alignment_of`) #[serde(default)] pub align: Option, + /// the size of its letters in points instead of its style's, with its + /// lines as much taller, e.g. a stamp's; the webview never sends it + #[serde(skip)] + pub size: Option, } /// an entry of a table of contents: a heading's level and text @@ -337,6 +341,33 @@ pub enum Content { #[serde(default)] share: Option, }, + /// a stamp placed on a PDF's page: an image, e.g. the user's signature + /// or initials, contained in the page, and lines of text under it. + /// Only a stamp engine's page (`Settings::stamp`) holds one; it isn't + /// text of the document, so the PDF tags it as an artifact + Stamp { + pos: u32, + /// the image, empty for none + #[serde(default)] + src: String, + /// the image's size in points, for its shape; 0 while it isn't + /// loaded + #[serde(default)] + width: f32, + #[serde(default)] + height: f32, + #[serde(default)] + alt: String, + #[serde(default)] + lines: Vec, + /// the size of the lines' letters, in points; 0 for the body's + #[serde(default)] + size: f32, + /// "center" centres the image and the lines, anything else sets the + /// lines at the left + #[serde(default)] + align: Option, + }, Image { pos: u32, src: String, @@ -613,6 +644,10 @@ pub struct Settings { pub start_number: i64, #[serde(default)] pub fields: Fields, + /// the page is a stamp's box (see `Content::Stamp`): as small as + /// MIN_STAMP, without margins, headers or footers + #[serde(default)] + pub stamp: bool, } fn arabic() -> String { @@ -670,6 +705,8 @@ fn finite(value: f32, fallback: f32, min: f32, max: f32) -> f32 { /// and the largest page a PDF can have (200 inches) pub const MIN_PAGE: f32 = 36.0; pub const MAX_PAGE: f32 = 14_400.0; +/// the smallest page of a stamp, in points: a stamp may be tiny +pub const MIN_STAMP: f32 = 2.0; impl Default for Settings { fn default() -> Self { @@ -692,6 +729,7 @@ impl Default for Settings { number_style: arabic(), start_number: 1, fields: Fields::default(), + stamp: false, } } } @@ -699,11 +737,22 @@ impl Default for Settings { impl Settings { /// the settings as the engine can lay out with them, whatever JS handed /// over: a page of MIN_PAGE to MAX_PAGE points, margins that leave - /// MIN_PAGE of it for the text, and a number style it knows + /// MIN_PAGE of it for the text, and a number style it knows; a stamp's + /// page from MIN_STAMP on, without margins or bands pub fn sanitize(&mut self) { let default = Settings::default(); - self.width = finite(self.width, default.width, MIN_PAGE, MAX_PAGE); - self.height = finite(self.height, default.height, MIN_PAGE, MAX_PAGE); + let min = if self.stamp { MIN_STAMP } else { MIN_PAGE }; + self.width = finite(self.width, default.width, min, MAX_PAGE); + self.height = finite(self.height, default.height, min, MAX_PAGE); + if self.stamp { + // the stamp is the whole page + self.margins = Margins::default(); + self.header = Slots::default(); + self.footer = Slots::default(); + self.first_page = FirstPage::default(); + self.even_pages = None; + self.new_page_before.clear(); + } let margins = &mut self.margins; for margin in [ &mut margins.top, @@ -715,7 +764,7 @@ impl Settings { } // opposite margins shrink alike until the text has its room let fit = |a: &mut f32, b: &mut f32, size: f32| { - let room = size - MIN_PAGE; + let room = size - min; if *a + *b > room { let scale = room / (*a + *b); *a *= scale; @@ -752,6 +801,7 @@ impl Item { | Content::Boxed { pos, .. } | Content::Toc { pos, .. } | Content::Signature { pos, .. } + | Content::Stamp { pos, .. } | Content::Image { pos, .. } => *pos, Content::Table { pos, .. } => *pos, } @@ -766,6 +816,7 @@ impl Item { | Content::Boxed { pos, .. } | Content::Toc { pos, .. } | Content::Signature { pos, .. } + | Content::Stamp { pos, .. } | Content::Image { pos, .. } => pos.saturating_add(1), Content::Table { end, .. } => *end, } @@ -817,6 +868,7 @@ impl Item { | Content::Boxed { pos, .. } | Content::Toc { pos, .. } | Content::Signature { pos, .. } + | Content::Stamp { pos, .. } | Content::Image { pos, .. } => moved(pos), Content::Table { pos, end, rows, .. } => { moved(pos); @@ -903,6 +955,22 @@ impl Item { truncate(hint, MAX_LABEL); clamp_share(share); } + Content::Stamp { + width, + height, + alt, + lines, + size, + .. + } => { + image_size(width, height); + truncate(alt, MAX_LABEL); + lines.truncate(MAX_SIGNATURE_LINES); + for line in lines { + truncate(line, MAX_LABEL); + } + *size = finite(*size, 0.0, 0.0, MAX_STAMP_SIZE); + } Content::Toc { title, depth, @@ -982,6 +1050,8 @@ pub const MAX_LABEL: usize = 500; pub const MAX_TOC_ENTRIES: usize = 10_000; /// the lines a signature block has under its line, at most pub const MAX_SIGNATURE_LINES: usize = 8; +/// the largest letters of a stamp, in points +pub const MAX_STAMP_SIZE: f32 = 288.0; /// cuts `text` to at most `max` characters fn truncate(text: &mut String, max: usize) { @@ -1168,7 +1238,15 @@ mod tests { .unwrap(); let items: Vec = serde_json::from_value(sent["items"].clone()).unwrap(); let settings: Settings = serde_json::from_value(sent["settings"].clone()).unwrap(); - let read = serde_json::json!({ "items": items, "settings": settings }); + // and what a stamp engine gets (src/pdf/stampEngine.ts) + let stamp = &sent["stamp"]; + let stamp_items: Vec = serde_json::from_value(stamp["items"].clone()).unwrap(); + let stamp_settings: Settings = serde_json::from_value(stamp["settings"].clone()).unwrap(); + let read = serde_json::json!({ + "items": items, + "settings": settings, + "stamp": { "items": stamp_items, "settings": stamp_settings }, + }); let (mut wanted, mut found) = (Vec::new(), Vec::new()); keys(&sent, "", &mut wanted); keys(&read, "", &mut found); @@ -1184,5 +1262,30 @@ mod tests { "keys the engine doesn't read: {missing:?}" ); assert!(items.iter().any(|item| item.bars_continue)); + assert!(stamp_settings.stamp); + assert!(matches!(stamp_items[0].content, Content::Stamp { .. })); + } + + #[test] + fn a_stamp_page_is_its_box() { + let mut settings = Settings { + width: 1.0, + height: 30.0, + stamp: true, + ..Settings::default() + }; + settings.header.left = "{title}".into(); + settings.sanitize(); + assert_eq!((settings.width, settings.height), (MIN_STAMP, 30.0)); + assert_eq!(settings.margins, Margins::default()); + assert!(!settings.header.has_text()); + assert_eq!(settings.content_width(), MIN_STAMP); + // a document's page keeps its least size and margins + let mut page = Settings { + width: 1.0, + ..Settings::default() + }; + page.sanitize(); + assert_eq!(page.width, MIN_PAGE); } } diff --git a/src-tauri/layout/src/pdf/tags.rs b/src-tauri/layout/src/pdf/tags.rs index a3620867..bad8c6a4 100644 --- a/src-tauri/layout/src/pdf/tags.rs +++ b/src-tauri/layout/src/pdf/tags.rs @@ -286,7 +286,8 @@ fn item_node(engine: &Engine, index: usize, ids: &mut Ids) -> Option { Some(group(Tag::Div, children)) } Content::Toc { entries, .. } => Some(toc_node(index, entries.len(), ids)), - Content::Break { .. } | Content::Rule { .. } => None, + // a stamp isn't text of the document: it is drawn as an artifact + Content::Break { .. } | Content::Rule { .. } | Content::Stamp { .. } => None, } } diff --git a/src-tauri/layout/src/text.rs b/src-tauri/layout/src/text.rs index 6d92624c..2ddddf36 100644 --- a/src-tauri/layout/src/text.rs +++ b/src-tauri/layout/src/text.rs @@ -171,7 +171,14 @@ impl TextBox { alignment: Alignment, tabs: &[(std::ops::Range, f32)], ) -> TextBox { - let style = text_style(text.style); + let mut style = text_style(text.style); + // its own size, with lines and letter spacing as much larger + if let Some(size) = text.size.filter(|size| *size > 0.0) { + let scale = size / style.size; + style.size = size; + style.line *= scale; + style.tracking *= scale; + } let len = utf16_len(&text.text); let laid_text = if text.text.is_empty() { " ".to_string() diff --git a/src-tauri/layout/tests/exact.rs b/src-tauri/layout/tests/exact.rs index 3a510de5..0ebd8aff 100644 --- a/src-tauri/layout/tests/exact.rs +++ b/src-tauri/layout/tests/exact.rs @@ -21,6 +21,7 @@ fn text_item(pos: u32, text: &str, style: &str, level: u8, spans: Vec) -> hint: None, picture: false, align: None, + size: None, }), indent: 0.0, before: if level > 0 { 16.0 } else { 0.0 }, @@ -212,8 +213,11 @@ fn settings() -> Settings { settings } -/// the words pdftotext finds in a PDF: page, xMin, yMin, xMax, yMax, text -fn read_words(pdf: &std::path::Path) -> Option> { +/// a word pdftotext finds in a PDF: page, xMin, yMin, xMax, yMax, text +type PdfWord = (usize, f32, f32, f32, f32, String); + +/// the words pdftotext finds in a PDF +fn read_words(pdf: &std::path::Path) -> Option> { let out = pdf.with_extension("html"); let status = match Command::new("pdftotext") .arg("-bbox") @@ -1052,3 +1056,95 @@ fn pdf_tags_links_around_their_text() { let links = structure.matches("Link (inline)").count(); assert_eq!(links, 6 * 8, "{links} Links"); } + +/// a stamp engine's item: lines of text, centred, at 14 pt +fn stamp(lines: &[&str]) -> Item { + Item { + content: Content::Stamp { + pos: 0, + src: String::new(), + width: 0.0, + height: 0.0, + alt: String::new(), + lines: lines.iter().map(|line| line.to_string()).collect(), + size: 14.0, + align: Some("center".into()), + }, + indent: 0.0, + before: 0.0, + after: 0.0, + marker: None, + bars: vec![], + bars_continue: false, + page_start: false, + column: None, + frame: None, + flow_top: 0.0, + } +} + +#[test] +fn the_pdf_of_a_stamp_is_its_box() { + let mut engine = Engine::new(repository_fonts()); + // laid out as the webview finds its natural size, then on its box + let mut settings = Settings { + width: 600.0, + height: 200.0, + stamp: true, + ..Default::default() + }; + engine.set_settings(settings.clone()); + engine.set_items(vec![stamp(&["12 October 2026", "Berlin"])]); + let (page, x, y, width, height) = engine.boxes(0, 1)[0]; + assert_eq!(page, 0); + assert!(y.abs() < 0.01 && width > 50.0 && width < 200.0); + assert!((x - (600.0 - width) / 2.0).abs() < 0.01); + let line = 14.0 * blank_layout::style::NATURAL * 1.12; + assert!((height - 2.0 * line).abs() < 0.01); + settings.width = width.ceil(); + settings.height = height.ceil(); + engine.set_settings(settings); + assert_eq!(engine.pages.len(), 1); + // the lines stay unbroken in a box as wide as the wider one + assert_eq!(engine.words().len(), 4); + let pdf = pdf_of(&mut engine); + let path = std::env::temp_dir().join("blank-layout-stamp.pdf"); + std::fs::write(&path, &pdf).unwrap(); + let Some(info) = run("pdfinfo", &[path.to_str().unwrap()]) else { + return; + }; + assert!(info.contains("Pages: 1"), "{info}"); + let size = format!("Page size: {} x {} pts", width.ceil(), height.ceil()); + assert!(info.contains(&size), "{info}"); + compare(&mut engine, "stamp"); +} + +#[test] +fn the_pdf_tags_a_stamp_as_an_artifact() { + let mut engine = Engine::new(repository_fonts()); + engine.set_settings(Settings { + width: 120.0, + height: 30.0, + stamp: true, + ..Default::default() + }); + engine.set_items(vec![stamp(&["Berlin"])]); + let pdf = pdf_of(&mut engine); + let path = std::env::temp_dir().join("blank-layout-stamp-tags.pdf"); + std::fs::write(&path, &pdf).unwrap(); + let Some(content) = run( + "qpdf", + &[ + "--qdf", + "--object-streams=disable", + path.to_str().unwrap(), + "-", + ], + ) else { + return; + }; + // its text is drawn, but marked as an artifact, not as content with a + // place in the structure + assert!(content.contains("/Artifact"), "no artifact"); + assert!(!content.contains("/MCID"), "tagged as content"); +} diff --git a/src-tauri/pdf/fixtures/choices.pdf b/src-tauri/pdf/fixtures/choices.pdf new file mode 100644 index 00000000..283438f3 Binary files /dev/null and b/src-tauri/pdf/fixtures/choices.pdf differ diff --git a/src-tauri/pdf/fixtures/form.pdf b/src-tauri/pdf/fixtures/form.pdf index e6a940f3..86a413d5 100644 Binary files a/src-tauri/pdf/fixtures/form.pdf and b/src-tauri/pdf/fixtures/form.pdf differ diff --git a/src-tauri/pdf/fixtures/password.pdf b/src-tauri/pdf/fixtures/password.pdf index f42f02b1..b5c83cf5 100644 Binary files a/src-tauri/pdf/fixtures/password.pdf and b/src-tauri/pdf/fixtures/password.pdf differ diff --git a/src-tauri/pdf/fixtures/repaired.pdf b/src-tauri/pdf/fixtures/repaired.pdf index 2c675a45..11684460 100644 Binary files a/src-tauri/pdf/fixtures/repaired.pdf and b/src-tauri/pdf/fixtures/repaired.pdf differ diff --git a/src-tauri/pdf/helper/Cargo.toml b/src-tauri/pdf/helper/Cargo.toml index 33e4ddcb..eca75416 100644 --- a/src-tauri/pdf/helper/Cargo.toml +++ b/src-tauri/pdf/helper/Cargo.toml @@ -28,3 +28,6 @@ libc = "0.2" [build-dependencies] cc = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/pdf/helper/shim/blank_mupdf.c b/src-tauri/pdf/helper/shim/blank_mupdf.c index 2cec19ca..6954aa80 100644 --- a/src-tauri/pdf/helper/shim/blank_mupdf.c +++ b/src-tauri/pdf/helper/shim/blank_mupdf.c @@ -4,6 +4,7 @@ * so every entry point here catches. Nothing here prints or logs what a PDF * holds. */ +#include #include #include @@ -23,6 +24,9 @@ /* the most fields and kids a walk of the form looks at */ #define MAX_VISITS 100000 #define MAX_OPTIONS 1000 +/* what a drawn line is to be a rule: wide, thin (protocol.rs, RULE_*) */ +#define RULE_MIN_WIDTH 40 +#define RULE_MAX_HEIGHT 2 typedef struct { @@ -47,6 +51,10 @@ struct blank_engine struct blank_doc { blank_engine *engine; + /* the bytes it was opened from, and its password, to open it afresh */ + const unsigned char *data; + size_t len; + char *password; pdf_document *pdf; int pages; int repaired; @@ -270,6 +278,16 @@ int blank_open(blank_engine *engine, const unsigned char *data, size_t len, cons return status; } doc->engine = engine; + doc->data = data; + doc->len = len; + if (password && (doc->password = malloc(strlen(password) + 1)) != NULL) + memcpy(doc->password, password, strlen(password) + 1); + else if (password) + { + pdf_drop_document(ctx, pdf); + free(doc); + return BLANK_TOO_COMPLEX; + } doc->pdf = pdf; doc->pages = pages; doc->page_index = -1; @@ -288,6 +306,7 @@ void blank_doc_drop(blank_doc *doc) return; fz_drop_page(doc->engine->ctx, doc->page); pdf_drop_document(doc->engine->ctx, doc->pdf); + free(doc->password); free(doc); } @@ -378,6 +397,38 @@ int blank_render_size(blank_doc *doc, int index, float scale, int *width, int *h return status; } +/* draws the page as fz_run_page does, but for the orange arrow saying "SIGN" + * that MuPDF draws over every unsigned signature field in place of its + * appearance: Blank shows its own target there (PdfTarget.vue), which the + * user's signature then covers */ +static void run_page(fz_context *ctx, fz_page *page, fz_device *device, fz_matrix ctm, fz_cookie *cookie) +{ + pdf_page *pdf = pdf_page_from_fz_page(ctx, page); + pdf_annot *widget; + fz_run_page_contents(ctx, page, device, ctm, cookie); + fz_run_page_annots(ctx, page, device, ctm, cookie); + if (!pdf) + { + fz_run_page_widgets(ctx, page, device, ctm, cookie); + return; + } + for (widget = pdf_first_widget(ctx, pdf); widget; widget = pdf_next_widget(ctx, widget)) + { + if (cookie->abort) + break; + if (pdf_widget_type(ctx, widget) == PDF_WIDGET_TYPE_SIGNATURE && !pdf_widget_is_signed(ctx, widget)) + continue; + /* as fz_run_page_widgets: a cancel ends the drawing, not the render */ + fz_try(ctx) + pdf_run_annot(ctx, widget, device, ctm, cookie); + fz_catch(ctx) + { + fz_rethrow_unless(ctx, FZ_ERROR_ABORT); + fz_ignore_error(ctx); + } + } +} + int blank_render(blank_doc *doc, int index, float scale, blank_cookie *cookie, unsigned char *rgba, int width, int height) { blank_engine *engine = doc->engine; @@ -400,7 +451,7 @@ int blank_render(blank_doc *doc, int index, float scale, blank_cookie *cookie, u /* white and opaque, so straight and premultiplied alpha are the same */ fz_clear_pixmap_with_value(ctx, pixmap, 255); device = fz_new_draw_device(ctx, fz_identity, pixmap); - fz_run_page(ctx, page, device, fz_scale(scale, scale), &cookie->cookie); + run_page(ctx, page, device, fz_scale(scale, scale), &cookie->cookie); fz_close_device(ctx, device); } fz_always(ctx) @@ -488,7 +539,6 @@ typedef struct { blank_doc *doc; fz_buffer *out; - int signatures; int count; /* the fields and kids looked at: shared kids make a tree of few objects * that is very wide when walked */ @@ -600,6 +650,22 @@ static void json_widgets(form_walk *walk, pdf_obj *field, int with_state) fz_append_byte(ctx, walk->out, ']'); } +/* a field's value as people see it: for a choice whose options are + * [export display] pairs, the display text of the option /V exports (its + * options are listed by their display texts, and filled in by them too) */ +static const char *shown_value(fz_context *ctx, pdf_obj *field, int type) +{ + const char *value = pdf_field_value(ctx, field); + int i, n; + if (type != PDF_WIDGET_TYPE_COMBOBOX && type != PDF_WIDGET_TYPE_LISTBOX) + return value; + n = pdf_choice_field_option_count(ctx, field); + for (i = 0; i < n; i++) + if (!strcmp(pdf_choice_field_option(ctx, field, 1, i), value)) + return pdf_choice_field_option(ctx, field, 0, i); + return value; +} + static void json_field(form_walk *walk, pdf_obj *field, int type) { fz_context *ctx = walk->doc->engine->ctx; @@ -617,7 +683,7 @@ static void json_field(form_walk *walk, pdf_obj *field, int type) fz_append_printf(ctx, out, ",\"kind\":\"%s\",\"label\":", kind_name(type)); json_string(ctx, out, or_null(pdf_to_text_string(ctx, pdf_dict_get_inheritable(ctx, field, PDF_NAME(TU))))); fz_append_string(ctx, out, ",\"value\":"); - json_string(ctx, out, pdf_field_value(ctx, field)); + json_string(ctx, out, shown_value(ctx, field, type)); fz_append_string(ctx, out, ",\"options\":["); if (type == PDF_WIDGET_TYPE_COMBOBOX || type == PDF_WIDGET_TYPE_LISTBOX) options = fz_mini(pdf_choice_field_option_count(ctx, field), MAX_OPTIONS); @@ -648,46 +714,6 @@ static void json_field(form_walk *walk, pdf_obj *field, int type) fz_rethrow(ctx); } -static void json_signature(form_walk *walk, pdf_obj *field) -{ - fz_context *ctx = walk->doc->engine->ctx; - fz_buffer *out = walk->out; - pdf_obj *value = pdf_dict_get_inheritable(ctx, field, PDF_NAME(V)); - pdf_obj *kids = pdf_dict_get(ctx, field, PDF_NAME(Kids)); - pdf_obj *widget = pdf_is_array(ctx, kids) ? pdf_array_get(ctx, kids, 0) : field; - int page = page_of(walk, widget); - char *name = NULL; - fz_var(name); - fz_try(ctx) - { - name = pdf_load_field_name(ctx, field); - fz_append_string(ctx, out, walk->count ? ",{\"field\":" : "{\"field\":"); - json_string(ctx, out, name); - if (page >= 0) - { - fz_append_printf(ctx, out, ",\"page\":%d,\"rect\":", page); - json_rect(ctx, out, fz_transform_rect(pdf_dict_get_rect(ctx, widget, PDF_NAME(Rect)), walk->ctms[page])); - } - else - fz_append_string(ctx, out, ",\"page\":null,\"rect\":null"); - fz_append_string(ctx, out, ",\"subFilter\":"); - json_string(ctx, out, or_null(pdf_to_name(ctx, pdf_dict_get(ctx, value, PDF_NAME(SubFilter))))); - fz_append_string(ctx, out, ",\"reason\":"); - json_string(ctx, out, or_null(pdf_dict_get_text_string(ctx, value, PDF_NAME(Reason)))); - fz_append_string(ctx, out, ",\"location\":"); - json_string(ctx, out, or_null(pdf_dict_get_text_string(ctx, value, PDF_NAME(Location)))); - fz_append_string(ctx, out, ",\"name\":"); - json_string(ctx, out, or_null(pdf_dict_get_text_string(ctx, value, PDF_NAME(Name)))); - fz_append_string(ctx, out, ",\"time\":"); - json_string(ctx, out, or_null(pdf_dict_get_text_string(ctx, value, PDF_NAME(M)))); - fz_append_byte(ctx, out, '}'); - } - fz_always(ctx) - fz_free(ctx, name); - fz_catch(ctx) - fz_rethrow(ctx); -} - /* whether a field has kids that are fields (with /T) rather than widgets */ static int has_field_kids(fz_context *ctx, pdf_obj *kids) { @@ -722,17 +748,12 @@ static void walk_fields(form_walk *walk, pdf_obj *fields, pdf_cycle_list *up, in type = pdf_field_type(ctx, field); if (!kind_name(type)) continue; - if (!walk->signatures) - json_field(walk, field, type); - else if (type == PDF_WIDGET_TYPE_SIGNATURE && pdf_signature_is_signed(ctx, walk->doc->pdf, field)) - json_signature(walk, field); - else - continue; + json_field(walk, field, type); walk->count++; } } -int blank_form_json(blank_doc *doc, int signatures, char **json, size_t *len) +int blank_form_json(blank_doc *doc, char **json, size_t *len) { blank_engine *engine = doc->engine; fz_context *ctx = engine->ctx; @@ -740,7 +761,6 @@ int blank_form_json(blank_doc *doc, int signatures, char **json, size_t *len) int status = BLANK_OK; memset(&walk, 0, sizeof walk); walk.doc = doc; - walk.signatures = signatures; *json = NULL; *len = 0; engine->refused = 0; @@ -776,3 +796,509 @@ void blank_free(char *json) { free(json); } + +/* a page's text and rules, as JSON */ + +/* a character that separates words */ +static int is_space(int c) +{ + return c <= 32 || c == 0xa0 || (c >= 0x2000 && c <= 0x200b) || c == 0x202f || c == 0x205f || c == 0x3000 || c == 0xfeff; +} + +static int finite_rect(fz_rect rect) +{ + return isfinite(rect.x0) && isfinite(rect.y0) && isfinite(rect.x1) && isfinite(rect.y1); +} + +/* writes the word gathered in `word` with its box, if there is one, and empties it */ +static void flush_word(fz_context *ctx, fz_buffer *out, fz_buffer *word, fz_rect *box, int *count) +{ + unsigned char *data; + size_t size; + /* terminated first: it may move the buffer's storage */ + fz_terminate_buffer(ctx, word); + size = fz_buffer_storage(ctx, word, &data); + if (size && finite_rect(*box) && !fz_is_empty_rect(*box)) + { + fz_append_string(ctx, out, (*count)++ ? ",{\"text\":" : "{\"text\":"); + json_string(ctx, out, (const char *)data); + fz_append_string(ctx, out, ",\"rect\":"); + json_rect(ctx, out, *box); + fz_append_byte(ctx, out, '}'); + } + fz_clear_buffer(ctx, word); + *box = fz_empty_rect; +} + +/* a character's box: MuPDF's quad spans the font's ascender and descender, + * which many fonts give with their line gap; this one spans at most 0.8 em + * above the baseline and 0.25 below it, close to the type itself (and to + * what poppler reads from base 14 metrics) */ +static fz_rect char_box(fz_context *ctx, fz_stext_char *ch) +{ + float ascender = fz_font_ascender(ctx, ch->font); + float descender = fz_font_descender(ctx, ch->font); + float span = ascender - descender; + fz_point up, along, low; + fz_quad quad; + if (!(span > 0.01f)) + return fz_rect_from_quad(ch->quad); + /* a vector of 1 em from the baseline up, and the advance along it */ + up = fz_make_point((ch->quad.ul.x - ch->quad.ll.x) / span, (ch->quad.ul.y - ch->quad.ll.y) / span); + along = fz_make_point(ch->quad.lr.x - ch->quad.ll.x, ch->quad.lr.y - ch->quad.ll.y); + ascender = fz_min(ascender, 0.8f); + descender = fz_max(descender, -0.25f); + low = fz_make_point(ch->origin.x + up.x * descender, ch->origin.y + up.y * descender); + quad.ll = low; + quad.lr = fz_make_point(low.x + along.x, low.y + along.y); + quad.ul = fz_make_point(low.x + up.x * (ascender - descender), low.y + up.y * (ascender - descender)); + quad.ur = fz_make_point(quad.ul.x + along.x, quad.ul.y + along.y); + return fz_rect_from_quad(quad); +} + +/* whether a vector is nearly white: not a line anyone sees */ +static int is_white(uint32_t argb) +{ + return ((argb >> 16) & 0xff) > 0xf0 && ((argb >> 8) & 0xff) > 0xf0 && (argb & 0xff) > 0xf0; +} + +int blank_page_text_json(blank_doc *doc, int index, char **json, size_t *len) +{ + blank_engine *engine = doc->engine; + fz_context *ctx = engine->ctx; + fz_stext_page *text = NULL; + fz_device *device = NULL; + fz_buffer *out = NULL, *rules = NULL, *word = NULL; + int status = BLANK_OK; + *json = NULL; + *len = 0; + engine->refused = 0; + fz_var(text); + fz_var(device); + fz_var(out); + fz_var(rules); + fz_var(word); + fz_var(status); + fz_try(ctx) + { + fz_page *page = load_page(doc, index); + fz_stext_options options; + fz_stext_block *block; + unsigned char *data; + size_t size; + int words = 0, lines = 0; + memset(&options, 0, sizeof options); + options.flags = FZ_STEXT_COLLECT_VECTORS; + text = fz_new_stext_page(ctx, fz_bound_page(ctx, page)); + device = fz_new_stext_device(ctx, text, &options); + /* the page and its annotations, without the form's widgets: their + * values aren't the page's text */ + fz_run_page_contents(ctx, page, device, fz_identity, NULL); + fz_run_page_annots(ctx, page, device, fz_identity, NULL); + fz_close_device(ctx, device); + + out = fz_new_buffer(ctx, 4096); + rules = fz_new_buffer(ctx, 256); + word = fz_new_buffer(ctx, 64); + fz_append_string(ctx, out, "{\"words\":["); + for (block = text->first_block; block; block = block->next) + { + if (block->type == FZ_STEXT_BLOCK_TEXT) + { + fz_stext_line *line; + for (line = block->u.t.first_line; line; line = line->next) + { + fz_stext_char *ch; + fz_rect box = fz_empty_rect; + for (ch = line->first_char; ch; ch = ch->next) + { + char utf8[FZ_UTFMAX]; + if (is_space(ch->c)) + { + flush_word(ctx, out, word, &box, &words); + continue; + } + fz_append_data(ctx, word, utf8, fz_runetochar(utf8, ch->c)); + box = fz_union_rect(box, char_box(ctx, ch)); + } + flush_word(ctx, out, word, &box, &words); + } + } + else if (block->type == FZ_STEXT_BLOCK_VECTOR) + { + fz_rect box = block->bbox; + if (finite_rect(box) && box.x1 - box.x0 >= RULE_MIN_WIDTH && box.y1 - box.y0 <= RULE_MAX_HEIGHT && !is_white(block->u.v.argb)) + { + if (lines++) + fz_append_byte(ctx, rules, ','); + json_rect(ctx, rules, box); + } + } + } + fz_append_string(ctx, out, "],\"rules\":["); + size = fz_buffer_storage(ctx, rules, &data); + fz_append_data(ctx, out, data, size); + fz_append_string(ctx, out, "]}"); + size = fz_buffer_storage(ctx, out, &data); + *json = malloc(size); + if (!*json) + fz_throw(ctx, FZ_ERROR_SYSTEM, "out of memory"); + memcpy(*json, data, size); + *len = size; + } + fz_always(ctx) + { + fz_drop_device(ctx, device); + fz_drop_stext_page(ctx, text); + fz_drop_buffer(ctx, out); + fz_drop_buffer(ctx, rules); + fz_drop_buffer(ctx, word); + } + fz_catch(ctx) + status = caught(engine, BLANK_ENGINE); + return status; +} + +/* filling the form in */ + +/* the base 14 font MuPDF draws a field's text in: the one its /DA names, + * if it is one of the five it knows, else Helvetica (pdf-appearance.c) */ +static const char *appearance_font(fz_context *ctx, pdf_document *pdf, pdf_obj *field) +{ + const char *da = pdf_to_str_buf(ctx, pdf_dict_get_inheritable(ctx, field, PDF_NAME(DA))); + const char *font; + float size, color[4]; + int n; + if (!da || !*da) + da = pdf_to_str_buf(ctx, pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/DA")); + pdf_parse_default_appearance(ctx, da ? da : "", &font, &size, &n, color); + if (!strcmp(font, "Cour")) return "Courier"; + if (!strcmp(font, "TiRo")) return "Times-Roman"; + if (!strcmp(font, "Symb")) return "Symbol"; + if (!strcmp(font, "ZaDb")) return "ZapfDingbats"; + return "Helvetica"; +} + +/* whether MuPDF can show `text` in the field: it writes a field's value in + * its base 14 font with the Windows-1252 encoding, and puts anything else + * in fonts of its own (Greek, Cyrillic, CJK fonts it doesn't embed) or + * replaces it, so only what that encoding and font have is shown as typed */ +static int shows(fz_context *ctx, pdf_document *pdf, pdf_obj *field, const char *text) +{ + fz_font *font = fz_new_base14_font(ctx, appearance_font(ctx, pdf, field)); + int ok = 1; + fz_try(ctx) + { + while (*text && ok) + { + int c; + text += fz_chartorune(&c, text); + if (c == '\n' || c == '\r') + continue; + if (c == FZ_REPLACEMENT_CHARACTER || fz_windows_1252_from_unicode(c) <= 0 || fz_encode_character(ctx, font, c) <= 0) + ok = 0; + } + } + fz_always(ctx) + fz_drop_font(ctx, font); + fz_catch(ctx) + fz_rethrow(ctx); + return ok; +} + +/* whether MuPDF can show every option of a list box: it draws them all, by + * their display texts, whichever is chosen (pdf_write_ch_widget_appearance) */ +static int shows_options(fz_context *ctx, pdf_document *pdf, pdf_obj *field) +{ + int i, n = pdf_choice_field_option_count(ctx, field); + for (i = 0; i < n; i++) + if (!shows(ctx, pdf, field, pdf_choice_field_option(ctx, field, 0, i))) + return 0; + return 1; +} + +/* the widget `i` of a terminal field: its kids without /T, or itself; NULL past the last */ +static pdf_obj *widget_of(fz_context *ctx, pdf_obj *field, int i) +{ + pdf_obj *kids = pdf_dict_get(ctx, field, PDF_NAME(Kids)); + pdf_obj *widget; + if (!pdf_is_array(ctx, kids)) + return i == 0 ? field : NULL; + while (i < pdf_array_len(ctx, kids)) + { + widget = pdf_array_get(ctx, kids, i); + if (!pdf_dict_get(ctx, widget, PDF_NAME(T))) + return widget; + i++; + } + return NULL; +} + +/* the number of runes in `text` */ +static int runes(const char *text) +{ + int count = 0, c; + while (*text) + { + text += fz_chartorune(&c, text); + count++; + } + return count; +} + +/* sets a check box or radio button group to the state `value` (a name): + * the field's /V, and each widget's /AS where it has that state, else /Off */ +static void set_button(fz_context *ctx, pdf_document *pdf, pdf_obj *field, pdf_obj *value) +{ + pdf_obj *widget; + int i; + pdf_dict_put(ctx, field, PDF_NAME(V), value); + pdf_dirty_obj(ctx, field); + for (i = 0; (widget = widget_of(ctx, field, i)) != NULL; i++) + { + pdf_obj *states = pdf_dict_getp(ctx, widget, "AP/N"); + pdf_dict_put(ctx, widget, PDF_NAME(AS), pdf_dict_get(ctx, states, value) ? value : PDF_NAME(Off)); + pdf_dirty_obj(ctx, widget); + } + pdf->resynth_required = 1; +} + +/* whether a radio button group has a widget with the state `name` */ +static int has_state(fz_context *ctx, pdf_obj *field, const char *name) +{ + pdf_obj *widget; + int i; + for (i = 0; (widget = widget_of(ctx, field, i)) != NULL; i++) + if (pdf_dict_gets(ctx, pdf_dict_getp(ctx, widget, "AP/N"), name)) + return 1; + return 0; +} + +/* fills one field in; inside fz_try */ +static int fill_field(fz_context *ctx, pdf_document *pdf, pdf_obj *fields, const blank_fill *fill) +{ + pdf_obj *field = pdf_lookup_field(ctx, fields, fill->field); + char *name; + int same, type, flags, max_len, i, options; + const char *value = fill->text; + if (!field || has_field_kids(ctx, pdf_dict_get(ctx, field, PDF_NAME(Kids)))) + return BLANK_BAD_REQUEST; + /* the field of that full name, not one whose name only starts so */ + name = pdf_load_field_name(ctx, field); + same = !strcmp(name, fill->field); + fz_free(ctx, name); + if (!same) + return BLANK_BAD_REQUEST; + type = pdf_field_type(ctx, field); + flags = pdf_field_flags(ctx, field); + if (flags & PDF_FIELD_IS_READ_ONLY) + return BLANK_BAD_REQUEST; + switch (type) + { + case PDF_WIDGET_TYPE_TEXT: + if (!value) + return BLANK_BAD_REQUEST; + max_len = pdf_dict_get_inheritable_int(ctx, field, PDF_NAME(MaxLen)); + if (max_len > 0 && runes(value) > max_len) + return BLANK_BAD_REQUEST; + if (!(flags & PDF_TX_FIELD_IS_MULTILINE) && strpbrk(value, "\r\n")) + return BLANK_BAD_REQUEST; + if (!shows(ctx, pdf, field, value)) + return BLANK_MISSING_GLYPHS; + pdf_set_field_value(ctx, pdf, field, value, 1); + return BLANK_OK; + case PDF_WIDGET_TYPE_COMBOBOX: + case PDF_WIDGET_TYPE_LISTBOX: + if (!value) + return BLANK_BAD_REQUEST; + /* one of its options, by what it shows; its export value is set */ + options = pdf_choice_field_option_count(ctx, field); + for (i = 0; i < options; i++) + if (!strcmp(pdf_choice_field_option(ctx, field, 0, i), value)) + { + value = pdf_choice_field_option(ctx, field, 1, i); + break; + } + if (i == options && *value && !(type == PDF_WIDGET_TYPE_COMBOBOX && (flags & PDF_CH_FIELD_IS_EDIT))) + return BLANK_BAD_REQUEST; + /* what MuPDF draws: a combo box's value, the one exported (its /V), + * a list box's options */ + if (type == PDF_WIDGET_TYPE_COMBOBOX ? !shows(ctx, pdf, field, value) : !shows_options(ctx, pdf, field)) + return BLANK_MISSING_GLYPHS; + pdf_set_field_value(ctx, pdf, field, value, 1); + return BLANK_OK; + case PDF_WIDGET_TYPE_CHECKBOX: + if (value) + return BLANK_BAD_REQUEST; + if (fill->checked) + { + pdf_obj *widget, *on = NULL; + for (i = 0; !on && (widget = widget_of(ctx, field, i)) != NULL; i++) + { + const char *state = on_state(ctx, widget); + if (state) + on = pdf_new_name(ctx, state); + } + if (!on) + on = PDF_NAME(Yes); + fz_try(ctx) + set_button(ctx, pdf, field, on); + fz_always(ctx) + pdf_drop_obj(ctx, on); + fz_catch(ctx) + fz_rethrow(ctx); + } + else + set_button(ctx, pdf, field, PDF_NAME(Off)); + return BLANK_OK; + case PDF_WIDGET_TYPE_RADIOBUTTON: + if (!value) + return BLANK_BAD_REQUEST; + if (!strcmp(value, "Off")) + { + set_button(ctx, pdf, field, PDF_NAME(Off)); + return BLANK_OK; + } + if (!has_state(ctx, field, value)) + return BLANK_BAD_REQUEST; + { + pdf_obj *on = pdf_new_name(ctx, value); + fz_try(ctx) + set_button(ctx, pdf, field, on); + fz_always(ctx) + pdf_drop_obj(ctx, on); + fz_catch(ctx) + fz_rethrow(ctx); + } + return BLANK_OK; + default: + return BLANK_BAD_REQUEST; + } +} + +/* draws the appearances of the filled fields' widgets again: their pages + * are loaded together, since MuPDF updates the pages that are open */ +static void update_pages(blank_doc *doc, const blank_fill *fills, int count) +{ + fz_context *ctx = doc->engine->ctx; + pdf_obj *fields = pdf_dict_getp(ctx, pdf_trailer(ctx, doc->pdf), "Root/AcroForm/Fields"); + form_walk walk; + fz_page **pages = NULL; + int i, j, page; + memset(&walk, 0, sizeof walk); + walk.doc = doc; + fz_var(pages); + fz_try(ctx) + { + find_widgets(&walk); + pages = fz_calloc(ctx, doc->pages ? doc->pages : 1, sizeof *pages); + for (i = 0; i < count; i++) + { + pdf_obj *field = pdf_lookup_field(ctx, fields, fills[i].field), *widget; + for (j = 0; field && (widget = widget_of(ctx, field, j)) != NULL; j++) + { + page = page_of(&walk, widget); + if (page >= 0 && !pages[page]) + pages[page] = fz_load_page(ctx, &doc->pdf->super, page); + } + } + for (page = 0; page < doc->pages; page++) + if (pages[page]) + pdf_update_page(ctx, (pdf_page *)pages[page]); + } + fz_always(ctx) + { + if (pages) + for (page = 0; page < doc->pages; page++) + fz_drop_page(ctx, pages[page]); + fz_free(ctx, pages); + fz_free(ctx, walk.widgets); + fz_free(ctx, walk.ctms); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +int blank_set_fills(blank_doc *doc, const blank_fill *fills, int count, blank_doc **out, int *failed) +{ + blank_engine *engine = doc->engine; + fz_context *ctx = engine->ctx; + blank_doc *filled = NULL; + int status, i; + *out = NULL; + *failed = -1; + status = blank_open(engine, doc->data, doc->len, doc->password, &filled); + if (status != BLANK_OK) + return status; + engine->refused = 0; + fz_var(status); + fz_var(i); + fz_try(ctx) + { + pdf_obj *fields = pdf_dict_getp(ctx, pdf_trailer(ctx, filled->pdf), "Root/AcroForm/Fields"); + for (i = 0; i < count && status == BLANK_OK; i++) + { + status = fill_field(ctx, filled->pdf, fields, &fills[i]); + if (status != BLANK_OK) + *failed = i; + } + if (status == BLANK_OK) + update_pages(filled, fills, count); + } + fz_catch(ctx) + status = caught(engine, BLANK_ENGINE); + if (status != BLANK_OK) + { + blank_doc_drop(filled); + return status; + } + *out = filled; + return BLANK_OK; +} + +int blank_save(blank_doc *doc, unsigned char **data, size_t *len) +{ + blank_engine *engine = doc->engine; + fz_context *ctx = engine->ctx; + fz_buffer *buffer = NULL; + fz_output *out = NULL; + int status = BLANK_OK; + *data = NULL; + *len = 0; + /* MuPDF can't append to a repaired PDF; one with bytes before its + * header would get offsets that are off by them (pdf_write_document + * doesn't take its bias into account) */ + if (doc->repaired || doc->pdf->bias) + return BLANK_REPAIRED; + if (doc->encrypted) + return BLANK_ENCRYPTED; + engine->refused = 0; + fz_var(buffer); + fz_var(out); + fz_var(status); + fz_try(ctx) + { + pdf_write_options options = pdf_default_write_options; + unsigned char *saved; + size_t size; + options.do_incremental = 1; + buffer = fz_new_buffer(ctx, doc->len + 4096); + out = fz_new_output_with_buffer(ctx, buffer); + pdf_write_document(ctx, doc->pdf, out, &options); + fz_close_output(ctx, out); + size = fz_buffer_storage(ctx, buffer, &saved); + *data = malloc(size ? size : 1); + if (!*data) + fz_throw(ctx, FZ_ERROR_SYSTEM, "out of memory"); + memcpy(*data, saved, size); + *len = size; + } + fz_always(ctx) + { + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buffer); + } + fz_catch(ctx) + status = caught(engine, BLANK_ENGINE); + return status; +} diff --git a/src-tauri/pdf/helper/shim/blank_mupdf.h b/src-tauri/pdf/helper/shim/blank_mupdf.h index 2aa5936a..8d36d082 100644 --- a/src-tauri/pdf/helper/shim/blank_mupdf.h +++ b/src-tauri/pdf/helper/shim/blank_mupdf.h @@ -20,6 +20,14 @@ enum BLANK_CANCELLED = 3, BLANK_NEEDS_PASSWORD = 4, BLANK_DAMAGED = 5, + /* a fill names no field it can fill in, or a value it can't take */ + BLANK_BAD_REQUEST = 6, + /* a fill's value has characters the field's font can't show */ + BLANK_MISSING_GLYPHS = 7, + /* blank_save: the PDF was repaired to be read */ + BLANK_REPAIRED = 8, + /* blank_save: the PDF is encrypted */ + BLANK_ENCRYPTED = 9, }; /* @@ -54,10 +62,34 @@ void blank_cookie_abort(blank_cookie *cookie); void blank_cookie_drop(blank_cookie *cookie); /* - * The form's fields, or (signatures != 0) its signatures, as JSON in the - * shapes of protocol.rs, in *json (blank_free frees it). + * The form's fields as JSON in the shape of protocol.rs, in *json + * (blank_free frees it). */ -int blank_form_json(blank_doc *doc, int signatures, char **json, size_t *len); +int blank_form_json(blank_doc *doc, char **json, size_t *len); void blank_free(char *json); +/* the words of a page and its thin horizontal lines, as JSON in the shape + * of protocol.rs's PageText, in *json (blank_free frees it) */ +int blank_page_text_json(blank_doc *doc, int page, char **json, size_t *len); + +/* a field (its full name) and its value: `text`, or for a check box (text + * NULL) whether it is `checked` */ +typedef struct blank_fill +{ + const char *field; + const char *text; + int checked; +} blank_fill; + +/* + * The PDF of `doc` opened afresh, from its bytes, with `fills` filled in and + * their appearances drawn again, in *out. On a failure *failed is the + * index of the fill that failed, or -1. + */ +int blank_set_fills(blank_doc *doc, const blank_fill *fills, int count, blank_doc **out, int *failed); + +/* the PDF as opened with its changes as an incremental update, in *data + * (blank_free frees it) */ +int blank_save(blank_doc *doc, unsigned char **data, size_t *len); + #endif diff --git a/src-tauri/pdf/helper/src/mupdf.rs b/src-tauri/pdf/helper/src/mupdf.rs index f7f3fa02..f478c772 100644 --- a/src-tauri/pdf/helper/src/mupdf.rs +++ b/src-tauri/pdf/helper/src/mupdf.rs @@ -6,7 +6,9 @@ use std::rc::Rc; use std::sync::Arc; use blank_pdf_ipc::engine::{Bitmap, CancelFlag, EngineError, PdfDoc, PdfEngine}; -use blank_pdf_ipc::protocol::{ErrorKind, Field, OpenInfo, PageInfo, SigInfo, MAX_PIXELS}; +use blank_pdf_ipc::protocol::{ + ErrorKind, Field, Fill, FillValue, OpenInfo, PageInfo, PageText, MAX_PIXELS, +}; use serde::de::DeserializeOwned; // the static MuPDF libraries the shim needs are linked through mupdf-sys @@ -25,6 +27,14 @@ struct RawCookie { _private: [u8; 0], } +/// `blank_fill` of shim/blank_mupdf.h +#[repr(C)] +struct RawFill { + field: *const c_char, + text: *const c_char, + checked: c_int, +} + /// see `blank_font_finder` in shim/blank_mupdf.h type FontFinder = unsafe extern "C" fn( name: *const c_char, @@ -82,13 +92,22 @@ extern "C" { fn blank_cookie_new() -> *mut RawCookie; fn blank_cookie_abort(cookie: *mut RawCookie); fn blank_cookie_drop(cookie: *mut RawCookie); - fn blank_form_json( + fn blank_form_json(doc: *mut RawDoc, json: *mut *mut c_char, len: *mut usize) -> c_int; + fn blank_free(json: *mut c_char); + fn blank_page_text_json( doc: *mut RawDoc, - signatures: c_int, + page: c_int, json: *mut *mut c_char, len: *mut usize, ) -> c_int; - fn blank_free(json: *mut c_char); + fn blank_set_fills( + doc: *mut RawDoc, + fills: *const RawFill, + count: c_int, + out: *mut *mut RawDoc, + failed: *mut c_int, + ) -> c_int; + fn blank_save(doc: *mut RawDoc, data: *mut *mut u8, len: *mut usize) -> c_int; } /// a shim status as a result @@ -99,6 +118,10 @@ fn checked(status: c_int) -> Result<(), EngineError> { 3 => ErrorKind::Cancelled, 4 => ErrorKind::NeedsPassword, 5 => ErrorKind::Damaged, + 6 => ErrorKind::BadRequest, + 7 => ErrorKind::MissingGlyphs, + 8 => ErrorKind::Repaired, + 9 => ErrorKind::Encrypted, _ => ErrorKind::Engine, }; Err(kind.into()) @@ -172,6 +195,7 @@ impl PdfEngine for Mupdf { Ok(MupdfDoc { raw, info, + fills: vec![], _bytes: bytes, _engine: self.engine.clone(), }) @@ -183,6 +207,8 @@ impl PdfEngine for Mupdf { pub struct MupdfDoc { raw: *mut RawDoc, info: OpenInfo, + /// the fills `raw` has: it is the PDF opened afresh with them + fills: Vec, _bytes: Arc<[u8]>, _engine: Rc, } @@ -212,17 +238,73 @@ impl MupdfDoc { } } - fn form(&mut self, signatures: bool) -> Result { - let mut json = std::ptr::null_mut(); - let mut len = 0; - checked(unsafe { blank_form_json(self.raw, signatures as c_int, &mut json, &mut len) })?; - let bytes = unsafe { std::slice::from_raw_parts(json as *const u8, len) }; - let parsed = serde_json::from_str(&String::from_utf8_lossy(bytes)); - unsafe { blank_free(json) }; - parsed.map_err(|_| ErrorKind::Engine.into()) + /// the PDF opened afresh with `fills`, their appearances drawn again; + /// a tab in a value is a space, which the fonts MuPDF draws fields in + /// have, where a tab has no glyph. A refusal says which fill failed + fn filled(&self, fills: &[Fill]) -> Result<*mut RawDoc, EngineError> { + let text = |text: &str| CString::new(text); + let mut strings = Vec::with_capacity(fills.len()); + for (index, fill) in fills.iter().enumerate() { + let refused = |_| EngineError::of_fill(ErrorKind::BadRequest, index); + let value = match &fill.value { + FillValue::Text(value) => Some(text(&value.replace('\t', " ")).map_err(refused)?), + FillValue::Checked(_) => None, + }; + strings.push((text(&fill.field).map_err(refused)?, value)); + } + let raw: Vec = fills + .iter() + .zip(&strings) + .map(|(fill, (field, value))| RawFill { + field: field.as_ptr(), + text: value + .as_ref() + .map_or(std::ptr::null(), |value| value.as_ptr()), + checked: matches!(fill.value, FillValue::Checked(true)) as c_int, + }) + .collect(); + let count = c_int::try_from(raw.len()).map_err(|_| ErrorKind::BadRequest)?; + let (mut out, mut failed) = (std::ptr::null_mut(), -1); + let status = + unsafe { blank_set_fills(self.raw, raw.as_ptr(), count, &mut out, &mut failed) }; + checked(status).map_err(|error| match usize::try_from(failed) { + Ok(index) if index < fills.len() => EngineError::of_fill(error.kind, index), + _ => error, + })?; + Ok(out) } } +/// reads the JSON a shim function writes, which it allocated +fn read_json( + write: impl FnOnce(*mut *mut c_char, *mut usize) -> c_int, +) -> Result { + let mut json = std::ptr::null_mut(); + let mut len = 0; + checked(write(&mut json, &mut len))?; + let bytes = unsafe { std::slice::from_raw_parts(json as *const u8, len) }; + let parsed = serde_json::from_str(&String::from_utf8_lossy(bytes)); + unsafe { blank_free(json) }; + parsed.map_err(|_| ErrorKind::Engine.into()) +} + +/// the pages of the fields whose value differs between `before` and `after` +fn changed_pages(before: &[Field], after: &[Field]) -> Vec { + let mut pages: Vec = after + .iter() + .filter(|field| { + before + .iter() + .find(|old| old.name == field.name) + .is_none_or(|old| old.value != field.value) + }) + .flat_map(|field| field.widgets.iter().map(|widget| widget.page)) + .collect(); + pages.sort_unstable(); + pages.dedup(); + pages +} + impl PdfDoc for MupdfDoc { fn info(&self) -> OpenInfo { self.info.clone() @@ -297,11 +379,38 @@ impl PdfDoc for MupdfDoc { }) } + fn text(&mut self, index: usize) -> Result { + let (raw, page) = (self.raw, self.index(index)?); + read_json(|json, len| unsafe { blank_page_text_json(raw, page, json, len) }) + } + fn fields(&mut self) -> Result, EngineError> { - self.form(false) + let raw = self.raw; + read_json(|json, len| unsafe { blank_form_json(raw, json, len) }) } - fn signatures(&mut self) -> Result, EngineError> { - self.form(true) + fn set_fills(&mut self, fills: &[Fill]) -> Result, EngineError> { + let before = self.fields()?; + let filled = self.filled(fills)?; + unsafe { blank_doc_drop(self.raw) }; + self.raw = filled; + self.fills = fills.to_vec(); + Ok(changed_pages(&before, &self.fields()?)) + } + + fn save_incremental(&mut self) -> Result, EngineError> { + // from a document of its own: MuPDF takes what it saved as saved + let fresh = MupdfDoc { + raw: self.filled(&self.fills)?, + info: self.info.clone(), + fills: self.fills.clone(), + _bytes: self._bytes.clone(), + _engine: self._engine.clone(), + }; + let (mut data, mut len) = (std::ptr::null_mut(), 0); + checked(unsafe { blank_save(fresh.raw, &mut data, &mut len) })?; + let saved = unsafe { std::slice::from_raw_parts(data, len) }.to_vec(); + unsafe { blank_free(data as *mut c_char) }; + Ok(saved) } } diff --git a/src-tauri/pdf/helper/tests/fills.rs b/src-tauri/pdf/helper/tests/fills.rs new file mode 100644 index 00000000..6439f02e --- /dev/null +++ b/src-tauri/pdf/helper/tests/fills.rs @@ -0,0 +1,421 @@ +//! A page's text, and filling the form in, on the fixtures of +//! scripts/build-pdf-fixtures.ts, checked with poppler and qpdf where they +//! are installed (CI has them: there a missing tool fails the test). + +use std::io::Write; +use std::process::{Command, Stdio}; +use std::sync::Arc; + +use blank_pdf::mupdf::{Mupdf, MupdfDoc}; +use blank_pdf_ipc::engine::{CancelFlag, PdfDoc, PdfEngine}; +use blank_pdf_ipc::protocol::{ErrorKind, Field, Fill, FillValue, Rect}; + +fn fixture(name: &str) -> Arc<[u8]> { + let path = format!("{}/../fixtures/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(path).unwrap().into() +} + +fn engine() -> Mupdf { + Mupdf::new(256 << 20, 32 << 20).unwrap() +} + +fn open(engine: &Mupdf, name: &str) -> MupdfDoc { + engine.open(fixture(name), None).unwrap() +} + +/// whether `tool` runs; without it a test is skipped, except on CI +fn has(tool: &str) -> bool { + let found = Command::new(tool) + .arg("-v") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok(); + if !found { + assert!(std::env::var_os("CI").is_none(), "CI needs {tool}"); + eprintln!("skipped: no {tool}"); + } + found +} + +/// runs `tool` with `args` and the PDF `pdf` as a file, then `-` (stdout) +fn run(tool: &str, args: &[&str], pdf: &[u8]) -> (bool, String) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("in.pdf"); + std::fs::File::create(&path) + .unwrap() + .write_all(pdf) + .unwrap(); + let output = Command::new(tool) + .args(args) + .arg(&path) + .args(if tool == "qpdf" { vec![] } else { vec!["-"] }) + .output() + .unwrap(); + ( + output.status.success(), + String::from_utf8_lossy(&output.stdout).into_owned(), + ) +} + +/// pdftotext's words on `page` (from 0) with their boxes, `[x, y, w, h]` +fn poppler_words(pdf: &[u8], page: usize) -> Vec<(String, Rect)> { + let first = (page + 1).to_string(); + let (ok, html) = run("pdftotext", &["-bbox", "-f", &first, "-l", &first], pdf); + assert!(ok); + html.lines() + .filter_map(|line| { + let line = line.trim().strip_prefix(" f32 { + let at = line.find(&format!("{name}=\"")).unwrap() + name.len() + 2; + line[at..].split('"').next().unwrap().parse().unwrap() + }; + let text = line.split('>').nth(1)?.split('<').next()?.to_string(); + let (x0, y0) = (number("xMin"), number("yMin")); + Some((text, [x0, y0, number("xMax") - x0, number("yMax") - y0])) + }) + .collect() +} + +/// whether the edges of `rect` are each within `within` of `expected`'s +fn near(rect: Rect, expected: Rect, within: f32) -> bool { + let edges = |[x, y, w, h]: Rect| [x, y, x + w, y + h]; + edges(rect) + .iter() + .zip(edges(expected)) + .all(|(a, b)| (a - b).abs() <= within) +} + +#[test] +fn reads_the_words_and_lines_of_a_page() { + let engine = engine(); + let text = open(&engine, "form.pdf").text(0).unwrap(); + let words: Vec<&str> = text.words.iter().map(|word| word.text.as_str()).collect(); + // the page's own text, not the values of its fields + assert_eq!( + words, + [ + "Contract", + "Unterschrift", + "Ort,", + "Datum:", + "______________" + ] + ); + // the line over "Unterschrift": 100 490 to 300 490, 0.5 pt wide; the + // underscores are a word, not a line + assert_eq!(text.rules.len(), 1, "{:?}", text.rules); + assert!( + near(text.rules[0], [100.0, 351.75, 200.0, 0.5], 1.0), + "{:?}", + text.rules[0] + ); + + if !has("pdftotext") { + return; + } + for word in &text.words { + let poppler = poppler_words(&fixture("form.pdf"), 0); + let (_, expected) = poppler.iter().find(|(text, _)| *text == word.text).unwrap(); + assert!( + near(word.rect, *expected, 1.0), + "{}: {:?}, pdftotext {:?}", + word.text, + word.rect, + expected + ); + } +} + +#[test] +fn reads_the_words_of_turned_and_cropped_pages() { + let engine = engine(); + let mut doc = open(&engine, "rotated.pdf"); + let turned = doc.text(0).unwrap(); + assert_eq!(turned.words.len(), 1); + assert_eq!(turned.words[0].text, "Turned"); + // the filled box is no line: 30 pt high + assert!(turned.rules.is_empty()); + let a5 = doc.text(1).unwrap(); + assert_eq!(a5.words[0].text, "A5"); + assert_eq!(doc.text(2).unwrap_err().kind, ErrorKind::BadRequest); + + if !has("pdftotext") { + return; + } + // pdftotext turns the page, but from the media box: the crop box is + // 50 pt in on both sides + let (_, [x, y, w, h]) = poppler_words(&fixture("rotated.pdf"), 0).remove(0); + let expected = [x - 50.0, y - 50.0, w, h]; + assert!( + near(turned.words[0].rect, expected, 1.0), + "{:?}, pdftotext {expected:?}", + turned.words[0].rect + ); + let (_, expected) = poppler_words(&fixture("rotated.pdf"), 1).remove(0); + assert!(near(a5.words[0].rect, expected, 1.0)); +} + +fn fill(field: &str, value: &str) -> Fill { + Fill { + field: field.into(), + value: FillValue::Text(value.into()), + } +} + +fn check(field: &str, checked: bool) -> Fill { + Fill { + field: field.into(), + value: FillValue::Checked(checked), + } +} + +fn values(fields: &[Field]) -> Vec<(&str, &str)> { + fields + .iter() + .map(|field| (field.name.as_str(), field.value.as_str())) + .collect() +} + +fn the_fills() -> Vec { + vec![ + fill("name", "Ada Müller"), + check("agree", true), + fill("color", "Blue"), + fill("address.street", "Main Street 1"), + fill("size", "L"), + fill("fruit", "Pear"), + ] +} + +const FILLED: [(&str, &str); 8] = [ + ("name", "Ada Müller"), + ("agree", "Yes"), + ("color", "Blue"), + ("signature", ""), + ("id", "A-17"), + ("address.street", "Main Street 1"), + ("size", "L"), + ("fruit", "Pear"), +]; + +/// whether the middle of a widget of `rect` is dark on a page drawn at scale 1 +fn inked(doc: &mut MupdfDoc, rect: Rect) -> bool { + let bitmap = doc.render(0, 1.0, &CancelFlag::default()).unwrap(); + let (x, y) = ( + (rect[0] + rect[2] / 2.0) as u32, + (rect[1] + rect[3] / 2.0) as u32, + ); + let at = ((y * bitmap.width + x) * 4) as usize; + bitmap.rgba[at] < 100 +} + +#[test] +fn fills_the_form_in_and_draws_it_so() { + let engine = engine(); + let mut doc = open(&engine, "form.pdf"); + let fields = doc.fields().unwrap(); + let widget = |name: &str, at: usize| { + fields + .iter() + .find(|field| field.name == name) + .unwrap() + .widgets[at] + .clone() + }; + let (agree, large) = (widget("agree", 0), widget("size", 1)); + assert_eq!(widget("size", 0).on_value.as_deref(), Some("S")); + assert_eq!(large.on_value.as_deref(), Some("L")); + assert!(!inked(&mut doc, agree.rect)); + + assert_eq!(doc.set_fills(&the_fills()).unwrap(), [0]); + assert_eq!(values(&doc.fields().unwrap()), FILLED); + assert!(inked(&mut doc, agree.rect), "the checkbox shows its check"); + assert!(inked(&mut doc, large.rect), "and the radio button L"); + assert!(!inked(&mut doc, widget("size", 0).rect)); + + // the same set again changes nothing; another undoes the rest + assert!(doc.set_fills(&the_fills()).unwrap().is_empty()); + doc.set_fills(&[fill("size", "S"), check("agree", false)]) + .unwrap(); + let fields = doc.fields().unwrap(); + let values = values(&fields); + assert_eq!(values[0], ("name", ""), "an earlier fill isn't kept"); + assert_eq!(values[1], ("agree", "Off")); + assert_eq!(values[6], ("size", "S")); + assert!(!inked(&mut doc, agree.rect)); +} + +#[test] +fn saves_the_fills_as_an_update_of_the_pdf() { + let engine = engine(); + let original = fixture("form.pdf"); + let mut doc = open(&engine, "form.pdf"); + assert_eq!(doc.save_incremental().unwrap(), &original[..], "no fills"); + doc.set_fills(&the_fills()).unwrap(); + let saved = doc.save_incremental().unwrap(); + assert!(saved.starts_with(&original) && saved.len() > original.len()); + assert_eq!(doc.save_incremental().unwrap(), saved, "the same again"); + + // read back, by MuPDF + let mut reopened = engine.open(saved.clone().into(), None).unwrap(); + assert!(!reopened.info().repaired); + assert_eq!(values(&reopened.fields().unwrap()), FILLED); + + if has("qpdf") { + let (ok, _) = run("qpdf", &["--check"], &saved); + assert!(ok, "qpdf --check"); + } + if has("pdftotext") { + let (ok, text) = run("pdftotext", &[], &saved); + assert!(ok); + for value in ["Ada Müller", "Main Street 1", "Blue"] { + assert!(text.contains(value), "{value} in {text}"); + } + } +} + +#[test] +fn saves_a_fill_on_a_turned_page() { + let engine = engine(); + let original = fixture("rotated.pdf"); + let mut doc = open(&engine, "rotated.pdf"); + assert_eq!(doc.set_fills(&[fill("stamp", "Signed")]).unwrap(), [0]); + let saved = doc.save_incremental().unwrap(); + assert!(saved.starts_with(&original)); + if has("pdftotext") { + let (_, text) = run("pdftotext", &["-f", "1", "-l", "1"], &saved); + assert!(text.contains("Signed"), "{text}"); + } +} + +#[test] +fn refuses_what_a_field_cant_take() { + let engine = engine(); + let mut doc = open(&engine, "form.pdf"); + doc.set_fills(&[fill("name", "Ada")]).unwrap(); + let refused = |doc: &mut MupdfDoc, fills: &[Fill]| doc.set_fills(fills).unwrap_err().kind; + // Helvetica in Windows-1252: no Chinese, no Polish Ł + for value in ["中文", "Łukasz", "Ada \u{1F600}"] { + assert_eq!( + refused(&mut doc, &[fill("name", value)]), + ErrorKind::MissingGlyphs, + "{value}" + ); + } + for fills in [ + vec![fill("id", "B-2")], // read-only + vec![fill("nobody", "x")], // no such field + vec![fill("address", "x")], // not a field to fill in + vec![fill("addr", "x")], // a part of a name + vec![fill("signature", "x")], // signed elsewhere + vec![fill("name", &"x".repeat(41))], // its /MaxLen is 40 + vec![fill("name", "two\nlines")], // one line only + vec![fill("agree", "Yes")], // a checkbox takes true or false + vec![check("name", true)], + vec![fill("size", "XL")], // not one of its buttons + vec![fill("fruit", "Fig")], // not one of its options + vec![fill("color", "Teal")], + ] { + assert_eq!( + refused(&mut doc, &fills), + ErrorKind::BadRequest, + "{fills:?}" + ); + } + // the last set that held stays + assert_eq!(values(&doc.fields().unwrap())[0], ("name", "Ada")); + // a radio button group can be emptied, a choice too + doc.set_fills(&[fill("size", "Off"), fill("color", "")]) + .unwrap(); +} + +#[test] +fn refuses_to_save_a_repaired_or_encrypted_pdf() { + let engine = engine(); + let mut repaired = open(&engine, "repaired.pdf"); + repaired.set_fills(&[fill("name", "Ada")]).unwrap(); + assert_eq!( + repaired.save_incremental().unwrap_err().kind, + ErrorKind::Repaired + ); + let mut locked = engine + .open(fixture("password.pdf"), Some("secret")) + .unwrap(); + locked.set_fills(&[fill("name", "Ada")]).unwrap(); + assert_eq!(values(&locked.fields().unwrap())[0], ("name", "Ada")); + assert_eq!( + locked.save_incremental().unwrap_err().kind, + ErrorKind::Encrypted + ); +} + +#[test] +fn shows_and_fills_choices_by_their_display_texts() { + let engine = engine(); + let mut doc = open(&engine, "choices.pdf"); + let shown = |doc: &mut MupdfDoc| { + doc.fields() + .unwrap() + .into_iter() + .map(|field| (field.name, field.value, field.options)) + .collect::>() + }; + let fields = shown(&mut doc); + assert_eq!( + fields[0], + ( + "country".into(), + "Germany".into(), + vec!["Germany".into(), "Łódź".into(), "Omega".into()] + ), + "the display text of the option /V exports" + ); + assert_eq!( + (fields[1].1.as_str(), &fields[1].2[..]), + ("Dog", &["Cat".to_string(), "Dog".to_string()][..]) + ); + + // by its display text; MuPDF draws a combo box's export value, which + // Helvetica shows for Łódź ("pl") and not for Omega (Ω) + doc.set_fills(&[fill("country", "Łódź"), fill("pets", "Cat")]) + .unwrap(); + let fields = shown(&mut doc); + assert_eq!( + (fields[0].1.as_str(), fields[1].1.as_str()), + ("Łódź", "Cat") + ); + let refused = |doc: &mut MupdfDoc, fills: &[Fill]| doc.set_fills(fills).unwrap_err(); + let omega = refused(&mut doc, &[fill("pets", "Dog"), fill("country", "Omega")]); + assert_eq!(omega.kind, ErrorKind::MissingGlyphs); + assert_eq!(omega.fill, Some(1), "the fill that failed"); + // a list box shows all its options, β among them, whichever is chosen + let letters = refused(&mut doc, &[fill("letters", "Alpha")]); + assert_eq!( + (letters.kind, letters.fill), + (ErrorKind::MissingGlyphs, Some(0)) + ); + + // saved, the exported values + doc.set_fills(&[fill("country", "Łódź"), fill("pets", "Cat")]) + .unwrap(); + let saved = doc.save_incremental().unwrap(); + let text = String::from_utf8_lossy(&saved); + let update = &text[fixture("choices.pdf").len()..]; + assert!( + update.contains("/V(pl)") || update.contains("/V (pl)"), + "{update}" + ); + assert!( + update.contains("/V(c)") || update.contains("/V (c)"), + "{update}" + ); +} + +#[test] +fn fills_a_tab_in_as_a_space() { + let engine = engine(); + let mut doc = open(&engine, "form.pdf"); + doc.set_fills(&[fill("name", "Ada\tMüller")]).unwrap(); + assert_eq!(values(&doc.fields().unwrap())[0], ("name", "Ada Müller")); +} diff --git a/src-tauri/pdf/helper/tests/mupdf.rs b/src-tauri/pdf/helper/tests/mupdf.rs index d75dac9e..0aad2aa8 100644 --- a/src-tauri/pdf/helper/tests/mupdf.rs +++ b/src-tauri/pdf/helper/tests/mupdf.rs @@ -105,7 +105,9 @@ fn reads_the_form() { "color", "signature", "id", - "address.street" + "address.street", + "size", + "fruit" ] ); let field = |name: &str| fields.iter().find(|field| field.name == name).unwrap(); @@ -133,6 +135,21 @@ fn reads_the_form() { assert_eq!(color.options, ["Red", "Green", "Blue"]); assert_eq!(color.value, "Green"); + let size = field("size"); + assert_eq!(size.kind, FieldKind::Radio); + assert_eq!(size.value, "Off"); + let states: Vec<_> = size + .widgets + .iter() + .map(|widget| widget.on_value.as_deref()) + .collect(); + assert_eq!(states, [Some("S"), Some("L")]); + + let fruit = field("fruit"); + assert_eq!(fruit.kind, FieldKind::List); + assert_eq!(fruit.options, ["Apple", "Pear"]); + assert_eq!(fruit.value, ""); + let signature = field("signature"); assert_eq!(signature.kind, FieldKind::Signature); assert!(!signature.signed); @@ -145,7 +162,6 @@ fn reads_the_form() { FieldKind::Text, "inherits /FT" ); - assert!(open(&engine, "form.pdf").signatures().unwrap().is_empty()); } #[test] @@ -161,19 +177,16 @@ fn places_widgets_on_turned_pages() { } #[test] -fn reads_the_signatures() { +fn reads_a_signed_signature_field() { let engine = engine(); - let signatures = open(&engine, "signature.pdf").signatures().unwrap(); - assert_eq!(signatures.len(), 1); - let signature = &signatures[0]; - assert_eq!(signature.field, "Signature1"); - assert_eq!(signature.page, Some(0)); - assert!(near(signature.rect.unwrap(), [72.0, 720.0, 200.0, 50.0])); - assert_eq!(signature.sub_filter.as_deref(), Some("ETSI.CAdES.detached")); - assert_eq!(signature.name.as_deref(), Some("Ada Lovelace")); - assert_eq!(signature.reason.as_deref(), Some("I agree")); - assert_eq!(signature.location.as_deref(), Some("London")); - assert_eq!(signature.time.as_deref(), Some("D:20261008120000Z")); + let fields = open(&engine, "signature.pdf").fields().unwrap(); + assert_eq!(fields.len(), 1); + let signature = &fields[0]; + assert_eq!(signature.name, "Signature1"); + assert_eq!(signature.kind, FieldKind::Signature); + assert!(signature.signed); + assert_eq!(signature.widgets[0].page, 0); + assert!(near(signature.widgets[0].rect, [72.0, 720.0, 200.0, 50.0])); } #[test] @@ -244,3 +257,27 @@ fn draws_chinese_in_a_system_font() { assert!(ink(35, 70) > 50, "the characters show"); assert_eq!(ink(0, 15), 0); } + +#[test] +fn draws_no_sign_arrow_over_an_unsigned_signature_field() { + // MuPDF draws an orange arrow saying "SIGN" over every unsigned + // signature field; Blank shows its own target there, which the user's + // signature covers + let engine = engine(); + let mut doc = open(&engine, "form.pdf"); + let fields = doc.fields().unwrap(); + let field = fields + .iter() + .find(|field| field.name == "signature") + .unwrap(); + let rect = field.widgets[0].rect; + let bitmap = doc.render(0, 1.0, &CancelFlag::default()).unwrap(); + let orange = (rect[1] as u32..(rect[1] + rect[3]) as u32).any(|y| { + (rect[0] as u32..(rect[0] + rect[2]) as u32).any(|x| { + let at = ((y * bitmap.width + x) * 4) as usize; + let [r, g, b] = [bitmap.rgba[at], bitmap.rgba[at + 1], bitmap.rgba[at + 2]]; + r > 200 && g < 150 && b < 100 + }) + }); + assert!(!orange, "the SIGN arrow shows"); +} diff --git a/src-tauri/pdf/helper/tests/served.rs b/src-tauri/pdf/helper/tests/served.rs index abb96241..a091fa0d 100644 --- a/src-tauri/pdf/helper/tests/served.rs +++ b/src-tauri/pdf/helper/tests/served.rs @@ -58,3 +58,35 @@ fn serves_a_pdf_to_the_client() { rounds[1] ); } + +#[test] +fn fills_a_form_in_and_saves_it_through_the_client() { + use blank_pdf_ipc::protocol::{Field, Fill, FillValue, PageText}; + let path = format!("{}/../fixtures/form.pdf", env!("CARGO_MANIFEST_DIR")); + let pdf: Arc<[u8]> = std::fs::read(path).unwrap().into(); + let config = HelperConfig { + program: env!("CARGO_BIN_EXE_blank-pdf").into(), + args: vec!["--memory-limit-mb".into(), "256".into()], + quiet: true, + timeouts: Timeouts::default(), + }; + let original = pdf.clone(); + let supervisor = Supervisor::new(config, Box::new(move || Ok(pdf.clone()))); + let text: PageText = supervisor.ask(Op::Text { page: 0 }).unwrap(); + assert_eq!(text.words[0].text, "Contract"); + let fills = vec![ + Fill { + field: "name".into(), + value: FillValue::Text("Ada".into()), + }, + Fill { + field: "agree".into(), + value: FillValue::Checked(true), + }, + ]; + assert_eq!(supervisor.set_fills(fills).unwrap(), [0]); + let fields: Vec = supervisor.ask(Op::Fields).unwrap(); + assert_eq!(fields[0].value, "Ada"); + let saved = supervisor.save().unwrap(); + assert!(saved.starts_with(&original) && saved.len() > original.len()); +} diff --git a/src-tauri/pdf/ipc/src/bin/fake-pdf-helper.rs b/src-tauri/pdf/ipc/src/bin/fake-pdf-helper.rs index e3803e11..a22a005c 100644 --- a/src-tauri/pdf/ipc/src/bin/fake-pdf-helper.rs +++ b/src-tauri/pdf/ipc/src/bin/fake-pdf-helper.rs @@ -3,7 +3,13 @@ //! page does: "ok" draws a solid colour, "slow" runs until cancelled, "stuck" //! ignores the cancel, "crash" dies, "reserve" asks for more memory than //! the helper's limits allow, "short" draws fewer pixels than its size says, -//! "huge" a page past `MAX_PIXELS`. `--protocol N` says hello with another +//! "huge" a page past `MAX_PIXELS`. Its form is a text field "name" on page +//! 0 and a checkbox "agree" on page 1; a fill with "中" lacks glyphs, a +//! checkbox's text or a text field's `true` is a bad request (each refusal +//! names the fill that failed), `setFills` answers the pages whose values +//! changed since the last set, and `save` appends the fills to the "PDF" as +//! a line of JSON. Each page's text +//! is the word "page" and its number, over a rule. `--protocol N` says hello with another //! version; `--garbage` answers every request with bytes that aren't a frame //! and then keeps running. @@ -12,13 +18,18 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use blank_pdf_ipc::engine::{Bitmap, CancelFlag, EngineError, PdfDoc, PdfEngine}; -use blank_pdf_ipc::protocol::{ErrorKind, Field, OpenInfo, PageInfo, SigInfo}; +use blank_pdf_ipc::protocol::{ + ErrorKind, Field, FieldFlags, FieldKind, Fill, FillValue, OpenInfo, PageInfo, PageText, Widget, + Word, +}; use blank_pdf_ipc::{frame, limits, serve}; struct Fake; struct FakeDoc { + bytes: Arc<[u8]>, pages: Vec, + fills: Vec, } impl PdfEngine for Fake { @@ -41,7 +52,11 @@ impl PdfEngine for Fake { .iter() .map(|page| page.as_str().unwrap_or("ok").to_string()) .collect(); - Ok(FakeDoc { pages }) + Ok(FakeDoc { + bytes, + pages, + fills: vec![], + }) } } @@ -121,12 +136,92 @@ impl PdfDoc for FakeDoc { }) } + fn text(&mut self, index: usize) -> Result { + Ok(PageText { + words: vec![ + Word { + text: "page".into(), + rect: [1.0, 2.0, 3.0, 1.0], + }, + Word { + text: index.to_string(), + rect: [5.0, 2.0, 1.0, 1.0], + }, + ], + rules: vec![[1.0, 4.0, 8.0, 0.5]], + }) + } + fn fields(&mut self) -> Result, EngineError> { - Ok(vec![]) + let value = + |name: &str, empty: &str| match self.fills.iter().find(|fill| fill.field == name) { + Some(Fill { + value: FillValue::Text(text), + .. + }) => text.clone(), + Some(Fill { + value: FillValue::Checked(true), + .. + }) => "Yes".into(), + _ => empty.into(), + }; + let field = |name: &str, kind, page, value| Field { + name: name.into(), + kind, + label: None, + value, + options: vec![], + flags: FieldFlags::default(), + signed: false, + widgets: vec![Widget { + page, + rect: [1.0, 1.0, 5.0, 2.0], + on_value: None, + }], + }; + Ok(vec![ + field("name", FieldKind::Text, 0, value("name", "")), + field("agree", FieldKind::Checkbox, 1, value("agree", "Off")), + ]) } - fn signatures(&mut self) -> Result, EngineError> { - Ok(vec![]) + fn set_fills(&mut self, fills: &[Fill]) -> Result, EngineError> { + let page = |field: &str| if field == "name" { 0 } else { 1 }; + for (index, fill) in fills.iter().enumerate() { + let refused = |kind| Err(EngineError::of_fill(kind, index)); + match (fill.field.as_str(), &fill.value) { + ("name", FillValue::Text(text)) if text.contains('中') => { + return refused(ErrorKind::MissingGlyphs) + } + ("name", FillValue::Text(_)) | ("agree", FillValue::Checked(_)) => {} + _ => return refused(ErrorKind::BadRequest), + } + } + // as the real one: the pages of the fields whose values changed, + // filled in, changed or no longer filled in + let value = |set: &[Fill], field: &str| { + set.iter() + .find(|fill| fill.field == field) + .map(|fill| fill.value.clone()) + }; + let mut pages: Vec = ["name", "agree"] + .into_iter() + .filter(|field| value(fills, field) != value(&self.fills, field)) + .map(page) + .collect(); + pages.sort_unstable(); + pages.dedup(); + self.fills = fills.to_vec(); + Ok(pages) + } + + fn save_incremental(&mut self) -> Result, EngineError> { + let mut saved = self.bytes.to_vec(); + if !self.fills.is_empty() { + saved.push(b'\n'); + saved.extend(serde_json::to_vec(&self.fills).map_err(|_| ErrorKind::Engine)?); + } + Ok(saved) } } @@ -140,7 +235,7 @@ fn main() { return; } if args.iter().any(|arg| arg == "--garbage") { - let hello = serde_json::json!({"id": 0, "event": "hello", "protocol": blank_pdf_ipc::protocol::PROTOCOL, "engine": "fake", "engineVersion": "1", "ops": []}); + let hello = serde_json::json!({"id": 0, "event": "hello", "protocol": blank_pdf_ipc::protocol::PROTOCOL, "engine": "fake", "engineVersion": "1", "ops": blank_pdf_ipc::protocol::Op::NAMES}); let mut stdout = io::stdout(); frame::write_frame(&mut stdout, &serde_json::to_vec(&hello).unwrap(), &[]).unwrap(); let mut stdin = io::stdin(); diff --git a/src-tauri/pdf/ipc/src/client.rs b/src-tauri/pdf/ipc/src/client.rs index 8057ea8e..5e08ef32 100644 --- a/src-tauri/pdf/ipc/src/client.rs +++ b/src-tauri/pdf/ipc/src/client.rs @@ -5,7 +5,8 @@ //! new helper and opens the PDF again, and the request that failed isn't //! asked again; after the second, the supervisor gives up (`Failed`), and only //! a new one (the tab's "Open again") tries again. A helper stopped for being -//! idle isn't a failure. +//! idle isn't a failure. The supervisor keeps the form's fills (`set_fills`), +//! so a new helper gets them again once it has opened the PDF. use std::collections::VecDeque; use std::io; @@ -19,10 +20,10 @@ use std::time::{Duration, Instant}; use serde::de::DeserializeOwned; use serde_json::Value; -use crate::frame::{read_frame, write_frame, MAX_BODY}; +use crate::frame::{read_frame_by, write_frame, MAX_BODY}; use crate::limits::Backstop; use crate::protocol::{ErrorKind, Hello, Op, OpenInfo, Outcome, Request, Response, PROTOCOL}; -use crate::protocol::{PageInfo, RenderInfo, MAX_PIXELS, PAGES_PER_REQUEST}; +use crate::protocol::{Fill, Filled, PageInfo, RenderInfo, MAX_PIXELS, PAGES_PER_REQUEST}; /// why a request got no answer #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -65,6 +66,10 @@ pub struct Timeouts { pub open: Duration, pub render: Duration, pub read: Duration, + /// `setFills` + pub fill: Duration, + /// `save` + pub save: Duration, /// how long a cancelled request has to answer before the helper is killed pub cancel: Duration, } @@ -76,6 +81,8 @@ impl Default for Timeouts { open: Duration::from_secs(20), render: Duration::from_secs(15), read: Duration::from_secs(15), + fill: Duration::from_secs(10), + save: Duration::from_secs(30), cancel: Duration::from_secs(1), } } @@ -108,6 +115,27 @@ struct Helper { answers: Receiver, next_id: u64, backstop: Backstop, + /// the ops it knows, from its hello + ops: Vec, + /// the request asked last and the most the reader takes of its answer's + /// body: a page's pixels, or for `save` the PDF and a little more; any + /// other answer gets `MAX_ANSWER` + limit: Arc>, + /// the field the last answer refused, if it named one (`setFills`) + refused_field: Option, +} + +/// the request whose answer may be bigger than `MAX_ANSWER`, and how big +#[derive(Clone, Copy)] +struct AnswerLimit { + id: u64, + max: u64, +} + +/// what the reader needs of an answer's header to pick its limit +#[derive(serde::Deserialize)] +struct AnswerId { + id: u64, } /// how many answers the reader holds before it waits for the client: a @@ -146,8 +174,22 @@ impl Helper { return Err(PdfError::Start); }; let (send, answers) = mpsc::sync_channel(QUEUED_ANSWERS); + let limit = Arc::new(Mutex::new(AnswerLimit { + id: 0, + max: MAX_ANSWER, + })); + let reader_limit = limit.clone(); + // the limit is the answer's own, picked once its header is read: by + // then the request it answers was written, with its limit set + let max_body = move |header: &[u8]| { + let wanted = *locked(&reader_limit); + match serde_json::from_slice::(header) { + Ok(answer) if answer.id == wanted.id && wanted.id != 0 => wanted.max, + _ => MAX_ANSWER, + } + }; thread::spawn(move || { - while let Ok(frame) = read_frame(&mut stdout, MAX_ANSWER) { + while let Ok(frame) = read_frame_by(&mut stdout, &max_body) { let Ok(header) = serde_json::from_slice(&frame.header) else { break; }; @@ -163,6 +205,9 @@ impl Helper { answers, next_id: 1, backstop, + ops: vec![], + limit, + refused_field: None, }; helper.greet(config.timeouts.start)?; Ok(helper) @@ -172,7 +217,10 @@ impl Helper { fn greet(&mut self, timeout: Duration) -> Result<(), PdfError> { match self.answers.recv_timeout(timeout) { Ok(Incoming::Frame(header, _)) => match serde_json::from_value::(header) { - Ok(hello) if hello.protocol == PROTOCOL => Ok(()), + Ok(hello) if hello.protocol == PROTOCOL => { + self.ops = hello.ops; + Ok(()) + } _ => { log::warn!("PDF helper: start: another protocol"); self.kill(); @@ -226,20 +274,31 @@ impl Helper { } } - /// asks `op` and waits for its answer at most `timeout`; `sent` learns - /// the request's id once it's written, so another thread can cancel it + /// asks `op` and waits for its answer at most `timeout`, an answer + /// whose body holds at most `max_answer` bytes; `sent` learns the + /// request's id once it's written, so another thread can cancel it fn call( &mut self, op: &Op, body: &[u8], - timeout: Duration, - cancel_timeout: Duration, + max_answer: u64, + timeouts: (Duration, Duration), sent: impl FnOnce(u64, Arc>), ) -> Result<(Value, Vec), PdfError> { + let (timeout, cancel_timeout) = timeouts; + self.refused_field = None; + // an op the helper doesn't know (one newer than it): not asked + if !self.ops.iter().any(|known| known == op.name()) { + return Err(PdfError::Engine(ErrorKind::UnknownOp)); + } let id = self.next_id; self.next_id += 1; let header = serde_json::to_vec(&Request { id, op: op.clone() }).map_err(|_| PdfError::Start)?; + *locked(&self.limit) = AnswerLimit { + id, + max: max_answer, + }; if write_frame(&mut *locked(&self.stdin), &header, body).is_err() { return Err(self.crashed(op)); } @@ -263,7 +322,10 @@ impl Helper { return match response.outcome { Outcome::Ok(value) => Ok((value, body)), Outcome::Error(_) if cancelled_at.is_some() => Err(PdfError::TimedOut), - Outcome::Error(failure) => Err(PdfError::Engine(failure.kind)), + Outcome::Error(failure) => { + self.refused_field = failure.field; + Err(PdfError::Engine(failure.kind)) + } }; } Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => { @@ -306,10 +368,26 @@ struct State { info: Option, /// the PDF while a helper has it open; dropped with the helper bytes: Option>, + /// the form's fills the helper has, which a new helper gets again + fills: Vec, failures: u32, last_used: Instant, } +/// a request that got no answer, and the field it refused, if it named one +/// (`setFills`: the fill that failed) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refused { + pub error: PdfError, + pub field: Option, +} + +impl From for Refused { + fn from(error: PdfError) -> Self { + Refused { error, field: None } + } +} + /// the request that runs: its ticket, its id and where to write a cancel; /// the tickets cancelled before their request was written (while it waited /// for the helper to start, or for the request before it), the last @@ -356,6 +434,7 @@ impl Supervisor { helper: None, info: None, bytes: None, + fills: vec![], failures: 0, last_used: Instant::now(), }), @@ -368,7 +447,6 @@ impl Supervisor { locked(&self.state).failures >= MAX_FAILURES } - /// whether a helper runs, and since when it was last asked something /// whether a helper runs, and since when it was last asked something; /// `None` while a request runs, which it doesn't wait for: it's in use pub fn idle_since(&self) -> Option { @@ -426,45 +504,71 @@ impl Supervisor { } /// asks `op`, starting the helper and opening the PDF in it first if - /// none runs; `ticket` lets `cancel` stop it + /// none runs (and giving it the fills again); `ticket` lets `cancel` + /// stop it. A `setFills` that succeeds replaces the fills kept pub fn request(&self, op: Op, ticket: Option) -> Result<(Value, Vec), PdfError> { + self.request_naming(op, ticket) + .map_err(|refused| refused.error) + } + + /// `request`, with the field a refusal named + fn request_naming(&self, op: Op, ticket: Option) -> Result<(Value, Vec), Refused> { let mut state = locked(&self.state); if state.failures >= MAX_FAILURES { - return Err(PdfError::Failed); + return Err(PdfError::Failed.into()); } state.last_used = Instant::now(); self.ensure_open(&mut state)?; if self.cancelled_ahead(ticket) { - return Err(PdfError::Engine(ErrorKind::Cancelled)); + return Err(PdfError::Engine(ErrorKind::Cancelled).into()); } let timeouts = &self.config.timeouts; let timeout = match op { Op::Open { .. } => timeouts.open, Op::Render { .. } => timeouts.render, + Op::SetFills { .. } => timeouts.fill, + Op::Save => timeouts.save, _ => timeouts.read, }; + // a saved PDF is the one opened and the fills' update + let max_answer = match (&op, &state.bytes) { + (Op::Save, Some(bytes)) => (bytes.len() as u64 + MAX_ANSWER).min(MAX_BODY), + _ => MAX_ANSWER, + }; let helper = state.helper.as_mut().ok_or(PdfError::Start)?; - let result = helper.call(&op, &[], timeout, timeouts.cancel, |id, stdin| { - if let Some(ticket) = ticket { - let mut in_flight = locked(&self.in_flight); - // cancelled between the check above and the write - if let Some(at) = in_flight.cancelled.iter().position(|&each| each == ticket) { - in_flight.cancelled.remove(at); - send_cancel(&stdin, id); + let result = helper.call( + &op, + &[], + max_answer, + (timeout, timeouts.cancel), + |id, stdin| { + if let Some(ticket) = ticket { + let mut in_flight = locked(&self.in_flight); + // cancelled between the check above and the write + if let Some(at) = in_flight.cancelled.iter().position(|&each| each == ticket) { + in_flight.cancelled.remove(at); + send_cancel(&stdin, id); + } + in_flight.last = in_flight.last.max(ticket); + in_flight.running = Some((ticket, id, stdin)); } - in_flight.last = in_flight.last.max(ticket); - in_flight.running = Some((ticket, id, stdin)); - } - }); + }, + ); + let field = helper.refused_field.take(); locked(&self.in_flight).running = None; let result = match (&op, result) { (Op::Render { .. }, Ok((value, pixels))) if !drawn(&value, &pixels) => { log::warn!("PDF helper: render: pixels don't match their size"); Err(PdfError::Engine(ErrorKind::Engine)) } + (Op::SetFills { fills }, Ok(answer)) => { + state.fills = fills.clone(); + Ok(answer) + } (_, result) => result, }; self.settle(&mut state, result) + .map_err(|error| Refused { error, field }) } /// every page's size, asked `PAGES_PER_REQUEST` at a time so that no @@ -483,6 +587,26 @@ impl Supervisor { Ok(pages) } + /// fills the form in with `fills`, the whole set (see `Op::SetFills`), + /// and keeps them for a new helper; gives the pages to draw again. A + /// refusal names the field whose fill it refused, when the helper says + pub fn set_fills(&self, fills: Vec) -> Result, Refused> { + let (value, _) = self.request_naming(Op::SetFills { fills }, None)?; + let filled: Filled = + serde_json::from_value(value).map_err(|_| PdfError::Engine(ErrorKind::Engine))?; + Ok(filled.pages) + } + + /// the fills the helper has + pub fn fills(&self) -> Vec { + locked(&self.state).fills.clone() + } + + /// the PDF with the fills as an incremental update (see `Op::Save`) + pub fn save(&self) -> Result, PdfError> { + self.request(Op::Save, None).map(|(_, bytes)| bytes) + } + /// asks `op` and reads its answer as `T` pub fn ask(&self, op: Op) -> Result { let (value, _) = self.request(op, None)?; @@ -514,16 +638,29 @@ impl Supervisor { return Err(PdfError::Engine(ErrorKind::TooComplex)); } let mut helper = Helper::start(&self.config)?; + let timeouts = &self.config.timeouts; let opened = helper.call( &Op::Open { password: None }, &bytes, - self.config.timeouts.open, - self.config.timeouts.cancel, + MAX_ANSWER, + (timeouts.open, timeouts.cancel), |_, _| {}, ); let (value, _) = self.settle(state, opened)?; let info: OpenInfo = serde_json::from_value(value).map_err(|_| PdfError::Engine(ErrorKind::Engine))?; + if !state.fills.is_empty() { + let refill = helper.call( + &Op::SetFills { + fills: state.fills.clone(), + }, + &[], + MAX_ANSWER, + (timeouts.fill, timeouts.cancel), + |_, _| {}, + ); + self.settle(state, refill)?; + } state.helper = Some(helper); state.bytes = Some(bytes); state.info = Some(info.clone()); diff --git a/src-tauri/pdf/ipc/src/engine.rs b/src-tauri/pdf/ipc/src/engine.rs index d8654595..42940ffb 100644 --- a/src-tauri/pdf/ipc/src/engine.rs +++ b/src-tauri/pdf/ipc/src/engine.rs @@ -4,16 +4,28 @@ use std::sync::{Arc, Mutex, PoisonError}; -use crate::protocol::{ErrorKind, Field, OpenInfo, PageInfo, SigInfo}; +use crate::protocol::{ErrorKind, Field, Fill, OpenInfo, PageInfo, PageText}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct EngineError { pub kind: ErrorKind, + /// `set_fills`: the index of the fill that failed, when one did + pub fill: Option, +} + +impl EngineError { + /// the fill at `index` failed with `kind` + pub fn of_fill(kind: ErrorKind, index: usize) -> Self { + EngineError { + kind, + fill: Some(index), + } + } } impl From for EngineError { fn from(kind: ErrorKind) -> Self { - EngineError { kind } + EngineError { kind, fill: None } } } @@ -41,9 +53,25 @@ pub trait PdfDoc { scale: f32, cancel: &CancelFlag, ) -> Result; + /// a page's words and thin horizontal lines, in display points + fn text(&mut self, _index: usize) -> Result { + Err(ErrorKind::UnknownOp.into()) + } /// every field of the form, with full names and what they inherit fn fields(&mut self) -> Result, EngineError>; - fn signatures(&mut self) -> Result, EngineError>; + /// fills the form in with `fills`, the whole set, on the PDF as it was + /// opened; what's drawn and saved from then on is the filled PDF. Gives + /// the pages whose fields changed since the last set. On a failure the + /// last set stays, and the error says which fill failed when one did + /// (`EngineError::fill`) + fn set_fills(&mut self, _fills: &[Fill]) -> Result, EngineError> { + Err(ErrorKind::UnknownOp.into()) + } + /// the PDF opened, byte for byte, with the fills appended as an + /// incremental update (none without fills) + fn save_incremental(&mut self) -> Result, EngineError> { + Err(ErrorKind::UnknownOp.into()) + } } type Hook = Box; diff --git a/src-tauri/pdf/ipc/src/frame.rs b/src-tauri/pdf/ipc/src/frame.rs index 4bea029d..3b64b86f 100644 --- a/src-tauri/pdf/ipc/src/frame.rs +++ b/src-tauri/pdf/ipc/src/frame.rs @@ -55,6 +55,16 @@ pub fn write_frame(output: &mut impl Write, header: &[u8], body: &[u8]) -> io::R /// reads one frame whose body holds at most `max_body` bytes pub fn read_frame(input: &mut impl Read, max_body: u64) -> Result { + read_frame_by(input, |_| max_body) +} + +/// reads one frame whose body holds at most what `max_body` says for its +/// header, which is read first: the most an answer may hold depends on +/// the request it answers +pub fn read_frame_by( + input: &mut impl Read, + max_body: impl FnOnce(&[u8]) -> u64, +) -> Result { let mut prefix = [0u8; 16]; loop { match input.read(&mut prefix[..1]) { @@ -73,11 +83,14 @@ pub fn read_frame(input: &mut impl Read, max_body: u64) -> Result MAX_HEADER { return Err(FrameError::Bad("header too long")); } - if body_len > max_body.min(MAX_BODY) { + if body_len > MAX_BODY { return Err(FrameError::Bad("body too long")); } let mut header = vec![0; header_len]; input.read_exact(&mut header)?; + if body_len > max_body(&header).min(MAX_BODY) { + return Err(FrameError::Bad("body too long")); + } let mut body = vec![0; body_len as usize]; input.read_exact(&mut body)?; Ok(Frame { header, body }) diff --git a/src-tauri/pdf/ipc/src/protocol.rs b/src-tauri/pdf/ipc/src/protocol.rs index 3406ec72..cc335f5e 100644 --- a/src-tauri/pdf/ipc/src/protocol.rs +++ b/src-tauri/pdf/ipc/src/protocol.rs @@ -8,10 +8,11 @@ //! //! Within version 1 a new op is optional: a helper that doesn't know it //! answers `unknownOp`. The hello lists the ops the helper knows -//! (`Hello::ops`); the app doesn't read that list yet, since every op it -//! asks is in version 1 from the start. A breaking change raises `PROTOCOL` and the -//! frame's magic. Positions and sizes are display points: from the top left -//! of the page's crop box after its `/Rotate`, with its UserUnit applied. +//! (`Hello::ops`), and the client asks none other (`text`, `setFills` and +//! `save` came after the first ops). A breaking change raises `PROTOCOL` +//! and the frame's magic. Positions and sizes are display points: from the +//! top left of the page's crop box after its `/Rotate`, with its UserUnit +//! applied. use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -44,13 +45,24 @@ pub enum Op { page: usize, scale: f32, }, - /// the form's fields. Not asked by the app yet: it is there for the - /// PDF forms of the next step (filling them in), see .claude/rules/pdf.md + /// the words and drawn lines of a page (`PageText`), for the places to + /// sign or fill in on a PDF without a form + Text { + page: usize, + }, + /// the form's fields (`Field`), with the values the fills gave them Fields, - /// what the signatures say of themselves. Not asked by the app yet: the - /// signature check reads the PDF itself (signing/verify.rs); this is for - /// placing signature fields in the next step, see .claude/rules/pdf.md - Signatures, + /// fills in the form: always the whole set, on the PDF as it was opened + /// (what an earlier `setFills` did is undone first); the answer is + /// `Filled`, the pages to draw again. A value the field can't take, or + /// a field that isn't there, fails the whole set and keeps the last one; + /// the failure names that fill's field (`Failure::field`) + SetFills { + fills: Vec, + }, + /// the PDF with the fills as an incremental update: the bytes opened, + /// unchanged, then the update; refused for a repaired or encrypted PDF + Save, /// stops request `target`, which then answers `cancelled`; no answer of /// its own Cancel { @@ -61,14 +73,8 @@ pub enum Op { impl Op { /// every op's name, as the helper says in its hello - pub const NAMES: [&'static str; 7] = [ - "open", - "pages", - "render", - "fields", - "signatures", - "cancel", - "ping", + pub const NAMES: [&'static str; 9] = [ + "open", "pages", "render", "text", "fields", "setFills", "save", "cancel", "ping", ]; /// its name, as in the request and in the log @@ -77,8 +83,10 @@ impl Op { Op::Open { .. } => "open", Op::Pages { .. } => "pages", Op::Render { .. } => "render", + Op::Text { .. } => "text", Op::Fields => "fields", - Op::Signatures => "signatures", + Op::SetFills { .. } => "setFills", + Op::Save => "save", Op::Cancel { .. } => "cancel", Op::Ping => "ping", } @@ -106,6 +114,13 @@ pub enum ErrorKind { Cancelled, /// anything else the engine threw Engine, + /// `save`: the engine had to repair the PDF to read it, so an update + /// can't be appended to it + Repaired, + /// `save`: the PDF is encrypted + Encrypted, + /// `setFills`: a value has characters the field's font can't show + MissingGlyphs, } impl ErrorKind { @@ -119,6 +134,9 @@ impl ErrorKind { ErrorKind::TooComplex => "tooComplex", ErrorKind::Cancelled => "cancelled", ErrorKind::Engine => "engine", + ErrorKind::Repaired => "repaired", + ErrorKind::Encrypted => "encrypted", + ErrorKind::MissingGlyphs => "missingGlyphs", } } } @@ -126,6 +144,15 @@ impl ErrorKind { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Failure { pub kind: ErrorKind, + /// `setFills`: the field of the fill that failed, when one did + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field: Option, +} + +impl From for Failure { + fn from(kind: ErrorKind) -> Self { + Failure { kind, field: None } + } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -235,21 +262,45 @@ pub struct Field { pub widgets: Vec, } -/// a signature in the PDF: where it is and what it says of itself; whether -/// it is valid is for the app's verify.rs to say +/// a word on a page, as the engine reads the page's text #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SigInfo { +pub struct Word { + pub text: String, + pub rect: Rect, +} + +/// what a page says and the lines drawn on it: its words in reading order, +/// and its thin horizontal lines (at least 40 pt wide, at most 2 pt high, +/// stroked or filled, not white: `RULE_*` in the helper's shim), where +/// people sign or write +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct PageText { + pub words: Vec, + pub rules: Vec, +} + +/// what a field is filled in with: a checkbox's `true` or `false`; else +/// text: a text field's, a choice's (one of its options, or any text for a +/// combo box that may be edited), a radio button's state when on (its +/// widget's `onValue`, or "Off" for none) +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum FillValue { + Checked(bool), + Text(String), +} + +/// a field (by its full name) and what it is filled in with +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Fill { pub field: String, - /// its first widget's page and box, if it shows on a page - pub page: Option, - pub rect: Option, - pub sub_filter: Option, - pub reason: Option, - pub location: Option, - pub name: Option, - /// its `/M`, as written - pub time: Option, + pub value: FillValue, +} + +/// what `setFills` changed: the pages whose fields look different now +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct Filled { + pub pages: Vec, } #[cfg(test)] @@ -274,6 +325,30 @@ mod tests { assert_eq!(serde_json::from_value::(written).unwrap(), request); let fields: Request = serde_json::from_value(json!({"id": 4, "op": "fields"})).unwrap(); assert_eq!(fields.op, Op::Fields); + let fills = Request { + id: 6, + op: Op::SetFills { + fills: vec![ + Fill { + field: "agree".into(), + value: FillValue::Checked(true), + }, + Fill { + field: "name".into(), + value: FillValue::Text("Ada".into()), + }, + ], + }, + }; + let written = serde_json::to_value(&fills).unwrap(); + assert_eq!( + written, + json!({"id": 6, "op": "setFills", "fills": [ + {"field": "agree", "value": true}, + {"field": "name", "value": "Ada"} + ]}) + ); + assert_eq!(serde_json::from_value::(written).unwrap(), fills); let ok = Response { id: 3, @@ -285,11 +360,18 @@ mod tests { ); let error = json!({"id": 5, "error": {"kind": "tooComplex"}}); let read: Response = serde_json::from_value(error).unwrap(); + assert_eq!(read.outcome, Outcome::Error(ErrorKind::TooComplex.into())); + // a refused fill names its field + let refused = Response { + id: 7, + outcome: Outcome::Error(Failure { + kind: ErrorKind::MissingGlyphs, + field: Some("name".into()), + }), + }; assert_eq!( - read.outcome, - Outcome::Error(Failure { - kind: ErrorKind::TooComplex - }) + serde_json::to_value(&refused).unwrap(), + json!({"id": 7, "error": {"kind": "missingGlyphs", "field": "name"}}) ); } @@ -307,6 +389,11 @@ mod tests { request["page"] = json!(0); request["scale"] = json!(1); } + "text" => request["page"] = json!(0), + "setFills" => { + request["fills"] = + json!([{"field": "a", "value": true}, {"field": "b", "value": "x"}]) + } "cancel" => request["target"] = json!(1), _ => {} } @@ -321,6 +408,9 @@ mod tests { ErrorKind::TooComplex, ErrorKind::Cancelled, ErrorKind::Engine, + ErrorKind::Repaired, + ErrorKind::Encrypted, + ErrorKind::MissingGlyphs, ] { assert_eq!(serde_json::to_value(kind).unwrap(), kind.name()); } diff --git a/src-tauri/pdf/ipc/src/serve.rs b/src-tauri/pdf/ipc/src/serve.rs index fedff86d..38c403ea 100644 --- a/src-tauri/pdf/ipc/src/serve.rs +++ b/src-tauri/pdf/ipc/src/serve.rs @@ -78,25 +78,23 @@ pub fn serve( running.current = Some((id, flag.clone())); } let answer = if flag.is_cancelled() { - Err(ErrorKind::Cancelled) + Err(ErrorKind::Cancelled.into()) } else { match parse(header) { Ok(op) => answer(engine, &mut doc, op, body, &flag), - Err(kind) => Err(kind), + Err(kind) => Err(kind.into()), } }; lock(&running).current = None; let (outcome, body) = match answer { Ok((value, body)) => (Outcome::Ok(value), body), - Err(kind) => (Outcome::Error(Failure { kind }), vec![]), + Err(failure) => (Outcome::Error(failure), vec![]), }; let mut header = serde_json::to_vec(&Response { id, outcome })?; let mut body = body; // an answer too big for a frame: the request asked too much if header.len() > MAX_HEADER || body.len() as u64 > MAX_BODY { - let outcome = Outcome::Error(Failure { - kind: ErrorKind::TooComplex, - }); + let outcome = Outcome::Error(ErrorKind::TooComplex.into()); header = serde_json::to_vec(&Response { id, outcome })?; body = vec![]; } @@ -145,12 +143,12 @@ fn parse(header: Value) -> Result { .map_err(|_| ErrorKind::BadRequest) } -type Answer = Result<(Value, Vec), ErrorKind>; +type Answer = Result<(Value, Vec), Failure>; fn json(value: impl serde::Serialize) -> Answer { serde_json::to_value(value) .map(|value| (value, vec![])) - .map_err(|_| ErrorKind::Engine) + .map_err(|_| ErrorKind::Engine.into()) } fn answer( @@ -160,7 +158,7 @@ fn answer( body: Vec, cancel: &CancelFlag, ) -> Answer { - let failed = |error: EngineError| error.kind; + let failed = |error: EngineError| Failure::from(error.kind); if let Op::Open { password } = &op { *doc = None; let opened = engine @@ -173,7 +171,7 @@ fn answer( if op == Op::Ping { return json(serde_json::json!({})); } - let doc = doc.as_mut().ok_or(ErrorKind::BadRequest)?; + let doc = doc.as_mut().ok_or(Failure::from(ErrorKind::BadRequest))?; match op { Op::Pages { from, to } => { let to = to.min(doc.info().pages); @@ -185,15 +183,15 @@ fn answer( } Op::Render { page, scale } => { if !(scale.is_finite() && scale > 0.0) || page >= doc.info().pages { - return Err(ErrorKind::BadRequest); + return Err(ErrorKind::BadRequest.into()); } let bitmap = doc.render(page, scale, cancel).map_err(failed)?; let count = bitmap.width as u64 * bitmap.height as u64; if count > MAX_PIXELS { - return Err(ErrorKind::TooComplex); + return Err(ErrorKind::TooComplex.into()); } if bitmap.rgba.len() as u64 != count * 4 { - return Err(ErrorKind::Engine); + return Err(ErrorKind::Engine.into()); } let (value, _) = json(crate::protocol::RenderInfo { width: bitmap.width, @@ -201,8 +199,35 @@ fn answer( })?; Ok((value, bitmap.rgba)) } + Op::Text { page } => { + if page >= doc.info().pages { + return Err(ErrorKind::BadRequest.into()); + } + json(doc.text(page).map_err(failed)?) + } Op::Fields => json(doc.fields().map_err(failed)?), - Op::Signatures => json(doc.signatures().map_err(failed)?), - Op::Open { .. } | Op::Ping | Op::Cancel { .. } => Err(ErrorKind::BadRequest), + Op::SetFills { fills } => { + // a refused fill is named by its field + let pages = doc.set_fills(&fills).map_err(|error| Failure { + kind: error.kind, + field: error + .fill + .and_then(|index| fills.get(index)) + .map(|fill| fill.field.clone()), + })?; + json(crate::protocol::Filled { pages }) + } + Op::Save => { + let info = doc.info(); + if info.repaired { + return Err(ErrorKind::Repaired.into()); + } + if info.encrypted { + return Err(ErrorKind::Encrypted.into()); + } + let bytes = doc.save_incremental().map_err(failed)?; + Ok((serde_json::json!({}), bytes)) + } + Op::Open { .. } | Op::Ping | Op::Cancel { .. } => Err(ErrorKind::BadRequest.into()), } } diff --git a/src-tauri/pdf/ipc/tests/client.rs b/src-tauri/pdf/ipc/tests/client.rs index 7f30e340..f038fdf8 100644 --- a/src-tauri/pdf/ipc/tests/client.rs +++ b/src-tauri/pdf/ipc/tests/client.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use std::time::{Duration, Instant}; -use blank_pdf_ipc::client::{HelperConfig, PdfError, Supervisor, Timeouts}; -use blank_pdf_ipc::protocol::{ErrorKind, Op, PageInfo}; +use blank_pdf_ipc::client::{HelperConfig, PdfError, Refused, Supervisor, Timeouts}; +use blank_pdf_ipc::protocol::{ErrorKind, Field, Fill, FillValue, Op, PageInfo, PageText}; fn config(args: &[&str]) -> HelperConfig { HelperConfig { @@ -319,3 +319,98 @@ fn refuses_a_pdf_bigger_than_a_frame() { ); assert!(!pdf.failed()); } + +fn fill(field: &str, value: FillValue) -> Fill { + Fill { + field: field.into(), + value, + } +} + +#[test] +fn keeps_the_fills_for_a_new_helper() { + let pdf = supervisor(&["ok", "crash"]); + let fills = vec![ + fill("name", FillValue::Text("Ada".into())), + fill("agree", FillValue::Checked(true)), + ]; + assert_eq!(pdf.set_fills(fills.clone()).unwrap(), [0, 1]); + assert!( + pdf.set_fills(fills.clone()).unwrap().is_empty(), + "nothing changed" + ); + // a refusal names the field whose fill failed + assert_eq!( + pdf.set_fills(vec![ + fill("agree", FillValue::Checked(false)), + fill("name", FillValue::Text("中".into())) + ]) + .unwrap_err(), + Refused { + error: PdfError::Engine(ErrorKind::MissingGlyphs), + field: Some("name".into()) + } + ); + assert_eq!( + pdf.set_fills(vec![fill("agree", FillValue::Text("Yes".into()))]) + .unwrap_err(), + Refused { + error: PdfError::Engine(ErrorKind::BadRequest), + field: Some("agree".into()) + } + ); + assert_eq!(pdf.fills(), fills, "a refused set keeps the last"); + assert_eq!( + pdf.set_fills(vec![fill("name", FillValue::Text("Ada".into()))]) + .unwrap(), + [1], + "only the checkbox changed" + ); + assert_eq!(pdf.set_fills(fills.clone()).unwrap(), [1]); + + assert_eq!(render(&pdf, 1).unwrap_err(), PdfError::Crashed); + // the new helper has them again + let fields: Vec = pdf.ask(Op::Fields).unwrap(); + let values: Vec<&str> = fields.iter().map(|field| field.value.as_str()).collect(); + assert_eq!(values, ["Ada", "Yes"]); + let saved = pdf.save().unwrap(); + let opened = serde_json::to_vec(&serde_json::json!({ "pages": ["ok", "crash"] })).unwrap(); + assert!(saved.starts_with(&opened) && saved.len() > opened.len()); + + assert_eq!(pdf.set_fills(vec![]).unwrap(), [0, 1], "both changed back"); + assert_eq!(pdf.save().unwrap(), opened); +} + +#[test] +fn reads_a_pages_text() { + let pdf = supervisor(&["ok", "ok"]); + let text: PageText = pdf.ask(Op::Text { page: 1 }).unwrap(); + let words: Vec<&str> = text.words.iter().map(|word| word.text.as_str()).collect(); + assert_eq!(words, ["page", "1"]); + assert_eq!(text.rules, [[1.0, 4.0, 8.0, 0.5]]); + assert_eq!( + pdf.ask::(Op::Text { page: 2 }).unwrap_err(), + PdfError::Engine(ErrorKind::BadRequest) + ); +} + +#[test] +fn takes_a_saved_pdf_bigger_than_a_pages_pixels() { + // past MAX_PIXELS × 4, the most of any other answer: save's own limit + // must hold for its answer, not the next one + let pad = "x".repeat(70 << 20); + let pdf: Arc<[u8]> = serde_json::to_vec(&serde_json::json!({ "pages": ["ok"], "pad": pad })) + .unwrap() + .into(); + let opened = pdf.clone(); + let supervisor = Supervisor::new(config(&[]), Box::new(move || Ok(pdf.clone()))); + supervisor + .set_fills(vec![fill("name", FillValue::Text("Ada".into()))]) + .unwrap(); + let saved = supervisor.save().unwrap(); + assert!(saved.starts_with(&opened) && saved.len() > opened.len()); + assert_eq!(supervisor.save().unwrap(), saved, "and again"); + assert!(!supervisor.failed()); + // a render after it is held to a page's pixels again + assert!(render(&supervisor, 0).is_ok()); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d2ae72d..d101c531 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,6 +105,9 @@ pub fn run() { pdf_view::commands::pdf_close, pdf_view::commands::pdf_verify, pdf_view::commands::pdf_print, + pdf_view::commands::pdf_text, + pdf_view::commands::pdf_fields, + pdf_view::commands::pdf_set_fills, signing::commands::signing_certificate, signing::commands::signing_create_certificate, signing::commands::signing_import, @@ -112,6 +115,7 @@ pub fn run() { signing::commands::signing_remove, signing::commands::signing_check_passphrase, signing::commands::signing_sign_pdf, + signing::commands::signing_pdf_rules, signing::commands::signing_trusted, signing::commands::signing_trust, signing::commands::signing_untrust, diff --git a/src-tauri/src/pdf_view/commands.rs b/src-tauri/src/pdf_view/commands.rs index 552fa810..0ea46ce6 100644 --- a/src-tauri/src/pdf_view/commands.rs +++ b/src-tauri/src/pdf_view/commands.rs @@ -2,7 +2,8 @@ //! a kind (`PdfError::kind`, "unreadable" for a file that can't be read, or //! for a file read again "missing" when it has gone and "changed" when it //! isn't the one opened) and the log as the same kind with what was asked: -//! never the PDF's text. The path of a PDF opened is logged at info. +//! never the PDF's text, nor its words or the values of its fields. The +//! path of a PDF opened is logged at info. use std::io; use std::path::{Path, PathBuf}; @@ -12,11 +13,11 @@ use std::time::Instant; use crate::print::{self, Meta, PrintError, PrintSetup, Sent}; use blank_pdf_ipc::client::{PdfError, Supervisor}; use blank_pdf_ipc::frame::MAX_BODY; -use blank_pdf_ipc::protocol::{ErrorKind, Op, PageInfo}; +use blank_pdf_ipc::protocol::{ErrorKind, Field, Fill, Op, PageInfo, PageText}; use serde::Serialize; use tauri::{AppHandle, State}; -use super::{helper_config, read_checked, sha256, source, PdfTab, PdfTabs}; +use super::{helper_config, read_checked, read_for_signing, sha256, source, PdfTab, PdfTabs}; use crate::signing::commands::{checked_signatures, SigningError}; use crate::signing::trust::CheckedSignature; @@ -49,6 +50,19 @@ fn file_problem(path: &Path) -> &'static str { } } +/// the kind of a helper's failure, logged with `context`: for a helper that +/// stopped while idle and can't open the file again, why (`file_problem`) +fn helper_failed(context: &'static str, path: PathBuf) -> impl Fn(PdfError) -> String { + move |error| match error { + PdfError::Source => { + let problem = file_problem(&path); + log::warn!("PDF: {context}: {problem}"); + problem.to_string() + } + error => failed(context)(error), + } +} + /// what reading a PDF tab's file again found, by `read_checked`'s error fn read_problem(error: &io::Error) -> &'static str { match error.kind() { @@ -166,6 +180,71 @@ pub async fn pdf_render( Ok(tauri::ipc::Response::new(pixels)) } +/// the words and thin horizontal lines of page `page` (from 0) of tab +/// `tab`, in display points +#[tauri::command] +pub async fn pdf_text( + tabs: State<'_, PdfTabs>, + tab: String, + page: usize, +) -> Result { + let pdf = tabs.get(&tab).ok_or("closed")?; + tabs.stop_idle(Some(&tab), Instant::now()); + blocking(move || { + pdf.supervisor + .ask(Op::Text { page }) + .map_err(helper_failed("text", pdf.path.clone())) + }) + .await +} + +/// the form's fields of tab `tab`'s PDF, with the values its fills gave +#[tauri::command] +pub async fn pdf_fields(tabs: State<'_, PdfTabs>, tab: String) -> Result, String> { + let pdf = tabs.get(&tab).ok_or("closed")?; + tabs.stop_idle(Some(&tab), Instant::now()); + blocking(move || { + pdf.supervisor + .ask(Op::Fields) + .map_err(helper_failed("fields", pdf.path.clone())) + }) + .await +} + +/// why `pdf_set_fills` failed: the kind, as the other commands fail with, +/// and for a fill the helper refused, the full name of its field +#[derive(Debug, PartialEq, Serialize)] +pub struct FillFailure { + pub kind: String, + pub field: Option, +} + +/// fills the form of tab `tab`'s PDF in with `fills`, always the whole set, +/// which its helper keeps (a new helper gets them again); gives the pages +/// to draw again. A value refused ("missingGlyphs", "badRequest") leaves +/// the last set as it was, and names the field it was for +#[tauri::command] +pub async fn pdf_set_fills( + tabs: State<'_, PdfTabs>, + tab: String, + fills: Vec, +) -> Result, FillFailure> { + let failure = |kind: String| FillFailure { kind, field: None }; + let pdf = tabs.get(&tab).ok_or_else(|| failure("closed".into()))?; + tabs.stop_idle(Some(&tab), Instant::now()); + tauri::async_runtime::spawn_blocking(move || { + // the field's name never reaches the log + pdf.supervisor + .set_fills(fills) + .map_err(|refused| FillFailure { + kind: helper_failed("fill in", pdf.path.clone())(refused.error), + field: refused.field, + }) + }) + .await + .unwrap_or_else(|_| Err(failure("failed".into()))) +} + /// stops the render of `ticket` in tab `tab`, if it runs #[tauri::command] pub fn pdf_cancel(tabs: State<'_, PdfTabs>, tab: String, ticket: u64) { @@ -192,15 +271,7 @@ pub async fn pdf_verify( .get(&tab) .ok_or_else(|| SigningError::Failed("This PDF isn't open any more".into()))?; tauri::async_runtime::spawn_blocking(move || { - let bytes = read_checked(&pdf.path, &pdf.sha256).map_err(|error| { - let problem = read_problem(&error); - log::warn!("PDF: read to check its signatures: {problem}"); - match problem { - "changed" => SigningError::FileChanged, - "missing" => SigningError::FileMissing, - _ => SigningError::Failed("Blank can't read this PDF again".into()), - } - })?; + let bytes = read_for_signing(&pdf.path, &pdf.sha256, "to check its signatures")?; checked_signatures(&app, &bytes) }) .await diff --git a/src-tauri/src/pdf_view/mod.rs b/src-tauri/src/pdf_view/mod.rs index 14236fc8..7a293a49 100644 --- a/src-tauri/src/pdf_view/mod.rs +++ b/src-tauri/src/pdf_view/mod.rs @@ -12,9 +12,12 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; -use blank_pdf_ipc::client::{HelperConfig, Source, Supervisor, Timeouts}; +use blank_pdf_ipc::client::{HelperConfig, PdfError, Source, Supervisor, Timeouts}; +use blank_pdf_ipc::protocol::{ErrorKind, Fill}; use sha2::{Digest, Sha256}; +use crate::signing::commands::SigningError; + /// a helper unused this long is stopped, which isn't a failure const IDLE: Duration = Duration::from_secs(120); /// the most helpers that run at once; the least recently used stops @@ -123,6 +126,25 @@ pub fn read_checked(path: &Path, expected: &str) -> io::Result> { Ok(bytes.into()) } +/// reads `path` for signing while it still holds the PDF of `sha256`: +/// `FileChanged` when it holds another, `FileMissing` when it has gone; +/// `context` says what for in the log +pub fn read_for_signing( + path: &Path, + sha256: &str, + context: &str, +) -> Result, SigningError> { + read_checked(path, sha256).map_err(|error| { + let problem = match error.kind() { + io::ErrorKind::InvalidData => SigningError::FileChanged, + io::ErrorKind::NotFound => SigningError::FileMissing, + _ => SigningError::Failed("Blank can't read this PDF again".into()), + }; + log::warn!("PDF: read {context}: {}", error.kind()); + problem + }) +} + /// the PDF's bytes for its helper: `first` for the first helper, which /// were just read, then the file again, while it is the same pub fn source(path: PathBuf, sha256: String, first: Arc<[u8]>) -> Source { @@ -135,6 +157,65 @@ pub fn source(path: PathBuf, sha256: String, first: Arc<[u8]>) -> Source { ) } +/// the bytes to sign of tab `tab`'s PDF, the file at `path`, while it still +/// holds the PDF of `sha256` (else `FileChanged` or `FileMissing`): the file +/// as it is without `fills`, else the file with the fills appended as an +/// incremental update by the tab's helper (MuPDF writes R1, the signer then +/// appends R2). Waits for the helper: call it on a blocking thread +pub fn filled_bytes( + tabs: &PdfTabs, + tab: &str, + path: &Path, + sha256: &str, + fills: &[Fill], +) -> Result, SigningError> { + let bytes = read_for_signing(path, sha256, "to fill in")?; + if fills.is_empty() { + return Ok(bytes.to_vec()); + } + let pdf = tabs + .get(tab) + .ok_or_else(|| SigningError::Failed("This PDF isn't open any more".into()))?; + // the tab opened another version of the file meanwhile + if pdf.sha256 != sha256 { + return Err(SigningError::FileChanged); + } + let saved = (|| { + if pdf.supervisor.fills() != fills { + pdf.supervisor + .set_fills(fills.to_vec()) + .map_err(|refused| refused.error)?; + } + pdf.supervisor.save() + })() + .map_err(|error| { + log::warn!("PDF: save the fills: {}", error.kind()); + SigningError::Failed(fill_refusal(error).into()) + })?; + // what MuPDF wrote must leave the file as it is, and only add to it + if !saved.starts_with(&bytes) { + log::warn!("PDF: save the fills: not an update of the file"); + return Err(SigningError::Failed( + fill_refusal(PdfError::Engine(ErrorKind::Engine)).into(), + )); + } + Ok(saved) +} + +/// why the fills couldn't be saved, in words for the user +fn fill_refusal(error: PdfError) -> &'static str { + match error { + PdfError::Engine(ErrorKind::Repaired) => { + "Blank can't fill in this PDF: it's damaged, and only a repaired copy could be read" + } + PdfError::Engine(ErrorKind::Encrypted) => "Blank can't fill in an encrypted PDF", + PdfError::Engine(ErrorKind::MissingGlyphs) => { + "A field's font can't show some of the characters filled in" + } + _ => "Blank couldn't fill in this PDF", + } +} + /// where the helper is: on Linux in `../lib/blank` beside the app's /// directory, where the packages put it (`bundle.resources` of /// `tauri.linux.conf.json`: `/usr/lib/blank` of the .deb, the .rpm and the @@ -230,6 +311,80 @@ mod tests { assert_eq!(to_stop(&many, Some("f"), now), ["a"]); } + /// the helper `make pdf-helper` built, which the app's build needs + fn built_helper() -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries"); + std::fs::read_dir(&dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + let name = path.file_name().unwrap().to_string_lossy(); + name.starts_with("blank-pdf") && !name.ends_with(".stamp") + }) + .expect("make pdf-helper") + } + + #[test] + fn gives_the_signer_the_file_with_its_fills_as_an_update() { + use blank_pdf_ipc::protocol::FillValue; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("form.pdf"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("pdf/fixtures/form.pdf"); + std::fs::copy(fixture, &path).unwrap(); + let original: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let sha = sha256(&original); + let config = HelperConfig { + program: built_helper(), + args: vec!["--memory-limit-mb".into(), "256".into()], + quiet: true, + timeouts: Timeouts::default(), + }; + let supervisor = + Supervisor::new(config, source(path.clone(), sha.clone(), original.clone())); + let tabs = PdfTabs::default(); + tabs.insert( + "t".into(), + Arc::new(PdfTab { + supervisor: Arc::new(supervisor), + path: path.clone(), + sha256: sha.clone(), + }), + ); + let fills = [Fill { + field: "name".into(), + value: FillValue::Text("Ada".into()), + }]; + + let plain = filled_bytes(&tabs, "t", &path, &sha, &[]).unwrap(); + assert_eq!(plain, &original[..], "no fills: the file as it is"); + let filled = filled_bytes(&tabs, "t", &path, &sha, &fills).unwrap(); + assert!(filled.starts_with(&original) && filled.len() > original.len()); + assert_eq!(tabs.get("t").unwrap().supervisor.fills(), fills); + assert!(matches!( + filled_bytes(&tabs, "other", &path, &sha, &fills), + Err(SigningError::Failed(_)) + )); + let refused = [Fill { + field: "name".into(), + value: FillValue::Text("中文".into()), + }]; + assert!(matches!( + filled_bytes(&tabs, "t", &path, &sha, &refused), + Err(SigningError::Failed(message)) if message.contains("font") + )); + + std::fs::write(&path, b"%PDF-1.7 another").unwrap(); + assert!(matches!( + filled_bytes(&tabs, "t", &path, &sha, &fills), + Err(SigningError::FileChanged) + )); + std::fs::remove_file(&path).unwrap(); + assert!(matches!( + filled_bytes(&tabs, "t", &path, &sha, &[]), + Err(SigningError::FileMissing) + )); + } + #[test] fn reads_the_file_again_only_while_it_is_the_same() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/signing/commands.rs b/src-tauri/src/signing/commands.rs index f2b522c1..551fd717 100644 --- a/src-tauri/src/signing/commands.rs +++ b/src-tauri/src/signing/commands.rs @@ -12,11 +12,15 @@ use serde::{Deserialize, Deserializer, Serialize}; use tauri::{AppHandle, Manager}; use zeroize::Zeroizing; +use blank_pdf_ipc::protocol::Fill; + +use crate::pdf_view::{filled_bytes, read_for_signing, PdfTabs}; use crate::signing::certificate::CertificateInfo; use crate::signing::keys::{self, Identity, OpenError}; -use crate::signing::pdf::sign::{sign_pdf, SignRequest}; +use crate::signing::pdf::load_for_update; +use crate::signing::pdf::sign::{update_pdf, Other, PlacedStamp, Plan as UpdatePlan, Signer, Spot}; use crate::signing::trust::{self, CheckedSignature}; -use crate::signing::verify::verify_pdf; +use crate::signing::verify::{verify_doc, verify_pdf, SignatureReport}; use crate::signing::Refusal; /// the folder of the user's signature, initials, key and certificate @@ -369,46 +373,117 @@ pub async fn signing_check_passphrase(app: AppHandle, passphrase: String) -> Ans .map_err(|_| SigningError::Failed("Blank can't open your key".into()))? } -/// how to sign, besides the PDF and the passphrase +/// a place on a page: x, y, width and height in points from the top left +/// of the page as shown +type Rect = [f64; 4]; + +/// a stamp to put on the pages +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StampPlan { + /// the body's part with its one-page PDF + pub part: usize, + /// from 1 + pub page: u32, + pub rect: Rect, + /// on every page, as far from the nearer edges as on `page` (see + /// `stamp::repeat_into`) + #[serde(default)] + pub repeat: bool, +} + +/// where the signature goes +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SignaturePlan { + /// in no place on the pages + Invisible, + /// the empty signature field of this full name, showing the stamp of + /// the body's part `stamp` + Existing { field: String, stamp: Option }, + /// a new field on page `page` (from 1) + New { + page: u32, + rect: Rect, + stamp: Option, + }, +} + +/// an empty signature field for someone else, named after them #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SignOptions { - /// the page of the signature's field, from 1 +pub struct OtherPlan { pub page: u32, - /// where the signature is shown: x, y, width and height in points from - /// the page's top left; none for an invisible signature - pub rect: Option<[f64; 4]>, + pub rect: Rect, + /// who is to sign it: the field is called "Signature of ‹name›" + pub name: String, +} + +/// what to add to the PDF, and what the signature says +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Plan { + #[serde(default)] + pub stamps: Vec, + pub signature: SignaturePlan, + #[serde(default)] + pub others: Vec, pub location: Option, pub reason: Option, /// an RFC 3161 server's URL, for a timestamp (B-T) pub timestamp_server: Option, } -/// signer is the name of the certificate's holder, which the signature -/// names as who signed it; None when it has no common name -fn signer(cert: &[u8]) -> Option { +/// where the PDF to sign comes from +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Source { + /// the body's part 0 (the export flow) + Body, + /// a PDF tab's file, which must still be the one the tab opened, with + /// the tab's fills + Tab { + tab: String, + path: PathBuf, + sha256: String, + #[serde(default)] + fills: Vec, + }, +} + +/// the user's key file and its passphrase +pub struct Key<'a> { + pub p12: &'a [u8], + pub passphrase: &'a str, +} + +/// signer_name is the name of the certificate's holder, which the +/// signature names as who signed it; None when it has no common name +fn signer_name(cert: &[u8]) -> Option { use der::Decode; let cert = x509_cert::Certificate::from_der(cert).ok()?; crate::signing::certificate::common_name(&cert.tbs_certificate.subject) } -/// sign signs `pdf` with the user's key, in the name of its certificate's -/// holder -pub fn sign(p12: &[u8], passphrase: &str, pdf: &[u8], options: &SignOptions) -> Answer> { - let identity = open_key(p12, passphrase, "Blank can't open your key")?; - let blank = |value: &Option| value.clone().filter(|text| !text.trim().is_empty()); - let request = SignRequest { - identity: &identity, - page: options.page.max(1), - rect: options.rect, - reason: blank(&options.reason), - location: blank(&options.location), - name: blank(&signer(&identity.cert)), - tsa: blank(&options.timestamp_server), - reserve: RESERVE, - }; - let signed = sign_pdf(pdf, &request).map_err(|error| { - log::warn!("failed to sign a PDF: {}", kind_of(&error)); +/// signing's fixed sentence for what failed +const SIGN_FAILED: &str = "Blank couldn't sign the PDF"; +const STAMP_FAILED: &str = "Blank couldn't add to the PDF"; + +/// failed_sentence is what failed, said to the user: a signature, or (no +/// passphrase) the stamps and fields alone +fn failed_sentence(signing: bool) -> &'static str { + if signing { + SIGN_FAILED + } else { + STAMP_FAILED + } +} + +/// refusal_of tells the user why an update failed: a refusal's sentence, +/// else `message` +fn refusal_of(message: &str) -> impl Fn(anyhow::Error) -> SigningError + '_ { + move |error| { + log::warn!("failed to update a PDF: {}", kind_of(&error)); match error.downcast_ref::() { Some( refusal @ (Refusal::NoTimestamp @@ -416,28 +491,130 @@ pub fn sign(p12: &[u8], passphrase: &str, pdf: &[u8], options: &SignOptions) -> | Refusal::TimestampUnusable), ) => SigningError::Timestamp(refusal.to_string()), Some(refusal) => SigningError::Failed(refusal.to_string()), - None => SigningError::Failed("Blank couldn't sign the PDF".into()), + None => SigningError::Failed(message.into()), } - })?; - check_signed(&signed, request.tsa.is_some())?; - Ok(signed) + } } -/// check_signed makes sure what was just signed checks out, and that its -/// last signature covers the whole file, or it isn't saved; `timestamped` -/// asks for a timestamp that holds as well, since the user asked for one -fn check_signed(signed: &[u8], timestamped: bool) -> Answer<()> { - let reports = verify_pdf(signed).map_err(failed( - "failed to check a signed PDF", - "Blank couldn't sign the PDF", - ))?; - let Some(last) = reports - .last() - .filter(|report| report.valid() && report.covers_the_whole_file()) - else { +/// sign adds `plan` to `pdf` and signs it with the user's key, or, without +/// a key, adds the stamps and fields alone; the stamps' PDFs are `parts` +/// of the body, by `part`. `fills` are the fills the tab's helper wrote +/// into `pdf` as a revision of their own: refused if an earlier signature +/// locked one of their fields +pub fn sign( + key: Option, + pdf: &[u8], + parts: &[&[u8]], + plan: &Plan, + fills: &[Fill], +) -> Answer> { + let message = failed_sentence(key.is_some()); + let identity = key + .map(|key| open_key(key.p12, key.passphrase, "Blank can't open your key")) + .transpose()?; + let part = |index: usize| -> Answer<&[u8]> { + parts.get(index).copied().ok_or_else(|| { + log::warn!("a stamp names a part the request doesn't have"); + SigningError::Failed(message.into()) + }) + }; + let stamps = plan + .stamps + .iter() + .map(|s| { + Ok(PlacedStamp { + pdf: part(s.part)?, + page: s.page, + rect: s.rect, + repeat: s.repeat, + }) + }) + .collect::>>()?; + let signature = match &plan.signature { + SignaturePlan::Invisible => Spot::Invisible, + SignaturePlan::Existing { field, stamp } => Spot::Existing { + field: field.clone(), + stamp: stamp.map(part).transpose()?, + }, + SignaturePlan::New { page, rect, stamp } => Spot::New { + page: *page, + rect: *rect, + stamp: stamp.map(part).transpose()?, + }, + }; + let others = plan + .others + .iter() + .map(|o| Other { + page: o.page, + rect: o.rect, + name: o.name.clone(), + }) + .collect(); + let update = UpdatePlan { + stamps, + signature, + others, + filled: fills.iter().map(|fill| fill.field.as_str()).collect(), + }; + let blank = |value: &Option| value.clone().filter(|text| !text.trim().is_empty()); + let signer = identity.as_ref().map(|identity| Signer { + identity, + reason: blank(&plan.reason), + location: blank(&plan.location), + name: blank(&signer_name(&identity.cert)), + tsa: blank(&plan.timestamp_server), + reserve: RESERVE, + }); + // read once, for what its signatures were (to compare after) and the + // update; a PDF whose signatures can't be checked isn't added to + let doc = load_for_update(pdf).map_err(refusal_of(message))?; + let before = verify_doc(&doc, pdf).map_err(failed("failed to check a PDF to sign", message))?; + let updated = update_pdf(pdf, doc, &update, signer.as_ref()).map_err(refusal_of(message))?; + match &signer { + Some(signer) => check_signed(&updated, signer.tsa.is_some(), &before)?, + None => check_unsigned(&updated, &before)?, + } + Ok(updated) +} + +/// added returns the signatures of `after` that weren't in `before`, if +/// every one of `before` is still there and checks out as it did; a +/// signature is known by its byte range, since the reports come in the +/// order of the form's fields, not of the revisions +fn added<'a>( + after: &'a [SignatureReport], + before: &[SignatureReport], +) -> Option> { + let kept = before.iter().all(|b| { + after + .iter() + .any(|a| a.byte_range == b.byte_range && a.field == b.field && a.valid() == b.valid()) + }); + kept.then(|| { + after + .iter() + .filter(|a| !before.iter().any(|b| b.byte_range == a.byte_range)) + .collect() + }) +} + +/// check_signed makes sure what was just signed checks out: one new +/// signature, valid and covering the whole file, and the signatures it had +/// `before` as valid as they were, or it isn't saved; `timestamped` asks +/// for a timestamp that holds as well, since the user asked for one +fn check_signed(signed: &[u8], timestamped: bool, before: &[SignatureReport]) -> Answer<()> { + let reports = + verify_pdf(signed).map_err(failed("failed to check a signed PDF", SIGN_FAILED))?; + let new = added(&reports, before).unwrap_or_default(); + let [last] = new[..] else { log::warn!("a PDF Blank signed didn't check out"); - return Err(SigningError::Failed("Blank couldn't sign the PDF".into())); + return Err(SigningError::Failed(SIGN_FAILED.into())); }; + if !(last.valid() && last.covers_the_whole_file()) { + log::warn!("a PDF Blank signed didn't check out"); + return Err(SigningError::Failed(SIGN_FAILED.into())); + } if timestamped && (last.timestamp.is_none() || last.timestamp_unreadable) { log::warn!("a PDF Blank signed has no timestamp that holds"); return Err(SigningError::Timestamp( @@ -447,17 +624,31 @@ fn check_signed(signed: &[u8], timestamped: bool) -> Answer<()> { Ok(()) } +/// check_unsigned makes sure an update without a signature added none, and +/// left the signatures as valid as they were +fn check_unsigned(updated: &[u8], before: &[SignatureReport]) -> Answer<()> { + let reports = + verify_pdf(updated).map_err(failed("failed to check an updated PDF", STAMP_FAILED))?; + if added(&reports, before).is_none_or(|new| !new.is_empty()) { + log::warn!("a PDF Blank added to didn't check out"); + return Err(SigningError::Failed(STAMP_FAILED.into())); + } + Ok(()) +} + /// what the header `blank-signing` holds #[derive(Deserialize)] struct Header { - #[serde(deserialize_with = "passphrase")] - passphrase: Passphrase, - options: SignOptions, + /// none: the stamps and fields alone, no signature + #[serde(default, deserialize_with = "passphrase")] + passphrase: Option, + source: Source, + plan: Plan, } /// passphrase reads a passphrase straight into memory that is wiped -fn passphrase<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - String::deserialize(deserializer).map(Passphrase::new) +fn passphrase<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + Option::::deserialize(deserializer).map(|p| p.map(Passphrase::new)) } /// header reads the header `blank-signing`, which the webview encodes with @@ -470,33 +661,154 @@ fn header(value: &str) -> Option
{ serde_json::from_str(&json).ok() } -/// signing_sign_pdf signs the PDF in the request's body with the user's -/// key; its header `blank-signing` holds the passphrase and the -/// SignOptions as JSON. It returns the signed PDF. +/// the most parts a request may have +const MAX_PARTS: usize = 1_024; + +/// parts splits the body into its parts: a little-endian u32 count, then +/// each part as a little-endian u64 length and its bytes, nothing after +fn parts(body: &[u8]) -> Option> { + let count = u32::from_le_bytes(body.get(..4)?.try_into().ok()?) as usize; + if count > MAX_PARTS { + return None; + } + let mut rest = &body[4..]; + let mut out = Vec::with_capacity(count); + for _ in 0..count { + let length = u64::from_le_bytes(rest.get(..8)?.try_into().ok()?); + let length = usize::try_from(length).ok()?; + let part = rest.get(8..8usize.checked_add(length)?)?; + out.push(part); + rest = &rest[8 + length..]; + } + rest.is_empty().then_some(out) +} + +/// signing_sign_pdf adds stamps, empty signature fields for others and a +/// signature to a PDF, and returns it. Its header `blank-signing` holds, +/// as JSON, the passphrase (null: no signature, the stamps and fields +/// alone), where the PDF comes from (`source`: the body's part 0, or a PDF +/// tab's file) and the `plan`; the body holds the parts (see `parts`), the +/// stamps' one-page PDFs among them. #[tauri::command] pub async fn signing_sign_pdf( app: AppHandle, request: tauri::ipc::Request<'_>, ) -> Answer { - let tauri::ipc::InvokeBody::Raw(pdf) = request.body() else { - return Err(SigningError::Failed("Blank couldn't sign the PDF".into())); + let unreadable = || SigningError::Failed(SIGN_FAILED.into()); + let tauri::ipc::InvokeBody::Raw(body) = request.body() else { + return Err(unreadable()); }; let header = request .headers() .get("blank-signing") .and_then(|value| value.to_str().ok()) .and_then(header) - .ok_or_else(|| SigningError::Failed("Blank couldn't sign the PDF".into()))?; - let p12 = read_key(&app)?; - let pdf = pdf.clone(); + .ok_or_else(unreadable)?; + // from here on, what failed is said as what was asked + let message = failed_sentence(header.passphrase.is_some()); + let refused = move || SigningError::Failed(message.into()); + if parts(body).is_none() { + log::warn!("a request to sign had a body that isn't parts"); + return Err(refused()); + } + let p12 = match header.passphrase { + Some(_) => Some(read_key(&app)?), + None => None, + }; + let body = body.clone(); let signed = tauri::async_runtime::spawn_blocking(move || { - sign(&p12, &header.passphrase, &pdf, &header.options) + // a PDF tab's file as it still is, with its fills as MuPDF's + // revision; this waits for the tab's helper + let pdf = match &header.source { + Source::Body => None, + Source::Tab { + tab, + path, + sha256, + fills, + } => Some(filled_bytes( + &app.state::(), + tab, + path, + sha256, + fills, + )?), + }; + let parts = parts(&body).ok_or_else(refused)?; + let pdf = match &pdf { + Some(pdf) => pdf.as_slice(), + None => parts.first().copied().ok_or_else(refused)?, + }; + let key = match (&p12, &header.passphrase) { + (Some(p12), Some(passphrase)) => Some(Key { p12, passphrase }), + _ => None, + }; + let fills = match &header.source { + Source::Tab { fills, .. } => fills.as_slice(), + Source::Body => &[], + }; + sign(key, pdf, &parts, &header.plan, fills) }) .await - .map_err(|_| SigningError::Failed("Blank couldn't sign the PDF".into()))??; + .map_err(|_| refused())??; Ok(tauri::ipc::Response::new(signed)) } +/// what may be placed on a PDF a tab shows (see .claude/rules/pdf.md), for its +/// signing tools: whether it is signed already, its DocMDP permission, the +/// fields earlier signatures locked, and whether it is encrypted or would +/// need repairing, which Blank can't add to +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PdfRules { + pub signed: bool, + pub docmdp: Option, + pub encrypted: bool, + pub damaged: bool, + pub locked: Vec, +} + +/// pdf_rules reads what may be placed on the PDF `bytes` +pub fn pdf_rules(bytes: &[u8]) -> PdfRules { + let doc = match crate::signing::pdf::load_for_update(bytes) { + Ok(doc) => doc, + Err(error) => { + let encrypted = matches!(error.downcast_ref::(), Some(Refusal::Restricted)); + return PdfRules { + encrypted, + damaged: !encrypted, + ..PdfRules::default() + }; + } + }; + let rules = crate::signing::pdf::rules::Rules::read(&doc); + let locked: std::collections::BTreeSet = crate::signing::pdf::form_fields(&doc) + .into_iter() + .map(|field| field.name) + .filter(|name| !name.is_empty() && rules.locks(name)) + .collect(); + PdfRules { + signed: rules.signed, + docmdp: rules.docmdp, + encrypted: false, + damaged: false, + locked: locked.into_iter().collect(), + } +} + +/// signing_pdf_rules reads what may be placed on the PDF at `path`, which a +/// PDF tab shows, while it is still the file the tab opened (`sha256`): +/// `fileChanged` or `fileMissing` when it isn't +#[tauri::command] +pub async fn signing_pdf_rules(path: PathBuf, sha256: String) -> Answer { + tauri::async_runtime::spawn_blocking(move || { + let bytes = read_for_signing(&path, &sha256, "for its rules")?; + Ok(pdf_rules(&bytes)) + }) + .await + .map_err(|_| SigningError::Failed("Blank can't read this PDF again".into()))? +} + /// checked_signatures checks every signature of the PDF `pdf`, and whom it /// is from as far as Blank can tell (see trust.rs) pub fn checked_signatures(app: &AppHandle, pdf: &[u8]) -> Answer> { @@ -545,3 +857,6 @@ pub async fn signing_untrust(app: AppHandle, fingerprint: String) -> Answer<()> #[cfg(test)] mod tests; + +#[cfg(test)] +mod received; diff --git a/src-tauri/src/signing/commands/received.rs b/src-tauri/src/signing/commands/received.rs new file mode 100644 index 00000000..8db09de9 --- /dev/null +++ b/src-tauri/src/signing/commands/received.rs @@ -0,0 +1,950 @@ +//! signing PDFs Blank didn't write, and stamping them: stamps in the pages' +//! content, signature fields the PDF has, empty fields for others, and the +//! rules of what may be added. The fixtures are the PDF tab's +//! (`src-tauri/pdf/fixtures`, see `scripts/build-pdf-fixtures.ts`); the +//! stamps are one-page PDFs made here with lopdf, as Blank's engine would +//! make them: marked content, a structure parent, an indirect font. + +use std::path::Path; +use std::process::Command; + +use blank_pdf_ipc::protocol::FillValue; +use lopdf::{dictionary, Document, Object, ObjectId, Stream}; + +use super::tests::{key, options, pdf, sign_as, signed, widgets, PASSPHRASE, RSA_KEY}; +use super::*; + +const ROTATED: &[u8] = include_bytes!("../../../pdf/fixtures/rotated.pdf"); +const FORM: &[u8] = include_bytes!("../../../pdf/fixtures/form.pdf"); +const PASSWORD: &[u8] = include_bytes!("../../../pdf/fixtures/password.pdf"); + +/// a stamp's one-page PDF of `w` × `h` points with `text` in it, its +/// marked content tagged with an /MCID, its font an object that refers to +/// itself +fn stamp_pdf(w: f64, h: f64, text: &str) -> Vec { + let mut doc = Document::with_version("1.7"); + let pages = doc.new_object_id(); + let font = doc.new_object_id(); + doc.objects.insert( + font, + Object::Dictionary(dictionary! { + "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", + // a cycle the copy must end + "Itself" => font, + }), + ); + let content = doc.add_object(Stream::new( + dictionary! {}, + format!("/P <> BDC BT /F1 10 Tf 4 6 Td ({text}) Tj ET EMC").into_bytes(), + )); + let page = doc.add_object(dictionary! { + "Type" => "Page", "Parent" => pages, "Contents" => content, + "MediaBox" => vec![0.into(), 0.into(), (w as f32).into(), (h as f32).into()], + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, + "StructParents" => 0, + }); + doc.objects.insert( + pages, + Object::Dictionary(dictionary! { + "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1, + }), + ); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + doc.trailer.set("Root", catalog); + let mut bytes = vec![]; + doc.save_to(&mut bytes).unwrap(); + bytes +} + +/// a plan with `stamps`, signed `signature`, nothing else +fn plan(stamps: Vec, signature: SignaturePlan) -> Plan { + Plan { + stamps, + signature, + ..options(None) + } +} + +fn stamp_at(part: usize, page: u32, rect: [f64; 4], repeat: bool) -> StampPlan { + StampPlan { + part, + page, + rect, + repeat, + } +} + +/// stamped adds `plan` to `pdf` without a signature, the stamps' PDFs +/// `stamps` from part 1 on +fn stamped(pdf: &[u8], plan: &Plan, stamps: &[&[u8]]) -> Answer> { + let parts: Vec<&[u8]> = std::iter::once(pdf).chain(stamps.iter().copied()).collect(); + sign(None, pdf, &parts, plan, &[]) +} + +/// has tells whether `tool` of poppler-utils or qpdf runs, and for poppler's +/// tools that it is poppler's: Windows' runner has xpdf's pdftotext, which +/// can't give the words' boxes. CI on Linux, which installs them, never +/// skips +fn has(tool: &str) -> bool { + let poppler = tool.starts_with("pdf"); + let found = Command::new(tool) + .arg(if poppler { "-v" } else { "--version" }) + .output() + .is_ok_and(|out| { + !poppler + || [&out.stdout, &out.stderr] + .iter() + .any(|text| String::from_utf8_lossy(text).contains("Poppler")) + }); + if !found && cfg!(target_os = "linux") && std::env::var_os("CI").is_some() { + panic!("{tool} is missing: CI doesn't skip the check"); + } + found +} + +/// written writes `bytes` to a file of `dir` +fn written(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path +} + +/// a word pdftotext found: its page from 1, xMin, yMin, xMax, yMax +type Word = (usize, f64, f64, f64, f64, String); + +/// words runs `pdftotext -bbox` on `pdf`, in the pages' crop boxes, the +/// pages as shown +fn words(pdf: &Path) -> Vec { + let html = pdf.with_extension("html"); + assert!(Command::new("pdftotext") + .arg("-bbox") + .arg("-cropbox") + .arg(pdf) + .arg(&html) + .status() + .unwrap() + .success()); + let html = std::fs::read_to_string(html).unwrap(); + let mut found = vec![]; + let mut page = 0; + for line in html.lines().map(str::trim) { + if line.starts_with(" f64 { + let at = rest.find(&format!("{key}=\"")).unwrap() + key.len() + 2; + rest[at..].split('"').next().unwrap().parse().unwrap() + }; + let text = rest.split('>').nth(1).unwrap().split('<').next().unwrap(); + found.push(( + page, + number("xMin"), + number("yMin"), + number("xMax"), + number("yMax"), + text.to_string(), + )); + } + found +} + +/// pdfsig_valid tells how many signatures of `pdf` pdfsig calls valid, and +/// how many it found. Its HOME is `dir`, so it finds no NSS database of the +/// user's and checks without one (poppler 22.02, CI's, leaves NSS unset on an +/// empty -nssdir), and it counts "Signature #n:", which every poppler prints +fn pdfsig_valid(dir: &Path, pdf: &Path) -> (usize, usize) { + let out = Command::new("pdfsig") + .env("HOME", dir) + .arg(pdf) + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + let found = text + .lines() + .filter(|line| line.starts_with("Signature #") && line.ends_with(':')) + .count(); + let valid = text + .matches("Signature Validation: Signature is Valid") + .count(); + (valid, found) +} + +/// the page objects of `pdf`, by number +fn pages(doc: &Document) -> Vec { + doc.get_pages().values().copied().collect() +} + +/// the object `id` of `doc`, followed if it's a reference +fn deref<'a>(doc: &'a Document, o: &'a Object) -> &'a Object { + doc.dereference(o).unwrap().1 +} + +/// the XObjects of a page's direct /Resources +fn page_xobjects(doc: &Document, page: ObjectId) -> lopdf::Dictionary { + let page = doc.get_dictionary(page).unwrap(); + let resources = deref(doc, page.get(b"Resources").unwrap()) + .as_dict() + .unwrap(); + deref(doc, resources.get(b"XObject").unwrap()) + .as_dict() + .unwrap() + .clone() +} + +#[test] +fn puts_a_stamp_where_it_was_placed_on_a_turned_page() { + let stamp = stamp_pdf(120., 30., "Stamped"); + let rect = [200., 150., 120., 30.]; + let out = stamped( + ROTATED, + &plan(vec![stamp_at(1, 1, rect, false)], SignaturePlan::Invisible), + &[&stamp], + ) + .unwrap(); + assert!(out.starts_with(ROTATED)); + if !has("pdftotext") { + return; + } + let dir = tempfile::tempdir().unwrap(); + // where the word is on the stamp itself, and so where it must land + let alone = words(&written(dir.path(), "stamp.pdf", &stamp)); + let (_, sx, sy, sx1, sy1, _) = alone.iter().find(|w| w.5 == "Stamped").unwrap().clone(); + let found = words(&written(dir.path(), "stamped.pdf", &out)); + let (page, x, y, x1, y1, _) = found.iter().find(|w| w.5 == "Stamped").unwrap().clone(); + assert_eq!(page, 1); + // upright, as wide and high as on the stamp + assert!(((x1 - x) - (sx1 - sx)).abs() <= 1. && ((y1 - y) - (sy1 - sy)).abs() <= 1.); + assert!((x - (rect[0] + sx)).abs() <= 1., "{x} vs {}", rect[0] + sx); + assert!((y - (rect[1] + sy)).abs() <= 1., "{y} vs {}", rect[1] + sy); + // the page's own text still has its font, which the page inherits + assert!( + found.iter().any(|w| w.0 == 1 && w.5 == "Turned"), + "{found:?}" + ); +} + +#[test] +fn keeps_the_resources_a_page_inherits() { + let stamp = stamp_pdf(120., 30., "Stamped"); + let out = stamped( + ROTATED, + &plan( + vec![stamp_at(1, 1, [20., 20., 120., 30.], false)], + SignaturePlan::Invisible, + ), + &[&stamp], + ) + .unwrap(); + let doc = Document::load_mem(&out).unwrap(); + let page = pages(&doc)[0]; + let resources = deref( + &doc, + doc.get_dictionary(page).unwrap().get(b"Resources").unwrap(), + ) + .as_dict() + .unwrap(); + // a copy of what /Pages gave it, with the stamp + assert!(deref(&doc, resources.get(b"Font").unwrap()) + .as_dict() + .unwrap() + .has(b"F1")); + assert!(page_xobjects(&doc, page).has(b"BlankStamp0")); + // the shared resources weren't touched + let parent = doc + .get_dictionary(page) + .unwrap() + .get(b"Parent") + .unwrap() + .as_reference() + .unwrap(); + let shared = doc + .get_dictionary(parent) + .unwrap() + .get(b"Resources") + .unwrap() + .as_dict() + .unwrap(); + assert!(!shared.has(b"XObject")); +} + +#[test] +fn makes_the_stamps_marked_content_artifacts() { + let stamp = stamp_pdf(120., 30., "Stamped"); + let out = stamped( + ROTATED, + &plan( + vec![stamp_at(1, 1, [20., 20., 120., 30.], false)], + SignaturePlan::Invisible, + ), + &[&stamp], + ) + .unwrap(); + let doc = Document::load_mem(&out).unwrap(); + let id = page_xobjects(&doc, pages(&doc)[0]) + .get(b"BlankStamp0") + .unwrap() + .as_reference() + .unwrap(); + let form = doc.get_object(id).unwrap().as_stream().unwrap(); + let bbox: Vec = form + .dict + .get(b"BBox") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|n| n.as_float().unwrap()) + .collect(); + assert_eq!(bbox, [0., 0., 120., 30.]); + assert!(!form.dict.has(b"StructParents")); + let content = form.get_plain_content().unwrap(); + let content = String::from_utf8_lossy(&content); + assert!(!content.contains("MCID"), "{content}"); + assert!(content.contains("/Artifact BMC"), "{content}"); + // the font that refers to itself came along once + let resources = form.dict.get(b"Resources").unwrap().as_dict().unwrap(); + let fonts = resources.get(b"Font").unwrap().as_dict().unwrap(); + let font_id = fonts.get(b"F1").unwrap().as_reference().unwrap(); + let font = doc.get_dictionary(font_id).unwrap(); + assert_eq!( + font.get(b"Itself").unwrap().as_reference().unwrap(), + font_id + ); +} + +#[test] +fn puts_a_repeated_stamp_on_every_page_inside_it() { + let stamp = stamp_pdf(56., 28., "Initials"); + // bottom right of the first page as shown (842 × 495), outside the A5 + // page (420 × 595) + let rect = [750., 430., 56., 28.]; + let out = stamped( + ROTATED, + &plan(vec![stamp_at(1, 1, rect, true)], SignaturePlan::Invisible), + &[&stamp], + ) + .unwrap(); + let doc = Document::load_mem(&out).unwrap(); + let ids: Vec = pages(&doc) + .into_iter() + .map(|page| { + page_xobjects(&doc, page) + .get(b"BlankStamp0") + .unwrap() + .clone() + }) + .collect(); + // one XObject, drawn on both pages + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], ids[1]); + if !has("pdftotext") { + return; + } + let dir = tempfile::tempdir().unwrap(); + let found = words(&written(dir.path(), "initials.pdf", &out)); + let on: Vec<&Word> = found.iter().filter(|w| w.5 == "Initials").collect(); + assert_eq!(on.iter().map(|w| w.0).collect::>(), [1, 2]); + // inside the smaller page + let second = on[1]; + assert!(second.3 <= 420. && second.4 <= 595., "{second:?}"); +} + +#[test] +fn adds_stamps_without_a_signature() { + let stamp = stamp_pdf(120., 30., "Stamped"); + let original = pdf(); + let out = stamped( + &original, + &plan( + vec![stamp_at(1, 1, [72., 72., 120., 30.], false)], + SignaturePlan::Invisible, + ), + &[&stamp], + ) + .unwrap(); + assert!(out.starts_with(&original)); + assert!(verify_pdf(&out).unwrap().is_empty()); + assert!(widgets(&out).is_empty()); + if has("qpdf") { + let dir = tempfile::tempdir().unwrap(); + let path = written(dir.path(), "stamped.pdf", &out); + let status = Command::new("qpdf") + .arg("--check") + .arg(&path) + .output() + .unwrap(); + assert!( + status.status.success(), + "{}", + String::from_utf8_lossy(&status.stdout) + ); + } +} + +#[test] +fn signs_a_signature_field_the_pdf_has() { + let stamp = stamp_pdf(200., 50., "Signed"); + let plan = plan( + vec![], + SignaturePlan::Existing { + field: "signature".into(), + stamp: Some(1), + }, + ); + let out = sign_as(key(), PASSPHRASE, FORM, &plan, &[&stamp]).unwrap(); + assert!(out.starts_with(FORM)); + let reports = verify_pdf(&out).unwrap(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].field, "signature"); + assert!(reports[0].valid() && reports[0].covers_the_whole_file()); + // no new field: the one it had, with the stamp as its appearance + let found = widgets(&out); + assert_eq!(found.len(), 1, "{found:?}"); + let doc = Document::load_mem(&out).unwrap(); + let widget = &found[0].1; + assert_eq!(widget.get(b"T").unwrap().as_str().unwrap(), b"signature"); + let ap = deref(&doc, widget.get(b"AP").unwrap()).as_dict().unwrap(); + let normal = deref(&doc, ap.get(b"N").unwrap()).as_stream().unwrap(); + let shows = normal + .dict + .get(b"Resources") + .unwrap() + .as_dict() + .unwrap() + .get(b"XObject") + .unwrap() + .as_dict() + .unwrap() + .get(b"BlankStamp") + .unwrap() + .as_reference() + .unwrap(); + let form = doc.get_object(shows).unwrap().as_stream().unwrap(); + assert_eq!( + form.dict.get(b"Subtype").unwrap().as_name().unwrap(), + b"Form" + ); + assert_eq!(widget.get(b"F").unwrap().as_i64().unwrap(), 4); + if has("pdfsig") { + let dir = tempfile::tempdir().unwrap(); + let path = written(dir.path(), "signed.pdf", &out); + assert_eq!(pdfsig_valid(dir.path(), &path), (1, 1)); + } +} + +#[test] +fn refuses_a_field_that_is_signed_or_isnt_there() { + let once = sign_as( + key(), + PASSPHRASE, + FORM, + &plan( + vec![], + SignaturePlan::Existing { + field: "signature".into(), + stamp: None, + }, + ), + &[], + ) + .unwrap(); + for (pdf, field, refusal) in [ + (&once, "signature", Refusal::FieldSigned), + (&FORM.to_vec(), "Name", Refusal::NoSuchField), + (&FORM.to_vec(), "nothing", Refusal::NoSuchField), + ] { + let plan = plan( + vec![], + SignaturePlan::Existing { + field: field.into(), + stamp: None, + }, + ); + let Err(SigningError::Failed(message)) = sign_as(key(), PASSPHRASE, pdf, &plan, &[]) else { + panic!("signed {field}"); + }; + assert_eq!(message, refusal.to_string()); + } +} + +#[test] +fn adds_empty_named_fields_for_the_others_who_sign() { + let others = |plan: Plan| Plan { + others: vec![ + OtherPlan { + page: 1, + rect: [72., 600., 200., 60.], + name: "Ben Sample".into(), + }, + OtherPlan { + page: 1, + rect: [320., 600., 200., 60.], + name: "Ben Sample".into(), + }, + OtherPlan { + page: 1, + rect: [72., 700., 200., 60.], + name: "Dr. Zoë Ng".into(), + }, + ], + ..plan + }; + let original = pdf(); + let signed_out = signed( + key(), + &original, + &others(options(Some([300., 100., 200., 60.]))), + ); + let stamped_out = stamped(&original, &others(options(None)), &[]).unwrap(); + for (out, signatures) in [(signed_out, 1), (stamped_out, 0)] { + assert_eq!(verify_pdf(&out).unwrap().len(), signatures); + let doc = Document::load_mem(&out).unwrap(); + let names: Vec<(String, bool)> = widgets(&out) + .into_iter() + .map(|(_, w)| { + ( + lopdf::decode_text_string(w.get(b"T").unwrap()).unwrap(), + w.has(b"V"), + ) + }) + .collect(); + let empty: Vec<&str> = names + .iter() + .filter(|(_, v)| !v) + .map(|(n, _)| n.as_str()) + .collect(); + assert_eq!( + empty, + [ + "Signature of Ben Sample", + "Signature of Ben Sample 2", + "Signature of Dr Zoë Ng" + ] + ); + assert_eq!(names.len(), 3 + signatures); + // in the form, so PDF apps offer them to sign + let form = deref(&doc, doc.catalog().unwrap().get(b"AcroForm").unwrap()) + .as_dict() + .unwrap(); + let fields = deref(&doc, form.get(b"Fields").unwrap()) + .as_array() + .unwrap(); + assert_eq!(fields.len(), 3 + signatures); + } +} + +#[test] +fn draws_the_signature_on_an_unsigned_pdf_and_in_the_widget_on_a_signed_one() { + let stamp = stamp_pdf(200., 50., "Signed"); + let new = |page| SignaturePlan::New { + page, + rect: [72., 72., 200., 50.], + stamp: Some(1), + }; + // unsigned: in the page content, the widget's appearance empty + let original = pdf(); + let once = sign_as( + key(), + PASSPHRASE, + &original, + &plan(vec![], new(1)), + &[&stamp], + ) + .unwrap(); + let doc = Document::load_mem(&once).unwrap(); + assert!(page_xobjects(&doc, pages(&doc)[0]).has(b"BlankStamp0")); + // signed: in the widget, the pages as they were + let twice = sign_as(RSA_KEY, PASSPHRASE, &once, &plan(vec![], new(1)), &[&stamp]).unwrap(); + assert!(twice.starts_with(&once)); + let reports = verify_pdf(&twice).unwrap(); + assert_eq!(reports.len(), 2); + assert!(reports.iter().all(|r| r.valid())); + // the second only signed: the first is as valid as it was + assert_eq!( + reports[0].coverage, + crate::signing::verify::Coverage::SignedOnlyAgain + ); + let doc_twice = Document::load_mem(&twice).unwrap(); + let page = pages(&doc_twice)[0]; + assert_eq!( + doc_twice + .get_dictionary(page) + .unwrap() + .get(b"Contents") + .unwrap(), + doc.get_dictionary(page).unwrap().get(b"Contents").unwrap() + ); + let last = widgets(&twice).pop().unwrap().1; + let ap = deref(&doc_twice, last.get(b"AP").unwrap()) + .as_dict() + .unwrap(); + let normal = deref(&doc_twice, ap.get(b"N").unwrap()) + .as_stream() + .unwrap(); + assert!(normal.dict.has(b"Resources")); + if has("pdfsig") { + let dir = tempfile::tempdir().unwrap(); + let path = written(dir.path(), "twice.pdf", &twice); + // the earlier signature still valid + assert_eq!(pdfsig_valid(dir.path(), &path), (2, 2)); + } +} + +/// certified certifies `pdf` with DocMDP permission `p` (the dictionary +/// alone, as the tests of verify do: Blank doesn't certify) +fn certified(pdf: &[u8], p: i64) -> Vec { + let mut doc = Document::load_mem(pdf).unwrap(); + let docmdp = doc.add_object(dictionary! { + "Type" => "Sig", + "Reference" => vec![Object::Dictionary(dictionary! { + "TransformMethod" => "DocMDP", + "TransformParams" => dictionary! { "P" => p }, + })], + }); + let root = doc.trailer.get(b"Root").unwrap().as_reference().unwrap(); + doc.get_dictionary_mut(root) + .unwrap() + .set("Perms", dictionary! { "DocMDP" => docmdp }); + let mut out = vec![]; + doc.save_to(&mut out).unwrap(); + out +} + +#[test] +fn refuses_what_the_pdf_doesnt_allow() { + let stamp = stamp_pdf(120., 30., "Stamped"); + let content = plan( + vec![stamp_at(1, 1, [72., 72., 120., 30.], false)], + SignaturePlan::Invisible, + ); + let earlier = signed(RSA_KEY, &pdf(), &options(None)); + let fails = |result: Answer>, refusal: Refusal| { + let Err(SigningError::Failed(message)) = result else { + panic!("not refused: {refusal:?}"); + }; + assert_eq!(message, refusal.to_string()); + }; + // P=1: nothing at all, a signature or stamps + let p1 = certified(&pdf(), 1); + fails( + sign_as(key(), PASSPHRASE, &p1, &options(None), &[]), + Refusal::Certified, + ); + fails(stamped(&p1, &content, &[&stamp]), Refusal::Certified); + // P=2, and signed: no stamps in the pages, with or without a signature + let p2 = certified(&pdf(), 2); + for pdf in [&p2, &earlier] { + fails(stamped(pdf, &content, &[&stamp]), Refusal::SignedPages); + fails( + sign_as(key(), PASSPHRASE, pdf, &content, &[&stamp]), + Refusal::SignedPages, + ); + } + // but a signature on P=2, its stamp as its appearance + let new = SignaturePlan::New { + page: 1, + rect: [72., 72., 120., 30.], + stamp: Some(1), + }; + sign_as(key(), PASSPHRASE, &p2, &plan(vec![], new), &[&stamp]).unwrap(); + // encrypted + fails(stamped(PASSWORD, &content, &[&stamp]), Refusal::Restricted); + fails( + sign_as(key(), PASSPHRASE, PASSWORD, &options(None), &[]), + Refusal::Restricted, + ); +} + +#[test] +fn reads_the_rules_of_a_pdf() { + use crate::signing::pdf::rules::{Lock, Rules}; + let read = |pdf: &[u8]| Rules::read(&Document::load_mem(pdf).unwrap()); + assert_eq!(read(&pdf()), Rules::default()); + assert_eq!(read(&certified(&pdf(), 1)).docmdp, Some(1)); + let earlier = read(&signed(key(), &pdf(), &options(None))); + assert!(earlier.signed && earlier.docmdp.is_none()); + assert!(!earlier.allows_page_content() && earlier.allows_changes()); + // a FieldMDP lock of a signed field + let mut doc = Document::load_mem(&signed(key(), &pdf(), &options(None))).unwrap(); + let field = widgets(&doc_bytes(&doc))[0].1.clone(); + let id = doc + .objects + .iter() + .find(|(_, o)| o.as_dict().is_ok_and(|d| *d == field)) + .map(|(id, _)| *id) + .unwrap(); + doc.get_dictionary_mut(id).unwrap().set( + "Lock", + dictionary! { "Action" => "Include", "Fields" => vec![Object::string_literal("Name")] }, + ); + let locked = read(&doc_bytes(&doc)); + assert_eq!(locked.locks, [Lock::Include(vec!["Name".into()])]); + assert!(locked.locks("Name") && !locked.locks("Other")); +} + +#[test] +fn tells_the_tools_what_a_pdf_allows() { + assert_eq!(pdf_rules(&pdf()), PdfRules::default()); + let p1 = pdf_rules(&certified(&pdf(), 1)); + assert_eq!((p1.docmdp, p1.signed), (Some(1), true)); + let earlier = pdf_rules(&signed(key(), &pdf(), &options(None))); + assert!(earlier.signed && earlier.docmdp.is_none() && earlier.locked.is_empty()); + let encrypted = pdf_rules(PASSWORD); + assert!(encrypted.encrypted && !encrypted.damaged); + let damaged = pdf_rules(b"%PDF-1.7\nnot a PDF"); + assert!(damaged.damaged && !damaged.encrypted); + // the fields an earlier signature locked, by name + let mut doc = Document::load_mem(FORM).unwrap(); + let names: Vec = crate::signing::pdf::form_fields(&doc) + .into_iter() + .map(|field| field.name) + .collect(); + let first = names.first().cloned().expect("the form has fields"); + let signed_form = signed(key(), &doc_bytes(&doc), &options(None)); + doc = Document::load_mem(&signed_form).unwrap(); + let sig = crate::signing::pdf::form_fields(&doc) + .into_iter() + .find(|field| field.signature && field.dict.get(b"V").is_ok()) + .and_then(|field| field.id) + .expect("a signed field"); + doc.get_dictionary_mut(sig).unwrap().set( + "Lock", + dictionary! { "Action" => "Include", "Fields" => vec![Object::string_literal(first.clone())] }, + ); + assert_eq!(pdf_rules(&doc_bytes(&doc)).locked, [first]); +} + +fn doc_bytes(doc: &Document) -> Vec { + let mut out = vec![]; + doc.clone().save_to(&mut out).unwrap(); + out +} + +#[test] +fn drops_transparency_for_a_pdfa1_target() { + // a stamp with a transparency group and an image with a soft mask + let mut doc = Document::load_mem(&stamp_pdf(10., 10., "x")).unwrap(); + let mask = doc.add_object(Stream::new( + dictionary! { "Type" => "XObject", "Subtype" => "Image", "Width" => 2, "Height" => 1, + "ColorSpace" => "DeviceGray", "BitsPerComponent" => 8 }, + vec![0, 255], + )); + let image = doc.add_object(Stream::new( + dictionary! { "Type" => "XObject", "Subtype" => "Image", "Width" => 2, "Height" => 1, + "ColorSpace" => "DeviceGray", "BitsPerComponent" => 8, "SMask" => mask }, + vec![0, 0], + )); + let page = pages(&doc)[0]; + let p = doc.get_dictionary_mut(page).unwrap(); + p.set( + "Group", + dictionary! { "S" => "Transparency", "CS" => "DeviceRGB" }, + ); + let resources = p.get_mut(b"Resources").unwrap().as_dict_mut().unwrap(); + resources.set("XObject", dictionary! { "Im0" => image }); + resources.set( + "ExtGState", + dictionary! { "G0" => dictionary! { "Type" => "ExtGState", "ca" => 0.5 } }, + ); + let stamp = doc_bytes(&doc); + + let import = |pdfa1: bool| { + let target = pdf(); + let mut incr = lopdf::IncrementalDocument::create_from( + target.clone(), + Document::load_mem(&target).unwrap(), + ); + let form = crate::signing::pdf::xobject::import_stamp(&mut incr, &stamp, pdfa1).unwrap(); + (incr.new_document, form.id) + }; + let (kept, id) = import(false); + assert!(kept + .get_object(id) + .unwrap() + .as_stream() + .unwrap() + .dict + .has(b"Group")); + let (flat, id) = import(true); + let form = flat.get_object(id).unwrap().as_stream().unwrap(); + assert!(!form.dict.has(b"Group")); + let resources = form.dict.get(b"Resources").unwrap().as_dict().unwrap(); + let image_id = resources + .get(b"XObject") + .unwrap() + .as_dict() + .unwrap() + .get(b"Im0") + .unwrap() + .as_reference() + .unwrap(); + let image = flat.get_object(image_id).unwrap().as_stream().unwrap(); + assert!(!image.dict.has(b"SMask")); + // the transparent pixel is white now, the opaque one as it was + assert_eq!(image.get_plain_content().unwrap(), [255, 0]); + let state = resources + .get(b"ExtGState") + .unwrap() + .as_dict() + .unwrap() + .get(b"G0") + .unwrap() + .as_dict() + .unwrap(); + assert_eq!(state.get(b"ca").unwrap().as_float().unwrap(), 1.0); +} + +#[test] +fn reads_the_parts_of_a_body() { + let mut body = 2u32.to_le_bytes().to_vec(); + for part in [&b"%PDF"[..], b"ab"] { + body.extend((part.len() as u64).to_le_bytes()); + body.extend(part); + } + assert_eq!(parts(&body).unwrap(), [&b"%PDF"[..], b"ab"]); + // short, long, or with bytes after + assert!(parts(&body[..body.len() - 1]).is_none()); + let mut longer = body.clone(); + longer.push(0); + assert!(parts(&longer).is_none()); + assert!(parts(&[1, 0]).is_none()); + assert_eq!(parts(&0u32.to_le_bytes()).unwrap(), Vec::<&[u8]>::new()); +} + +#[test] +fn reads_a_tabs_file_while_it_is_the_one_opened() { + let dir = tempfile::tempdir().unwrap(); + let path = written(dir.path(), "a.pdf", FORM); + let sha = crate::pdf_view::sha256(FORM); + let tabs = PdfTabs::default(); + assert_eq!(filled_bytes(&tabs, "t", &path, &sha, &[]).unwrap(), FORM); + assert!(matches!( + filled_bytes(&tabs, "t", &path, "00", &[]), + Err(SigningError::FileChanged) + )); + assert!(matches!( + filled_bytes(&tabs, "t", &dir.path().join("gone.pdf"), &sha, &[]), + Err(SigningError::FileMissing) + )); + // fills need the tab's helper, which a tab that isn't open has not + let fill = Fill { + field: "Name".into(), + value: FillValue::Text("Ada".into()), + }; + assert!(filled_bytes(&tabs, "t", &path, &sha, &[fill]).is_err()); +} + +#[test] +fn signs_a_field_listed_before_an_earlier_signature() { + // the earlier signature in a new field, after the form's own + let earlier = signed(RSA_KEY, FORM, &options(Some([72., 72., 200., 50.]))); + let stamp = stamp_pdf(200., 50., "Signed"); + let plan = plan( + vec![], + SignaturePlan::Existing { + field: "signature".into(), + stamp: Some(1), + }, + ); + let out = sign_as(key(), PASSPHRASE, &earlier, &plan, &[&stamp]).unwrap(); + assert!(out.starts_with(&earlier)); + let reports = verify_pdf(&out).unwrap(); + let fields: Vec<&str> = reports.iter().map(|r| r.field.as_str()).collect(); + assert_eq!(fields, ["signature", "Signature1"]); + assert!(reports.iter().all(|r| r.valid())); + // filling in the PDF's own empty field only signed it again + assert_eq!( + reports[1].coverage, + crate::signing::verify::Coverage::SignedOnlyAgain + ); + if has("pdfsig") { + let dir = tempfile::tempdir().unwrap(); + let path = written(dir.path(), "twice.pdf", &out); + assert_eq!(pdfsig_valid(dir.path(), &path), (2, 2)); + } +} + +#[test] +fn refuses_fills_of_fields_an_earlier_signature_locked() { + // the form signed, its signature locking the field "name" + let mut doc = Document::load_mem(&signed(key(), FORM, &options(None))).unwrap(); + let sig = crate::signing::pdf::form_fields(&doc) + .into_iter() + .find(|field| field.signature && field.dict.get(b"V").is_ok()) + .and_then(|field| field.id) + .expect("a signed field"); + doc.get_dictionary_mut(sig).unwrap().set( + "Lock", + dictionary! { "Action" => "Include", "Fields" => vec![Object::string_literal("name")] }, + ); + let locked = doc_bytes(&doc); + let fill = |field: &str| Fill { + field: field.into(), + value: FillValue::Text("Ada".into()), + }; + let nothing = plan(vec![], SignaturePlan::Invisible); + let Err(SigningError::Failed(message)) = + sign(None, &locked, &[&locked], ¬hing, &[fill("name")]) + else { + panic!("filled a locked field"); + }; + assert_eq!(message, Refusal::FilledLocked.to_string()); + // a field it didn't lock may be filled in, and signed + let new = SignaturePlan::New { + page: 1, + rect: [72., 72., 120., 30.], + stamp: None, + }; + let p12 = key(); + sign( + Some(Key { + p12, + passphrase: PASSPHRASE, + }), + &locked, + &[&locked], + &plan(vec![], new), + &[fill("address.street")], + ) + .unwrap(); +} + +#[test] +fn keeps_a_stamps_content_where_its_page_box_has_it() { + // a stamp page whose box doesn't start at the origin + let mut doc = Document::load_mem(&stamp_pdf(56., 28., "x")).unwrap(); + let page = pages(&doc)[0]; + doc.get_dictionary_mut(page) + .unwrap() + .set("MediaBox", vec![10.into(), 20.into(), 66.into(), 48.into()]); + let stamp = doc_bytes(&doc); + let target = pdf(); + let mut incr = lopdf::IncrementalDocument::create_from( + target.clone(), + Document::load_mem(&target).unwrap(), + ); + let form = crate::signing::pdf::xobject::import_stamp(&mut incr, &stamp, false).unwrap(); + assert_eq!((form.width, form.height), (56., 28.)); + let dict = &incr + .new_document + .get_object(form.id) + .unwrap() + .as_stream() + .unwrap() + .dict; + let floats = |key: &[u8]| -> Vec { + dict.get(key) + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|n| n.as_float().unwrap()) + .collect() + }; + // the BBox clips in the form's own space, where its content is + assert_eq!(floats(b"BBox"), [10., 20., 66., 48.]); + assert_eq!(floats(b"Matrix"), [1., 0., 0., 1., -10., -20.]); +} diff --git a/src-tauri/src/signing/commands/tests.rs b/src-tauri/src/signing/commands/tests.rs index 66faabd3..41c0f729 100644 --- a/src-tauri/src/signing/commands/tests.rs +++ b/src-tauri/src/signing/commands/tests.rs @@ -1,13 +1,15 @@ use lopdf::{dictionary, Document, Object, ObjectId, Stream}; +use blank_pdf_ipc::protocol::FillValue; + use super::*; use crate::signing::verify::{verify_pdf, Coverage}; -const PASSPHRASE: &str = "correct horse"; +pub(super) const PASSPHRASE: &str = "correct horse"; /// a PDF of `pages` A4 pages with a line of text each, as lopdf writes it; /// `modern` writes object streams and a cross-reference stream -fn pdf_of(pages: usize, modern: bool) -> Vec { +pub(super) fn pdf_of(pages: usize, modern: bool) -> Vec { let mut doc = Document::with_version("1.7"); let parent = doc.new_object_id(); let font = doc.add_object(dictionary! { @@ -44,27 +46,49 @@ fn pdf_of(pages: usize, modern: bool) -> Vec { bytes } -fn pdf() -> Vec { +pub(super) fn pdf() -> Vec { pdf_of(1, false) } /// what Blank's layout engine writes, see fixtures/README.md const BLANK_PDF: &[u8] = include_bytes!("../fixtures/blank.pdf"); /// an RSA key from OpenSSL, see fixtures/README.md -const RSA_KEY: &[u8] = include_bytes!("../fixtures/rsa.p12"); - -fn options(rect: Option<[f64; 4]>) -> SignOptions { - SignOptions { - page: 1, - rect, +pub(super) const RSA_KEY: &[u8] = include_bytes!("../fixtures/rsa.p12"); + +/// a plan that signs in a new field at `rect` on page 1, or invisibly +pub(super) fn options(rect: Option<[f64; 4]>) -> Plan { + Plan { + stamps: vec![], + signature: match rect { + Some(rect) => SignaturePlan::New { + page: 1, + rect, + stamp: None, + }, + None => SignaturePlan::Invisible, + }, + others: vec![], location: Some("Zürich".into()), reason: None, timestamp_server: None, } } +/// sign_as signs `pdf` as the export flow sends it: the PDF the body's +/// part 0, the stamps' PDFs `stamps` after it +pub(super) fn sign_as( + p12: &[u8], + passphrase: &str, + pdf: &[u8], + plan: &Plan, + stamps: &[&[u8]], +) -> Answer> { + let parts: Vec<&[u8]> = std::iter::once(pdf).chain(stamps.iter().copied()).collect(); + sign(Some(Key { p12, passphrase }), pdf, &parts, plan, &[]) +} + // a key made once, with few rounds of PBKDF2, which opens fast -fn key() -> &'static [u8] { +pub(super) fn key() -> &'static [u8] { static KEY: std::sync::OnceLock> = std::sync::OnceLock::new(); KEY.get_or_init(|| { keys::create_certificate_with("Ada Lovelace", Some("ada@example.org"), PASSPHRASE, 1000) @@ -74,8 +98,8 @@ fn key() -> &'static [u8] { } /// signs `pdf` and checks that it was appended and checks out -fn signed(key: &[u8], pdf: &[u8], options: &SignOptions) -> Vec { - let signed = sign(key, PASSPHRASE, pdf, options).unwrap(); +pub(super) fn signed(key: &[u8], pdf: &[u8], options: &Plan) -> Vec { + let signed = sign_as(key, PASSPHRASE, pdf, options, &[]).unwrap(); // an incremental update: the original bytes stay as they were assert!(signed.starts_with(pdf)); let reports = verify_pdf(&signed).unwrap(); @@ -86,7 +110,7 @@ fn signed(key: &[u8], pdf: &[u8], options: &SignOptions) -> Vec { } /// the signature widgets on each page of `pdf`, by page number -fn widgets(pdf: &[u8]) -> Vec<(u32, lopdf::Dictionary)> { +pub(super) fn widgets(pdf: &[u8]) -> Vec<(u32, lopdf::Dictionary)> { let doc = Document::load_mem(pdf).unwrap(); let mut found = vec![]; for (number, page) in doc.get_pages() { @@ -110,7 +134,7 @@ fn widgets(pdf: &[u8]) -> Vec<(u32, lopdf::Dictionary)> { found } -fn numbers(array: &Object) -> Vec { +pub(super) fn numbers(array: &Object) -> Vec { array .as_array() .unwrap() @@ -187,9 +211,13 @@ fn puts_the_field_on_the_page_asked_for() { let signed = signed( key(), &original, - &SignOptions { - page: 2, - ..options(Some([72., 72., 100., 40.])) + &Plan { + signature: SignaturePlan::New { + page: 2, + rect: [72., 72., 100., 40.], + stamp: None, + }, + ..options(None) }, ); let pages: Vec = widgets(&signed).iter().map(|(page, _)| *page).collect(); @@ -246,24 +274,29 @@ fn signs_invisibly_and_twice() { fn signs_again_with_more_room_when_the_signature_doesnt_fit() { let identity = keys::load_p12(key(), PASSPHRASE).unwrap(); let original = pdf(); - let request = SignRequest { + let signer = Signer { identity: &identity, - page: 1, - rect: None, reason: None, location: None, name: None, tsa: None, reserve: 64, }; - let signed = sign_pdf(&original, &request).unwrap(); + let plan = UpdatePlan { + stamps: vec![], + signature: Spot::Invisible, + others: vec![], + filled: vec![], + }; + let doc = load_for_update(&original).unwrap(); + let signed = update_pdf(&original, doc, &plan, Some(&signer)).unwrap(); assert!(signed.starts_with(&original)); - check_signed(&signed, false).unwrap(); + check_signed(&signed, false, &[]).unwrap(); } #[test] fn finds_a_change_after_signing() { - let mut signed = sign(key(), PASSPHRASE, &pdf(), &options(None)).unwrap(); + let mut signed = sign_as(key(), PASSPHRASE, &pdf(), &options(None), &[]).unwrap(); // a byte of the page's text changed let at = signed.windows(6).position(|w| w == b"Signed").unwrap(); signed[at] = b'X'; @@ -272,7 +305,7 @@ fn finds_a_change_after_signing() { #[test] fn finds_what_was_added_after_signing() { - let signed = sign(key(), PASSPHRASE, &pdf(), &options(None)).unwrap(); + let signed = sign_as(key(), PASSPHRASE, &pdf(), &options(None), &[]).unwrap(); // an update after the signature, which no signature covers let doc = Document::load_mem(&signed).unwrap(); let mut update = lopdf::IncrementalDocument::create_from(signed.clone(), doc); @@ -289,7 +322,7 @@ fn finds_what_was_added_after_signing() { reports[0].coverage, crate::signing::verify::Coverage::ChangedAfterSigning ); - assert!(check_signed(&signed, false).is_err()); + assert!(check_signed(&signed, false, &[]).is_err()); } /// validated adds `change` to the signed `pdf` as an unsigned update, and @@ -337,8 +370,8 @@ fn first_contents(update: &lopdf::IncrementalDocument) -> ObjectId { #[test] fn takes_validation_data_added_after_signing_for_what_it_is() { - let signed = sign(key(), PASSPHRASE, &pdf(), &options(None)).unwrap(); - let modern = sign(key(), PASSPHRASE, &pdf_of(1, true), &options(None)).unwrap(); + let signed = sign_as(key(), PASSPHRASE, &pdf(), &options(None), &[]).unwrap(); + let modern = sign_as(key(), PASSPHRASE, &pdf_of(1, true), &options(None), &[]).unwrap(); // lopdf writes the update's cross-references as a stream, read here by // Blank's own reader of them for signed in [signed, modern] { @@ -351,7 +384,7 @@ fn takes_validation_data_added_after_signing_for_what_it_is() { #[test] fn calls_a_page_written_again_beside_validation_data_a_change() { - let signed = sign(key(), PASSPHRASE, &pdf(), &options(None)).unwrap(); + let signed = sign_as(key(), PASSPHRASE, &pdf(), &options(None), &[]).unwrap(); let rewrite = |update: &mut lopdf::IncrementalDocument| { let contents = first_contents(update); update.new_document.set_object( @@ -403,7 +436,7 @@ fn calls_a_page_its_own_dss_lists_written_again_a_change() { doc.get_dictionary_mut(root).unwrap().set("DSS", dss); let mut prepared = vec![]; doc.save_to(&mut prepared).unwrap(); - let signed = sign(key(), PASSPHRASE, &prepared, &options(None)).unwrap(); + let signed = sign_as(key(), PASSPHRASE, &prepared, &options(None), &[]).unwrap(); let out = validated( &signed, |update| { @@ -433,7 +466,7 @@ fn calls_the_info_its_own_dss_lists_written_again_a_change() { doc.get_dictionary_mut(root).unwrap().set("DSS", dss); let mut prepared = vec![]; doc.save_to(&mut prepared).unwrap(); - let signed = sign(key(), PASSPHRASE, &prepared, &options(None)).unwrap(); + let signed = sign_as(key(), PASSPHRASE, &prepared, &options(None), &[]).unwrap(); let out = validated( &signed, |update| { @@ -450,7 +483,7 @@ fn calls_the_info_its_own_dss_lists_written_again_a_change() { #[test] fn calls_an_emptied_dictionary_or_an_array_of_numbers_a_change() { - let signed = sign(key(), PASSPHRASE, &pdf(), &options(None)).unwrap(); + let signed = sign_as(key(), PASSPHRASE, &pdf(), &options(None), &[]).unwrap(); let emptied = validated( &signed, |update| { @@ -483,13 +516,10 @@ fn calls_an_emptied_dictionary_or_an_array_of_numbers_a_change() { } } -#[test] -fn calls_an_object_freed_after_signing_a_change() { - let signed = sign(key(), PASSPHRASE, &pdf(), &options(None)).unwrap(); - let mut out = validated(&signed, |_| {}, |_| vec![]); - // another update, whose cross-reference table marks the page's content - // stream free - let doc = Document::load_mem(&out).unwrap(); +/// with_contents_freed adds an update to `pdf` whose cross-reference table +/// marks the first page's content stream free +fn with_contents_freed(mut pdf: Vec) -> Vec { + let doc = Document::load_mem(&pdf).unwrap(); let page = *doc.get_pages().get(&1).unwrap(); let (number, generation) = doc .get_dictionary(page) @@ -498,7 +528,7 @@ fn calls_an_object_freed_after_signing_a_change() { .unwrap() .as_reference() .unwrap(); - let text = String::from_utf8_lossy(&out).into_owned(); + let text = String::from_utf8_lossy(&pdf).into_owned(); let prev: usize = text .rsplit("startxref") .next() @@ -510,8 +540,8 @@ fn calls_an_object_freed_after_signing_a_change() { .unwrap(); let root = doc.trailer.get(b"Root").unwrap().as_reference().unwrap(); let size = doc.trailer.get(b"Size").unwrap().as_i64().unwrap(); - let at = out.len() + 1; - out.extend_from_slice( + let at = pdf.len() + 1; + pdf.extend_from_slice( format!( "\nxref\n{number} 1\n0000000000 {:05} f\r\ntrailer\n<< /Size {size} /Root {} {} R /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n", generation + 1, @@ -520,6 +550,13 @@ fn calls_an_object_freed_after_signing_a_change() { ) .as_bytes(), ); + pdf +} + +#[test] +fn calls_an_object_freed_after_signing_a_change() { + let signed = sign_as(key(), PASSPHRASE, &pdf(), &options(None), &[]).unwrap(); + let out = with_contents_freed(validated(&signed, |_| {}, |_| vec![])); let reports = verify_pdf(&out).unwrap(); assert_eq!(reports[0].coverage, Coverage::ChangedAfterSigning); } @@ -527,7 +564,7 @@ fn calls_an_object_freed_after_signing_a_change() { #[test] fn says_when_the_passphrase_is_wrong() { assert!(matches!( - sign(key(), "wrong", &pdf(), &options(None)), + sign_as(key(), "wrong", &pdf(), &options(None), &[]), Err(SigningError::WrongPassphrase) )); } @@ -535,7 +572,7 @@ fn says_when_the_passphrase_is_wrong() { #[test] fn refuses_what_isnt_a_pdf_without_quoting_it() { let Err(SigningError::Failed(message)) = - sign(key(), PASSPHRASE, b"%PDF-1.7 junk", &options(None)) + sign_as(key(), PASSPHRASE, b"%PDF-1.7 junk", &options(None), &[]) else { panic!("signed what isn't a PDF"); }; @@ -551,7 +588,7 @@ fn refuses_a_place_that_isnt_a_rectangle() { [300., f64::INFINITY, 200., 60.], ] { let Err(SigningError::Failed(message)) = - sign(key(), PASSPHRASE, &pdf(), &options(Some(rect))) + sign_as(key(), PASSPHRASE, &pdf(), &options(Some(rect)), &[]) else { panic!("signed in {rect:?}"); }; @@ -575,7 +612,8 @@ fn says_when_a_certified_pdf_allows_no_changes() { .set("Perms", dictionary! { "DocMDP" => docmdp }); let mut certified = vec![]; doc.save_to(&mut certified).unwrap(); - let Err(SigningError::Failed(message)) = sign(key(), PASSPHRASE, &certified, &options(None)) + let Err(SigningError::Failed(message)) = + sign_as(key(), PASSPHRASE, &certified, &options(None), &[]) else { panic!("signed a PDF that allows no changes"); }; @@ -584,11 +622,30 @@ fn says_when_a_certified_pdf_allows_no_changes() { #[test] fn reads_the_header_the_webview_encodes() { - // encodeURIComponent of {"passphrase":"grüß","options":{"page":2,"rect":null}} - let value = "%7B%22passphrase%22%3A%22gr%C3%BC%C3%9F%22%2C%22options%22%3A%7B%22page%22%3A2%2C%22rect%22%3Anull%7D%7D"; - let read = header(value).unwrap(); - assert_eq!(read.passphrase.as_str(), "grüß"); - assert_eq!(read.options.page, 2); + let json = r#"{"passphrase":"grüß","source":{"kind":"tab","tab":"t1","path":"/tmp/a.pdf","sha256":"ab","fills":[{"field":"Name","value":"Ada"},{"field":"Agree","value":true}]},"plan":{"stamps":[{"part":0,"page":1,"rect":[1,2,3,4],"repeat":true}],"signature":{"kind":"existing","field":"Signature1","stamp":1},"others":[{"page":2,"rect":[5,6,7,8],"name":"Ben Sample"}],"name":null,"location":"Zürich","reason":null,"timestampServer":null}}"#; + let value: String = + percent_encoding::utf8_percent_encode(json, percent_encoding::NON_ALPHANUMERIC).to_string(); + let read = header(&value).unwrap(); + assert_eq!(read.passphrase.as_deref().map(String::as_str), Some("grüß")); + let Source::Tab { fills, sha256, .. } = &read.source else { + panic!("not a tab"); + }; + assert_eq!(sha256, "ab"); + assert!(matches!(&fills[1].value, FillValue::Checked(true))); + assert!(read.plan.stamps[0].repeat); + assert!(matches!( + read.plan.signature, + SignaturePlan::Existing { stamp: Some(1), .. } + )); + assert_eq!(read.plan.others[0].name, "Ben Sample"); + // without a passphrase: the stamps alone + let json = + r#"{"passphrase":null,"source":{"kind":"body"},"plan":{"signature":{"kind":"invisible"}}}"#; + let value: String = + percent_encoding::utf8_percent_encode(json, percent_encoding::NON_ALPHANUMERIC).to_string(); + let read = header(&value).unwrap(); + assert!(read.passphrase.is_none()); + assert!(matches!(read.source, Source::Body)); assert!(header("%7B%Z1").is_none()); } @@ -675,8 +732,8 @@ fn keeps_the_old_pair_when_the_new_certificate_cant_be_put_in_place() { #[test] fn refuses_a_signature_without_the_timestamp_asked_for() { let signed = signed(key(), &pdf(), &options(None)); - assert!(check_signed(&signed, false).is_ok()); - let Err(SigningError::Timestamp(message)) = check_signed(&signed, true) else { + assert!(check_signed(&signed, false, &[]).is_ok()); + let Err(SigningError::Timestamp(message)) = check_signed(&signed, true, &[]) else { panic!("a signature without its timestamp passed"); }; assert_eq!(message, Refusal::TimestampUnusable.to_string()); @@ -685,7 +742,19 @@ fn refuses_a_signature_without_the_timestamp_asked_for() { #[test] fn says_which_signature_signed_the_changes_after_an_earlier_one() { let first = signed(key(), &pdf(), &options(None)); - let twice = signed(RSA_KEY, &first, &options(None)); + // a change that isn't a signature: the page gains a key + let doc = Document::load_mem(&first).unwrap(); + let page = *doc.get_pages().values().next().unwrap(); + let mut update = lopdf::IncrementalDocument::create_from(first.clone(), doc); + update.opt_clone_object_to_new_document(page).unwrap(); + update + .new_document + .get_dictionary_mut(page) + .unwrap() + .set("Rotate", 90); + let mut changed = vec![]; + update.save_to(&mut changed).unwrap(); + let twice = signed(RSA_KEY, &changed, &options(None)); let reports = verify_pdf(&twice).unwrap(); let rsa_name = reports[1].signer.as_ref().unwrap().name.clone(); let shown = trust::checked_all(reports, None, &Default::default()); @@ -694,3 +763,93 @@ fn says_which_signature_signed_the_changes_after_an_earlier_one() { assert_eq!(shown[1].status, trust::Status::Valid); assert_eq!(shown[1].later_signer, None); } + +#[test] +fn keeps_a_signature_valid_that_later_revisions_only_signed_again() { + let first = signed(key(), &pdf(), &options(Some([72., 72., 200., 50.]))); + // a second signer in a field of its own, then a third invisibly + let twice = signed(RSA_KEY, &first, &options(Some([300., 72., 200., 50.]))); + let thrice = signed(key(), &twice, &options(None)); + let reports = verify_pdf(&thrice).unwrap(); + assert_eq!( + reports.iter().map(|r| r.coverage).collect::>(), + [ + crate::signing::verify::Coverage::SignedOnlyAgain, + crate::signing::verify::Coverage::SignedOnlyAgain, + crate::signing::verify::Coverage::WholeFile, + ] + ); + let rsa_name = reports[1].signer.as_ref().unwrap().name.clone(); + let shown = trust::checked_all(reports, None, &Default::default()); + assert!(shown.iter().all(|s| s.status == trust::Status::Valid)); + // who signed next is still said + assert_eq!(shown[0].later_signer.as_deref(), Some(rsa_name.as_str())); + assert_eq!(shown[2].later_signer, None); +} + +#[test] +fn calls_a_page_content_rewritten_before_a_later_signature_a_change() { + let first = signed(key(), &pdf(), &options(None)); + // the page's content stream rewritten in place, under its own number + let doc = Document::load_mem(&first).unwrap(); + let page = *doc.get_pages().values().next().unwrap(); + let contents = doc + .get_dictionary(page) + .unwrap() + .get(b"Contents") + .unwrap() + .as_reference() + .unwrap(); + let mut update = lopdf::IncrementalDocument::create_from(first.clone(), doc); + update.new_document.objects.insert( + contents, + Object::Stream(Stream::new( + dictionary! {}, + b"BT /F1 12 Tf 72 700 Td (Changed) Tj ET".to_vec(), + )), + ); + let mut changed = vec![]; + update.save_to(&mut changed).unwrap(); + let twice = signed(RSA_KEY, &changed, &options(None)); + assert_eq!( + verify_pdf(&twice).unwrap()[0].coverage, + Coverage::SignedAgainLater + ); +} + +#[test] +fn calls_a_page_content_freed_before_a_later_signature_a_change() { + let first = signed(key(), &pdf(), &options(None)); + let twice = signed(RSA_KEY, &with_contents_freed(first), &options(None)); + assert_eq!( + verify_pdf(&twice).unwrap()[0].coverage, + Coverage::SignedAgainLater + ); +} + +#[test] +fn calls_a_note_added_before_a_later_signature_a_change() { + let first = signed(key(), &pdf(), &options(None)); + // a new annotation that isn't a signature's, on the page + let doc = Document::load_mem(&first).unwrap(); + let page = *doc.get_pages().values().next().unwrap(); + let mut update = lopdf::IncrementalDocument::create_from(first.clone(), doc); + let note = update.new_document.add_object(dictionary! { + "Type" => "Annot", "Subtype" => "Text", + "Rect" => vec![0.into(), 0.into(), 10.into(), 10.into()], + }); + update.opt_clone_object_to_new_document(page).unwrap(); + update + .new_document + .get_dictionary_mut(page) + .unwrap() + .set("Annots", vec![Object::Reference(note)]); + let mut noted = vec![]; + update.save_to(&mut noted).unwrap(); + let twice = signed(RSA_KEY, ¬ed, &options(None)); + let reports = verify_pdf(&twice).unwrap(); + assert_eq!( + reports[0].coverage, + crate::signing::verify::Coverage::SignedAgainLater + ); +} diff --git a/src-tauri/src/signing/mod.rs b/src-tauri/src/signing/mod.rs index 5226e0e7..f5b10c3c 100644 --- a/src-tauri/src/signing/mod.rs +++ b/src-tauri/src/signing/mod.rs @@ -32,6 +32,16 @@ pub enum Refusal { Restricted, /// a certified PDF that allows no changes (DocMDP P=1) Certified, + /// stamps in the pages' content of a PDF that is signed or certified + SignedPages, + /// the signature field asked for isn't in the PDF + NoSuchField, + /// the signature field asked for is signed already + FieldSigned, + /// an earlier signer locked the signature field asked for + FieldLocked, + /// an earlier signer locked a field the user filled in + FilledLocked, /// the timestamp server can't be reached or didn't answer NoTimestamp, /// the timestamp server answered, and refused @@ -44,9 +54,18 @@ impl std::fmt::Display for Refusal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Refusal::Restricted => { - "This PDF has restrictions set by its author. Blank can't sign it yet." + "This PDF has restrictions set by its author. Blank can't sign it." } Refusal::Certified => "This PDF doesn't allow any changes, not even signatures", + Refusal::SignedPages => { + "This PDF is signed already: what you placed on its pages would change what was signed. Only your signature can still be added." + } + Refusal::NoSuchField => "This PDF has no signature field of that name", + Refusal::FieldSigned => "This signature field is signed already", + Refusal::FieldLocked => "An earlier signer locked this signature field", + Refusal::FilledLocked => { + "An earlier signer locked a field you filled in, so it can't change any more. Clear what you filled in there and sign again." + } Refusal::NoTimestamp => "The timestamp server didn't answer", Refusal::TimestampRefused => "The timestamp server refused to timestamp the signature", Refusal::TimestampUnusable => "The timestamp server's answer can't be used", diff --git a/src-tauri/src/signing/pdf/mod.rs b/src-tauri/src/signing/pdf/mod.rs index 2a0d62e8..a68d7344 100644 --- a/src-tauri/src/signing/pdf/mod.rs +++ b/src-tauri/src/signing/pdf/mod.rs @@ -1,10 +1,23 @@ //! loading a PDF for an incremental update, and what signing needs of it: //! page geometry, inherited attributes, text strings +//! +//! - `rules`: what may be added to a PDF (DocMDP, earlier signatures, +//! FieldMDP locks) +//! - `xobject`: a stamp's one-page PDF as a Form XObject +//! - `stamp`: stamps drawn in a page's content +//! - `sign`: the update itself: stamps, fields, the signature +pub mod rules; pub mod sign; +pub mod stamp; +pub mod xobject; + +use std::collections::BTreeMap; use anyhow::{bail, Context, Result}; -use lopdf::{Dictionary, Document, IncrementalDocument, Object, ObjectId, StringFormat}; +use lopdf::{ + Dictionary, Document, IncrementalDocument, LoadOptions, Object, ObjectId, StringFormat, +}; use crate::signing::Refusal; @@ -40,6 +53,21 @@ fn looks_like_xref_at(bytes: &[u8], at: usize) -> bool { if n.parse::().is_ok() && g.parse::().is_ok() && o.starts_with("obj")) } +/// the most one of a PDF's object or cross-reference streams may +/// decompress to while it's read: lopdf decodes them all up front, so a +/// small file could otherwise fill the memory (a decompression bomb) +pub(super) const MAX_DECOMPRESSED: usize = 128 << 20; + +/// load_bounded reads a PDF Blank didn't write, its streams decompressed +/// within `MAX_DECOMPRESSED` each +pub fn load_bounded(bytes: &[u8]) -> Result { + let options = LoadOptions { + max_decompressed_size: Some(MAX_DECOMPRESSED), + ..Default::default() + }; + Document::load_mem_with_options(bytes, options).context("can't read the PDF") +} + /// load_for_update reads a PDF that is about to get an incremental update, /// refusing what can't safely be updated pub fn load_for_update(bytes: &[u8]) -> Result { @@ -52,7 +80,7 @@ pub fn load_for_update(bytes: &[u8]) -> Result { if !looks_like_xref_at(bytes, startxref) { bail!("the cross-reference offset at the end of the file is wrong; the file would need repairing"); } - let doc = Document::load_mem(bytes).context("can't read the PDF")?; + let doc = load_bounded(bytes)?; if doc.xref_start == 0 || doc.xref_start != startxref { bail!("the cross-reference table had to be reconstructed; the file would need repairing"); } @@ -102,6 +130,8 @@ const MAX_NAME: usize = 1_024; /// a field of a PDF's form, as form_fields finds it pub struct FormField<'a> { + /// its object, when it is one of its own (as fields are) + pub id: Option, pub dict: &'a Dictionary, /// its partial name, `/T` as written pub partial: Option<&'a [u8]>, @@ -189,6 +219,7 @@ pub fn form_fields(doc: &Document) -> Vec> { } } out.push(FormField { + id, dict, partial, name, @@ -212,8 +243,8 @@ pub fn inherited<'a>(doc: &'a Document, page: ObjectId, key: &[u8]) -> Option<&' None } -/// numbers reads an array of numbers -fn numbers(o: &Object, doc: &Document) -> Option> { +/// numbers reads an array of numbers, e.g. a box +pub fn numbers(o: &Object, doc: &Document) -> Option> { let arr = resolve(doc, o)?.as_array().ok()?; arr.iter() .map(|v| match resolve(doc, v)? { @@ -296,6 +327,57 @@ impl PageGeometry { [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)] } + /// visible_size is the page as shown, in points: its width and height + pub fn visible_size(&self) -> (f64, f64) { + let w = (self.bbox[2] - self.bbox[0]) * self.user_unit; + let h = (self.bbox[3] - self.bbox[1]) * self.user_unit; + if self.rotate % 180 == 0 { + (w, h) + } else { + (h, w) + } + } + + /// placement is the matrix that draws a form made upright, its BBox + /// `[0 0 w h]` in points, into `user` (a user-space rectangle that + /// rect_to_user gave for a w × h rectangle) on this page + pub fn placement(&self, user: [f64; 4]) -> [f64; 6] { + let s = 1.0 / self.user_unit; + // turned counter-clockwise by the page's rotation, which the viewer + // turns clockwise again + let (a, b, c, d) = match self.rotate { + 90 => (0., s, -s, 0.), + 180 => (-s, 0., 0., -s), + 270 => (0., -s, s, 0.), + _ => (s, 0., 0., s), + }; + // where the form's origin lands: the corner that ends up lowest-left + let (e, f) = match self.rotate { + 90 => (user[2], user[1]), + 180 => (user[2], user[3]), + 270 => (user[0], user[3]), + _ => (user[0], user[1]), + }; + [a, b, c, d, e, f] + } + + /// user_to_display turns a user-space rectangle [llx lly urx ury] into + /// x, y, w, h in points from the top left of the page as shown + pub fn user_to_display(&self, r: [f64; 4]) -> [f64; 4] { + let [x0, y0, x1, y1] = self.bbox; + let u = self.user_unit; + // the corners in display space, by the inverse of point_to_user + let corner = |x: f64, y: f64| match self.rotate { + 90 => ((y - y0) * u, (x - x0) * u), + 180 => ((x1 - x) * u, (y - y0) * u), + 270 => ((y1 - y) * u, (x1 - x) * u), + _ => ((x - x0) * u, (y1 - y) * u), + }; + let (ax, ay) = corner(r[0], r[1]); + let (bx, by) = corner(r[2], r[3]); + [ax.min(bx), ay.min(by), (ax - bx).abs(), (ay - by).abs()] + } + /// appearance_matrix is the rotation alone, for a widget's appearance /// /Matrix pub fn appearance_matrix(&self) -> [f64; 6] { @@ -308,6 +390,36 @@ impl PageGeometry { } } +/// is_rect tells whether `r` (x, y, w, h) is a rectangle with room in it +pub fn is_rect(r: &[f64; 4]) -> bool { + r.iter().all(|value| value.is_finite()) && r[2] > 0. && r[3] > 0. +} + +/// current is the object `id` as this update has it: changed, or as it was +pub fn current(incr: &IncrementalDocument, id: ObjectId) -> Result<&Object> { + match incr.new_document.objects.get(&id) { + Some(object) => Ok(object), + None => Ok(incr.get_prev_documents().get_object(id)?), + } +} + +/// pdfa_part is the part of PDF/A the document claims in its XMP +/// metadata (`pdfaid:part`), if it claims one +pub fn pdfa_part(doc: &Document) -> Option { + let catalog = doc.catalog().ok()?; + let metadata = resolve(doc, catalog.get(b"Metadata").ok()?)? + .as_stream() + .ok()?; + let xmp = metadata.get_plain_content_with_limit(1 << 20).ok()?; + let text = String::from_utf8_lossy(&xmp); + // as an element, 1, or an attribute, + // pdfaid:part="1" + let at = text.find("pdfaid:part")? + "pdfaid:part".len(); + let rest = text[at..].trim_start_matches(['>', '=', '"', '\'', ' ']); + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + digits.parse().ok() +} + /// reals is an array of reals pub fn reals(v: &[f64]) -> Object { Object::Array(v.iter().map(|x| Object::Real(*x as f32)).collect()) @@ -350,7 +462,13 @@ pub fn clean_trailer(incr: &mut IncrementalDocument) { /// page_id returns the object of page `page`, from 1 pub fn page_id(doc: &Document, page: u32) -> Result { - doc.get_pages() + page_in(&doc.get_pages(), page) +} + +/// page_in is page `page` (from 1) of `pages`, as `Document::get_pages` +/// gives them +pub fn page_in(pages: &BTreeMap, page: u32) -> Result { + pages .get(&page) .copied() .with_context(|| format!("no page {page}")) diff --git a/src-tauri/src/signing/pdf/rules.rs b/src-tauri/src/signing/pdf/rules.rs new file mode 100644 index 00000000..c43acae8 --- /dev/null +++ b/src-tauri/src/signing/pdf/rules.rs @@ -0,0 +1,157 @@ +//! what may be added to a PDF (see .claude/rules/signing.md), read once: a +//! certification's DocMDP permission, whether it is signed already, and +//! the fields earlier signatures locked (FieldMDP). Encrypted PDFs never +//! get this far: `load_for_update` refuses them. + +use std::collections::HashSet; + +use lopdf::{Dictionary, Document, Object}; + +use super::{docmdp_permission, form_fields, resolve}; + +/// the fields one signature locks once it is signed +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Lock { + /// every field + All, + /// only these, by full name + Include(Vec), + /// every field but these + Exclude(Vec), +} + +impl Lock { + /// read reads a FieldMDP lock: a field's `/Lock`, or the + /// TransformParams of a FieldMDP reference + fn read(doc: &Document, dict: &Dictionary) -> Option { + let names = || -> Vec { + dict.get(b"Fields") + .ok() + .and_then(|f| resolve(doc, f)) + .and_then(|f| f.as_array().ok()) + .map(|fields| { + fields + .iter() + .filter_map(|f| resolve(doc, f)) + .filter_map(|f| lopdf::decode_text_string(f).ok()) + .collect() + }) + .unwrap_or_default() + }; + match dict.get(b"Action").and_then(Object::as_name).ok()? { + b"All" => Some(Lock::All), + b"Include" => Some(Lock::Include(names())), + b"Exclude" => Some(Lock::Exclude(names())), + _ => None, + } + } + + /// locks tells whether this lock covers the field `name` + fn locks(&self, name: &str) -> bool { + match self { + Lock::All => true, + Lock::Include(names) => names.iter().any(|n| n == name), + Lock::Exclude(names) => !names.iter().any(|n| n == name), + } + } +} + +/// what may be added to a PDF +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Rules { + /// the DocMDP permission of a certified PDF: 1 no changes, 2 filling in + /// and signing, 3 annotations too + pub docmdp: Option, + /// a signature field of it is signed + pub signed: bool, + /// what the signed signatures lock + pub locks: Vec, +} + +impl Rules { + /// read reads the rules of `doc` + pub fn read(doc: &Document) -> Self { + let docmdp = docmdp_permission(doc); + let mut signed = docmdp.is_some(); + let mut locks = vec![]; + let mut seen = HashSet::new(); + for field in form_fields(doc).iter().filter(|field| field.signature) { + let Some(value) = field + .dict + .get(b"V") + .ok() + .and_then(|v| resolve(doc, v)) + .and_then(|v| v.as_dict().ok()) + else { + continue; + }; + signed = true; + // a lock on the field takes effect once it is signed + if let Some(lock) = field + .dict + .get(b"Lock") + .ok() + .and_then(|l| resolve(doc, l)) + .and_then(|l| l.as_dict().ok()) + .and_then(|l| Lock::read(doc, l)) + { + if seen.insert(format!("{lock:?}")) { + locks.push(lock); + } + } + // and the signature's own FieldMDP reference + let references = value + .get(b"Reference") + .ok() + .and_then(|r| resolve(doc, r)) + .and_then(|r| r.as_array().ok()); + for reference in references.into_iter().flatten() { + let Some(reference) = resolve(doc, reference).and_then(|r| r.as_dict().ok()) else { + continue; + }; + if reference + .get(b"TransformMethod") + .and_then(Object::as_name) + .ok() + != Some(b"FieldMDP") + { + continue; + } + if let Some(lock) = reference + .get(b"TransformParams") + .ok() + .and_then(|p| resolve(doc, p)) + .and_then(|p| p.as_dict().ok()) + .and_then(|p| Lock::read(doc, p)) + { + if seen.insert(format!("{lock:?}")) { + locks.push(lock); + } + } + } + } + Self { + docmdp, + signed, + locks, + } + } + + /// allows_changes tells whether anything may be added, a signature + /// included: not to a certified PDF that allows no changes + pub fn allows_changes(&self) -> bool { + self.docmdp != Some(1) + } + + /// allows_page_content tells whether stamps may go into the pages' + /// content: not once the PDF is signed or certified (P=2 or 3 allow + /// filling in and signing, not changing the pages) + pub fn allows_page_content(&self) -> bool { + !self.signed && self.docmdp.is_none() + } + + /// locks tells whether an earlier signature locked the field `name` + pub fn locks(&self, name: &str) -> bool { + self.locks.iter().any(|lock| lock.locks(name)) + } +} diff --git a/src-tauri/src/signing/pdf/sign.rs b/src-tauri/src/signing/pdf/sign.rs index 4d2d3c1d..e8c2d9a7 100644 --- a/src-tauri/src/signing/pdf/sign.rs +++ b/src-tauri/src/signing/pdf/sign.rs @@ -1,24 +1,30 @@ -//! signing a PDF in an incremental update: a new /Sig field and widget, -//! the signature dictionary with a reserved /Contents, then the byte ranges -//! are patched in, hashed and signed +//! an incremental update of a PDF: stamps in its pages' content, empty +//! signature fields for others, and a signature: a /Sig field and widget +//! (one the PDF has, or a new one), the signature dictionary with a +//! reserved /Contents, then the byte ranges are patched in, hashed and +//! signed. Without a signer the update holds the stamps and fields alone. +use std::collections::HashMap; + +use super::rules::Rules; +use super::stamp::{appearance, draw_on_page, repeat_into, Draw}; +use super::xobject::{import_stamp, Imported}; use super::{ - clean_trailer, docmdp_permission, load_for_update, page_id, reals, text_string, PageGeometry, + clean_trailer, current, form_fields, is_rect, load_for_update, numbers, page_in, pdfa_part, + reals, resolve, text_string, PageGeometry, }; use crate::signing::cms::sign_detached; use crate::signing::keys::Identity; use crate::signing::Refusal; use anyhow::{bail, Context, Result}; -use lopdf::{dictionary, Dictionary, IncrementalDocument, Object, ObjectId, Stream, StringFormat}; +use lopdf::{ + dictionary, Dictionary, Document, IncrementalDocument, Object, ObjectId, StringFormat, +}; use sha2::{Digest, Sha256}; -/// what to sign with, and where the signature goes -pub struct SignRequest<'a> { +/// who signs, and what the signature says +pub struct Signer<'a> { pub identity: &'a Identity, - /// 1-based page of the signature widget - pub page: u32, - /// x, y, w, h in points from the top left of the page as shown; None: invisible - pub rect: Option<[f64; 4]>, pub reason: Option, pub location: Option, pub name: Option, @@ -27,9 +33,59 @@ pub struct SignRequest<'a> { pub reserve: usize, } +/// a stamp to put on the pages: its one-page PDF, made by Blank's engine +pub struct PlacedStamp<'a> { + pub pdf: &'a [u8], + /// from 1 + pub page: u32, + /// x, y, w, h in points from the top left of the page as shown + pub rect: [f64; 4], + /// on every page, as far from the nearer edges as on `page` + /// (`repeat_into`) + pub repeat: bool, +} + +/// where the signature goes, and what it shows +pub enum Spot<'a> { + /// nowhere on the pages: PDF apps show it in their signature panel + Invisible, + /// the empty signature field of this full name + Existing { + field: String, + stamp: Option<&'a [u8]>, + }, + /// a new field, `SignatureN` + New { + /// from 1 + page: u32, + rect: [f64; 4], + stamp: Option<&'a [u8]>, + }, +} + +/// an empty signature field for someone else to sign later +pub struct Other { + pub page: u32, + pub rect: [f64; 4], + /// who is to sign it: the field is "Signature of ‹name›" + pub name: String, +} + +/// what goes into the update +pub struct Plan<'a> { + pub stamps: Vec>, + pub signature: Spot<'a>, + pub others: Vec, + /// the fields the user filled in (full names), in a revision of their + /// own before this one: none may be one an earlier signature locked + pub filled: Vec<&'a str>, +} + /// the most room for a signature: a long chain and a timestamp token fit /// many times over const MAX_RESERVE: usize = 256 * 1024; +/// the most stamps and other signers' fields one update takes +const MAX_STAMPS: usize = 1_000; /// how a signing went: signed, or the signature needed more room enum Attempt { @@ -40,20 +96,44 @@ enum Attempt { const BR_PLACEHOLDER: [i64; 4] = [0, 1_111_111_111, 2_222_222_222, 3_333_333_333]; -/// sign_pdf signs `original` as `req` asks, and returns it with the -/// signature appended; a signature larger than `req.reserve` is made again -/// with room for it -pub fn sign_pdf(original: &[u8], req: &SignRequest) -> Result> { - if let Some(rect) = req.rect { - if !rect.iter().all(|value| value.is_finite()) || rect[2] <= 0. || rect[3] <= 0. { - bail!("the signature's place isn't a rectangle"); +/// update_pdf appends `plan` to `original`, which `load_for_update` read +/// as `doc`, as an incremental update, signed by `signer`, or with no +/// signature without one. A signature larger than `signer.reserve` is made +/// again with room for it. +pub fn update_pdf( + original: &[u8], + doc: Document, + plan: &Plan, + signer: Option<&Signer>, +) -> Result> { + let rects = plan + .stamps + .iter() + .map(|s| &s.rect) + .chain(plan.others.iter().map(|o| &o.rect)) + .chain(match &plan.signature { + Spot::New { rect, .. } => Some(rect), + _ => None, + }); + for rect in rects { + if !is_rect(rect) { + bail!("a place on the page isn't a rectangle"); } } - match sign_once(original, req, req.reserve)? { + if plan.stamps.len() + plan.others.len() > MAX_STAMPS { + bail!("too many stamps"); + } + let Some(signer) = signer else { + let (incr, _) = build(original, doc, plan, None)?; + return save(incr, original); + }; + match sign_once(original, doc, plan, signer, signer.reserve)? { Attempt::Signed(signed) => Ok(signed), - // a new signature, and a new timestamp, may be a little longer + // a new signature, and a new timestamp, may be a little longer; + // the PDF read again, which the first try took Attempt::TooLarge(length) if length + 2048 <= MAX_RESERVE => { - match sign_once(original, req, length + 2048)? { + let doc = load_for_update(original)?; + match sign_once(original, doc, plan, signer, length + 2048)? { Attempt::Signed(signed) => Ok(signed), Attempt::TooLarge(_) => bail!("the signature doesn't fit"), } @@ -62,6 +142,18 @@ pub fn sign_pdf(original: &[u8], req: &SignRequest) -> Result> { } } +/// save writes the update after `original` +fn save(mut incr: IncrementalDocument, original: &[u8]) -> Result> { + let mut out = Vec::new(); + incr.save_to(&mut out)?; + // lopdf ends with "%%EOF" and no newline; add one like most writers + out.push(b'\n'); + if !out.starts_with(original) { + bail!("the update changed the bytes before it"); + } + Ok(out) +} + /// pdf_date is now, as a PDF date fn pdf_date() -> String { let t = time::OffsetDateTime::now_utc(); @@ -78,12 +170,7 @@ fn pdf_date() -> String { /// add_annot adds `widget` to the page's /Annots, wherever that array lives fn add_annot(incr: &mut IncrementalDocument, page: ObjectId, widget: ObjectId) -> Result<()> { - let annots = incr - .get_prev_documents() - .get_dictionary(page)? - .get(b"Annots") - .ok() - .cloned(); + let annots = current(incr, page)?.as_dict()?.get(b"Annots").ok().cloned(); match annots { Some(Object::Reference(r)) => { incr.opt_clone_object_to_new_document(r)?; @@ -107,40 +194,46 @@ fn add_annot(incr: &mut IncrementalDocument, page: ObjectId, widget: ObjectId) - Ok(()) } -/// add_field adds the field to /AcroForm /Fields and sets /SigFlags 3 -fn add_field(incr: &mut IncrementalDocument, field: ObjectId) -> Result<()> { +/// add_fields adds `fields` to /AcroForm /Fields, and with `signed` sets +/// /SigFlags 3 (signatures exist, append only) +fn add_fields(incr: &mut IncrementalDocument, fields: &[ObjectId], signed: bool) -> Result<()> { + if fields.is_empty() && !signed { + return Ok(()); + } let root = incr .get_prev_documents() .trailer .get(b"Root")? .as_reference()?; - let form = incr - .get_prev_documents() - .get_dictionary(root)? + let form = current(incr, root)? + .as_dict()? .get(b"AcroForm") .ok() .cloned(); let update = |incr: &mut IncrementalDocument, form: &mut Dictionary| -> Result<()> { - form.set("SigFlags", 3); + if signed { + form.set("SigFlags", 3); + } + let added = fields.iter().map(|f| Object::Reference(*f)); match form.get(b"Fields").ok().cloned() { Some(Object::Reference(r)) => { incr.opt_clone_object_to_new_document(r)?; match incr.new_document.get_object_mut(r)? { - Object::Array(a) => a.push(Object::Reference(field)), + Object::Array(a) => a.extend(added), _ => bail!("/Fields doesn't point to an array"), } } Some(Object::Array(mut a)) => { - a.push(Object::Reference(field)); + a.extend(added); form.set("Fields", a); } - _ => form.set("Fields", vec![Object::Reference(field)]), + _ => form.set("Fields", added.collect::>()), } Ok(()) }; match form { Some(Object::Reference(r)) => { - let mut f = incr.get_prev_documents().get_dictionary(r)?.clone(); + let mut f = current(incr, r)?.as_dict()?.clone(); update(incr, &mut f)?; incr.new_document.set_object(r, f); } @@ -164,82 +257,326 @@ fn add_field(incr: &mut IncrementalDocument, field: ObjectId) -> Result<()> { Ok(()) } -/// appearance is the widget's appearance: an empty form. What the -/// signature looks like is in the page content (a signature block Blank -/// drew), or there is none (an invisible signature); Blank draws no frame -/// around a signature. -fn appearance(incr: &mut IncrementalDocument, geo: &PageGeometry, w: f64, h: f64) -> ObjectId { - let dict = dictionary! { - "Type" => "XObject", "Subtype" => "Form", "BBox" => reals(&[0., 0., w, h]), - "Matrix" => reals(&geo.appearance_matrix()), +/// free_field_name is `wanted`, or `wanted` with the first number that +/// makes it a name no field has; `numbered` starts with `wanted1` +fn free_field_name(taken: &mut Vec, wanted: &str, numbered: bool) -> String { + let name = (if numbered { 1 } else { 0 }..) + .map(|n| match n { + 0 => wanted.to_string(), + n if numbered => format!("{wanted}{n}"), + n => format!("{wanted} {}", n + 1), + }) + .find(|name| !taken.contains(name)) + .unwrap_or_default(); + taken.push(name.clone()); + name +} + +/// other_field_name is the field name for someone else's signature: a +/// partial name can't hold a period, which joins the parts of full names +fn other_field_name(name: &str) -> String { + let name: String = name + .chars() + .filter(|c| !c.is_control()) + .map(|c| if c == '.' { ' ' } else { c }) + .collect(); + let name = name.split_whitespace().collect::>().join(" "); + if name.is_empty() { + "Signature".into() + } else { + format!("Signature of {name}") + } +} + +/// an empty signature field the PDF has, and the widget showing it +struct Target { + field: ObjectId, + widget: ObjectId, + page: ObjectId, + /// the widget's rectangle in user space + rect: [f64; 4], +} + +/// find_target finds the empty signature field `name` of the PDF in `incr` +fn find_target(incr: &IncrementalDocument, rules: &Rules, name: &str) -> Result { + let doc = incr.get_prev_documents(); + let fields = form_fields(doc); + let field = fields + .iter() + .filter(|f| f.signature && f.name == name) + // a field with kids is the one the kids' values belong to + .find(|f| f.partial.is_some()) + .ok_or(Refusal::NoSuchField)?; + if field.dict.has(b"V") { + return Err(Refusal::FieldSigned.into()); + } + if rules.locks(name) { + return Err(Refusal::FieldLocked.into()); + } + let field_id = field.id.context("a signature field isn't an object")?; + let is_widget = + |d: &Dictionary| d.get(b"Subtype").and_then(Object::as_name).ok() == Some(b"Widget"); + let widget = if is_widget(field.dict) { + field_id + } else { + field + .dict + .get(b"Kids") + .ok() + .and_then(|k| resolve(doc, k)) + .and_then(|k| k.as_array().ok()) + .into_iter() + .flatten() + .filter_map(|k| k.as_reference().ok()) + .find(|k| doc.get_dictionary(*k).is_ok_and(is_widget)) + .context("a signature field has no widget")? }; - incr.new_document.add_object(Stream::new(dict, Vec::new())) + let widget_dict = doc.get_dictionary(widget)?; + let page = match widget_dict.get(b"P").and_then(Object::as_reference) { + Ok(page) if doc.get_pages().values().any(|p| *p == page) => page, + _ => doc + .get_pages() + .values() + .copied() + .find(|page| { + doc.get_dictionary(*page) + .ok() + .and_then(|p| p.get(b"Annots").ok()) + .and_then(|a| resolve(doc, a)) + .and_then(|a| a.as_array().ok()) + .is_some_and(|a| a.iter().any(|a| a.as_reference().ok() == Some(widget))) + }) + .context("a signature field isn't on a page")?, + }; + let Some([x0, y0, x1, y1]) = widget_dict + .get(b"Rect") + .ok() + .and_then(|r| numbers(r, doc)) + .and_then(|n| <[f64; 4]>::try_from(n).ok()) + else { + bail!("a signature field's widget has no rectangle"); + }; + Ok(Target { + field: field_id, + widget, + page, + rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], + }) } -/// sign_once signs `original` with `reserve` bytes of room for the DER -/// signature -fn sign_once(original: &[u8], req: &SignRequest, reserve: usize) -> Result { - let doc = load_for_update(original)?; - // P=2 allows signing, though Acrobat may count the new field as a - // change it didn't allow - if docmdp_permission(&doc) == Some(1) { +/// the stamps of a plan, each copied into the update once +struct Forms { + pdfa1: bool, + imported: HashMap<(usize, usize), Imported>, +} + +impl Forms { + fn get(&mut self, incr: &mut IncrementalDocument, pdf: &[u8]) -> Result { + let key = (pdf.as_ptr() as usize, pdf.len()); + if let Some(form) = self.imported.get(&key) { + return Ok(*form); + } + let form = import_stamp(incr, pdf, self.pdfa1)?; + self.imported.insert(key, form); + Ok(form) + } +} + +/// build makes the update `plan` asks for of `original` (read as `doc`), +/// signed with the signature dictionary `signature` if there is one +fn build( + original: &[u8], + doc: Document, + plan: &Plan, + signature: Option, +) -> Result<(IncrementalDocument, Option)> { + let rules = Rules::read(&doc); + if !rules.allows_changes() { return Err(Refusal::Certified.into()); } - let names: Vec> = super::form_fields(&doc) - .iter() - .filter_map(|field| field.partial.map(<[u8]>::to_vec)) - .collect(); + if plan.filled.iter().any(|name| rules.locks(name)) { + return Err(Refusal::FilledLocked.into()); + } + let mut taken: Vec = form_fields(&doc).into_iter().map(|f| f.name).collect(); + let mut forms = Forms { + pdfa1: pdfa_part(&doc) == Some(1), + imported: HashMap::new(), + }; let mut incr = IncrementalDocument::create_from(original.to_vec(), doc); clean_trailer(&mut incr); + let pages = incr.get_prev_documents().get_pages(); + let page_of = |number: u32| page_in(&pages, number); + let signing = signature.is_some(); + + let target = match &plan.signature { + Spot::Existing { field, .. } => Some(find_target(&incr, &rules, field)?), + _ => None, + }; + // the signature's stamp: in the page content on a PDF nobody signed + // yet, else as the signature's appearance; without a signature always + // in the page content + let signature_stamp = match &plan.signature { + Spot::New { stamp, .. } | Spot::Existing { stamp, .. } => *stamp, + Spot::Invisible => None, + }; + let stamp_in_content = signature_stamp.is_some() + && match &plan.signature { + Spot::New { .. } => !signing || rules.allows_page_content(), + _ => !signing, + }; - let page = page_id(incr.get_prev_documents(), req.page)?; - let geo = PageGeometry::of(incr.get_prev_documents(), page)?; - let date = pdf_date(); + // the stamps in the pages' content + let mut draws: Vec<(ObjectId, Draw)> = vec![]; + for stamp in &plan.stamps { + let form = forms.get(&mut incr, stamp.pdf)?; + if stamp.repeat { + let size = |page: ObjectId| { + PageGeometry::of(incr.get_prev_documents(), page).map(|geo| geo.visible_size()) + }; + let placed = size(page_of(stamp.page)?)?; + for page in pages.values() { + let rect = repeat_into(stamp.rect, placed, size(*page)?); + draws.push((*page, Draw { form, rect })); + } + } else { + draws.push(( + page_of(stamp.page)?, + Draw { + form, + rect: stamp.rect, + }, + )); + } + } + if let (true, Some(pdf)) = (stamp_in_content, signature_stamp) { + let form = forms.get(&mut incr, pdf)?; + match (&plan.signature, &target) { + (Spot::New { page, rect, .. }, _) => { + draws.push((page_of(*page)?, Draw { form, rect: *rect })); + } + (Spot::Existing { .. }, Some(target)) => { + let geo = PageGeometry::of(incr.get_prev_documents(), target.page)?; + let rect = geo.user_to_display(target.rect); + draws.push((target.page, Draw { form, rect })); + } + _ => {} + } + } + if !draws.is_empty() && !rules.allows_page_content() { + return Err(Refusal::SignedPages.into()); + } + let mut by_page: Vec<(ObjectId, Vec)> = vec![]; + for (page, draw) in draws { + match by_page.iter_mut().find(|(p, _)| *p == page) { + Some((_, list)) => list.push(draw), + None => by_page.push((page, vec![draw])), + } + } + for (page, list) in &by_page { + draw_on_page(&mut incr, *page, list)?; + } + // empty signature fields for the others who sign + let mut new_fields = vec![]; + for other in &plan.others { + let page = page_of(other.page)?; + let geo = PageGeometry::of(incr.get_prev_documents(), page)?; + let ap = appearance(&mut incr, &geo, other.rect[2], other.rect[3], None); + let name = free_field_name(&mut taken, &other_field_name(&other.name), false); + let widget = incr.new_document.add_object(dictionary! { + "Type" => "Annot", "Subtype" => "Widget", "FT" => "Sig", + "T" => text_string(&name), "TU" => text_string(&name), + "F" => 4, "Rect" => reals(&geo.rect_to_user(other.rect)), "P" => page, + "AP" => dictionary! { "N" => ap }, + }); + add_annot(&mut incr, page, widget)?; + new_fields.push(widget); + } + + // the signature + let mut sig_id = None; + if let Some(sig) = signature { + let id = incr.new_document.add_object(sig); + sig_id = Some(id); + let shown = if stamp_in_content { + None + } else { + signature_stamp + .map(|pdf| forms.get(&mut incr, pdf)) + .transpose()? + }; + match (&plan.signature, target) { + (Spot::Existing { .. }, Some(target)) => { + let geo = PageGeometry::of(incr.get_prev_documents(), target.page)?; + let [_, _, w, h] = geo.user_to_display(target.rect); + let ap = appearance(&mut incr, &geo, w, h, shown); + incr.opt_clone_object_to_new_document(target.field)?; + incr.new_document + .get_dictionary_mut(target.field)? + .set("V", id); + incr.opt_clone_object_to_new_document(target.widget)?; + let widget = incr.new_document.get_dictionary_mut(target.widget)?; + widget.set("AP", dictionary! { "N" => ap }); + widget.set("F", 4); + widget.remove(b"AS"); + } + (spot, _) => { + let (page, rect) = match spot { + Spot::New { page, rect, .. } => (page_of(*page)?, Some(*rect)), + _ => (page_of(1)?, None), + }; + let geo = PageGeometry::of(incr.get_prev_documents(), page)?; + let (user, ap) = match rect { + Some(r) => ( + geo.rect_to_user(r), + appearance(&mut incr, &geo, r[2], r[3], shown), + ), + None => ([0.; 4], appearance(&mut incr, &geo, 0., 0., None)), + }; + let name = free_field_name(&mut taken, "Signature", true); + let widget = incr.new_document.add_object(dictionary! { + "Type" => "Annot", "Subtype" => "Widget", "FT" => "Sig", + "T" => text_string(&name), + "V" => id, "F" => 4, "Rect" => reals(&user), "P" => page, + "AP" => dictionary! { "N" => ap }, + }); + add_annot(&mut incr, page, widget)?; + new_fields.push(widget); + } + } + } + add_fields(&mut incr, &new_fields, signing)?; + Ok((incr, sig_id)) +} + +/// sign_once signs `original` with `reserve` bytes of room for the DER +/// signature +fn sign_once( + original: &[u8], + doc: Document, + plan: &Plan, + signer: &Signer, + reserve: usize, +) -> Result { let mut sig = dictionary! { "Type" => "Sig", "Filter" => "Adobe.PPKLite", "SubFilter" => "ETSI.CAdES.detached", "ByteRange" => BR_PLACEHOLDER.iter().map(|n| Object::Integer(*n)).collect::>(), "Contents" => Object::String(vec![0; reserve], StringFormat::Hexadecimal), - "M" => Object::String(date.clone().into_bytes(), StringFormat::Literal), + "M" => Object::String(pdf_date().into_bytes(), StringFormat::Literal), }; - if let Some(n) = &req.name { + if let Some(n) = &signer.name { sig.set("Name", text_string(n)); } - if let Some(r) = &req.reason { + if let Some(r) = &signer.reason { sig.set("Reason", text_string(r)); } - if let Some(l) = &req.location { + if let Some(l) = &signer.location { sig.set("Location", text_string(l)); } - let sig_id = incr.new_document.add_object(sig); - let (rect, ap) = match req.rect { - Some(r) => (geo.rect_to_user(r), appearance(&mut incr, &geo, r[2], r[3])), - None => ([0.; 4], appearance(&mut incr, &geo, 0., 0.)), - }; - let mut n = 1; - let field_name = loop { - let name = format!("Signature{n}"); - if !names.iter().any(|x| x == name.as_bytes()) { - break name; - } - n += 1; - }; - let widget = dictionary! { - "Type" => "Annot", "Subtype" => "Widget", "FT" => "Sig", - "T" => Object::String(field_name.into_bytes(), StringFormat::Literal), - "V" => sig_id, "F" => 4, "Rect" => reals(&rect), "P" => page, - "AP" => dictionary! { "N" => ap }, - }; - let widget_id = incr.new_document.add_object(widget); - add_annot(&mut incr, page, widget_id)?; - add_field(&mut incr, widget_id)?; - - let mut out = Vec::new(); - incr.save_to(&mut out)?; - // lopdf ends with "%%EOF" and no newline; add one like most writers - out.push(b'\n'); + let (incr, _) = build(original, doc, plan, Some(sig))?; + let mut out = save(incr, original)?; // patch /ByteRange and find /Contents in what we appended let appended = original.len(); @@ -277,14 +614,15 @@ fn sign_once(original: &[u8], req: &SignRequest, reserve: usize) -> Result Result [f64; 4] { + let [x, y, w, h] = rect; + let scale = (to.0 / w).min(to.1 / h).min(1.); + let (fitted_w, fitted_h) = (w * scale, h * scale); + // along one axis: the stamp's start and size on its page of `page`, + // into one of `into` at the size `fitted` + let along = |start: f64, size: f64, page: f64, into: f64, fitted: f64| { + let far = page - start - size; + let at = if start <= far { + start + } else { + into - far - fitted + }; + at.min(into - fitted).max(0.) + }; + [ + along(x, w, from.0, to.0, fitted_w), + along(y, h, from.1, to.1, fitted_h), + fitted_w, + fitted_h, + ] +} + +/// matrix draws `form` into the display rectangle `rect` of a page of +/// `geo`, stretched to it +fn matrix(geo: &PageGeometry, form: &Imported, rect: [f64; 4]) -> [f64; 6] { + let [a, b, c, d, e, f] = geo.placement(geo.rect_to_user(rect)); + let (sx, sy) = (rect[2] / form.width, rect[3] / form.height); + // first scaled to the rectangle, then placed + [sx * a, sx * b, sy * c, sy * d, e, f] +} + +/// operator writes the numbers of a matrix and `cm` +fn cm(m: &[f64; 6]) -> String { + let n: Vec = m.iter().map(|v| format!("{v:.4}")).collect(); + format!("{} cm", n.join(" ")) +} + +/// free_name is the first of BlankStamp0, BlankStamp1, … that `xobjects` +/// doesn't hold +fn free_name(xobjects: &Dictionary) -> String { + (0..) + .map(|n| format!("BlankStamp{n}")) + .find(|name| !xobjects.has(name.as_bytes())) + .unwrap_or_default() +} + +/// draw_on_page draws `draws` on the page `page`, over its content. The +/// page gets a direct /Resources, a copy of what it has or inherits with +/// the stamps added: a resources object other pages share is never edited. +pub fn draw_on_page(incr: &mut IncrementalDocument, page: ObjectId, draws: &[Draw]) -> Result<()> { + if draws.is_empty() { + return Ok(()); + } + let prev = incr.get_prev_documents(); + let geo = PageGeometry::of(prev, page)?; + let page_dict = current(incr, page)? + .as_dict() + .context("a page isn't a dictionary")?; + let mut resources = match page_dict.get(b"Resources") { + Ok(r) => resolve(prev, r).and_then(|r| r.as_dict().ok()).cloned(), + Err(_) => inherited(prev, page, b"Resources") + .and_then(|r| r.as_dict().ok()) + .cloned(), + } + .unwrap_or_default(); + let mut xobjects = resources + .get(b"XObject") + .ok() + .and_then(|x| resolve(prev, x)) + .and_then(|x| x.as_dict().ok()) + .cloned() + .unwrap_or_default(); + // the content: [q…, the old streams…, Q… and the stamps], with a q for + // each Q the page has too many and a Q for each q it leaves open, so the + // stamps are drawn in the page's own space whatever its content does + let mut contents: Vec = match page_dict.get(b"Contents") { + Ok(Object::Array(a)) => a.clone(), + Ok(Object::Reference(r)) => match current(incr, *r)? { + // an indirect array of streams + Object::Array(a) => a.clone(), + _ => vec![Object::Reference(*r)], + }, + Ok(_) => bail!("a page's /Contents isn't a stream or an array"), + Err(_) => vec![], + }; + let (closes, depth) = nesting(incr, &contents); + // what is open after the page: Blank's q, and the page's own + let open = (1 + closes as i64 + depth).max(1) as usize; + let mut ops = format!("\n{}", "Q\n".repeat(open)); + for draw in draws { + let name = free_name(&xobjects); + xobjects.set(name.as_bytes(), draw.form.id); + ops.push_str(&format!( + "q {} /{name} Do Q\n", + cm(&matrix(&geo, &draw.form, draw.rect)) + )); + } + resources.set("XObject", xobjects); + + let open = incr.new_document.add_object(Stream::new( + Dictionary::new(), + "q\n".repeat(1 + closes).into_bytes(), + )); + let close = incr + .new_document + .add_object(Stream::new(Dictionary::new(), ops.into_bytes())); + contents.insert(0, Object::Reference(open)); + contents.push(Object::Reference(close)); + + incr.opt_clone_object_to_new_document(page)?; + let p = incr.new_document.get_dictionary_mut(page)?; + p.set("Contents", Object::Array(contents)); + p.set("Resources", resources); + Ok(()) +} + +/// nesting reads the page content `contents` and returns how many more Q +/// than q it has at its lowest, and how many more q than Q at its end +/// (fewer than none if its Qs win); (0, 0) for content that can't be read, +/// as most is balanced +fn nesting(incr: &IncrementalDocument, contents: &[Object]) -> (usize, i64) { + let mut bytes = vec![]; + for part in contents { + let Object::Reference(id) = part else { + continue; + }; + let Ok(Object::Stream(stream)) = current(incr, *id) else { + return (0, 0); + }; + let Ok(content) = stream.decompressed_content_with_limit(MAX_DECOMPRESSED) else { + return (0, 0); + }; + bytes.extend_from_slice(&content); + // streams are one content, split between tokens + bytes.push(b'\n'); + } + let Ok(content) = lopdf::content::Content::decode(&bytes) else { + return (0, 0); + }; + let (mut depth, mut lowest) = (0i64, 0i64); + for operation in &content.operations { + match operation.operator.as_str() { + "q" => depth += 1, + "Q" => { + depth -= 1; + lowest = lowest.min(depth); + } + _ => {} + } + } + ((-lowest) as usize, depth) +} + +/// appearance is a widget's appearance: a form of `w` × `h` points +/// (upright, turned with the page by its /Matrix) that shows `stamp` +/// fitted into it and centred, or nothing: Blank draws no frame around a +/// signature +pub fn appearance( + incr: &mut IncrementalDocument, + geo: &PageGeometry, + w: f64, + h: f64, + stamp: Option, +) -> ObjectId { + let mut dict = dictionary! { + "Type" => "XObject", "Subtype" => "Form", "BBox" => reals(&[0., 0., w, h]), + "Matrix" => reals(&geo.appearance_matrix()), + }; + let mut content = vec![]; + if let Some(stamp) = stamp.filter(|_| w > 0. && h > 0.) { + let s = (w / stamp.width).min(h / stamp.height); + let (dx, dy) = ((w - stamp.width * s) / 2., (h - stamp.height * s) / 2.); + content = format!("q {} /BlankStamp Do Q\n", cm(&[s, 0., 0., s, dx, dy])).into_bytes(); + dict.set( + "Resources", + dictionary! { "XObject" => dictionary! { "BlankStamp" => stamp.id } }, + ); + } + incr.new_document.add_object(Stream::new(dict, content)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// stamped draws a stamp on the one page of a PDF whose content is + /// `content`, and returns the page's content after + fn stamped(content: &[u8]) -> Vec { + let mut doc = lopdf::Document::with_version("1.7"); + let pages = doc.new_object_id(); + let stream = doc.add_object(Stream::new(Dictionary::new(), content.to_vec())); + let page = doc.add_object(dictionary! { + "Type" => "Page", "Parent" => pages, "Contents" => stream, + "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()], + }); + doc.objects.insert( + pages, + Object::Dictionary(dictionary! { + "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1, + }), + ); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + doc.trailer.set("Root", catalog); + let mut bytes = vec![]; + doc.save_to(&mut bytes).unwrap(); + let prev = lopdf::Document::load_mem(&bytes).unwrap(); + let mut incr = IncrementalDocument::create_from(bytes, prev); + let form = incr.new_document.add_object(Stream::new( + dictionary! { "Type" => "XObject", "Subtype" => "Form", "BBox" => reals(&[0., 0., 10., 10.]) }, + vec![], + )); + let draw = Draw { + form: Imported { + id: form, + width: 10., + height: 10., + }, + rect: [100., 100., 10., 10.], + }; + draw_on_page(&mut incr, page, &[draw]).unwrap(); + let Object::Array(parts) = incr + .new_document + .get_dictionary(page) + .unwrap() + .get(b"Contents") + .unwrap() + else { + panic!("no contents"); + }; + let mut all = vec![]; + for part in parts { + let id = part.as_reference().unwrap(); + let stream = current(&incr, id).unwrap().as_stream().unwrap(); + all.extend_from_slice(&stream.content); + all.push(b'\n'); + } + lopdf::content::Content::decode(&all).unwrap().operations + } + + #[test] + fn draws_stamps_in_the_pages_own_space_however_its_content_nests() { + // balanced, a q left open with a scale, a Q too many + for content in [ + &b"q 2 0 0 2 0 0 cm Q"[..], + b"q 2 0 0 2 0 0 cm", + b"Q 2 0 0 2 0 0 cm", + ] { + let mut depth = 0; + let mut at_stamp = None; + for operation in stamped(content) { + match operation.operator.as_str() { + "q" => depth += 1, + "Q" => depth -= 1, + "Do" => at_stamp = Some(depth), + _ => {} + } + assert!(depth >= 0, "a Q pops what isn't there"); + } + // inside its own q alone, and nothing left open + assert_eq!(at_stamp, Some(1)); + assert_eq!(depth, 0); + } + } + + #[test] + fn repeats_a_stamp_as_far_from_the_nearer_edges() { + let a4 = (595., 842.); + let a5 = (420., 595.); + // initials 36 pt from the bottom right of A4 + let corner = [595. - 36. - 56., 842. - 36. - 28., 56., 28.]; + assert_eq!(repeat_into(corner, a4, a4), corner, "its own page"); + assert_eq!( + repeat_into(corner, a4, a5), + [420. - 36. - 56., 595. - 36. - 28., 56., 28.] + ); + // from the top left: the same place + let top_left = [40., 50., 56., 28.]; + assert_eq!(repeat_into(top_left, a4, a5), top_left); + // onto a larger page: still 36 pt from the bottom right + assert_eq!( + repeat_into([420. - 36. - 56., 595. - 36. - 28., 56., 28.], a5, a4), + corner + ); + // too large for the page: smaller, on it + let wide = repeat_into([0., 700., 500., 100.], a4, (250., 400.)); + assert_eq!(wide, [0., 400. - 42. - 50., 250., 50.]); + assert!(wide[1] >= 0. && wide[1] + wide[3] <= 400.); + } +} diff --git a/src-tauri/src/signing/pdf/xobject.rs b/src-tauri/src/signing/pdf/xobject.rs new file mode 100644 index 00000000..d2fe8c9d --- /dev/null +++ b/src-tauri/src/signing/pdf/xobject.rs @@ -0,0 +1,340 @@ +//! a stamp's one-page PDF, which Blank's engine made with krilla, as a Form +//! XObject of the PDF being updated: page 1's content and every object its +//! resources reach, copied with new ids. Its marked content becomes +//! artifacts, since the stamp isn't part of the PDF's structure tree, and +//! for a PDF/A-1 target its transparency goes (PDF/A-1 has none). + +use std::collections::HashMap; + +use anyhow::{bail, Context, Result}; +use lopdf::content::{Content, Operation}; +use lopdf::Stream; +use lopdf::{dictionary, Dictionary, Document, IncrementalDocument, LoadOptions, Object, ObjectId}; + +use super::{inherited, numbers, page_id, reals, resolve}; + +/// the largest stamp PDF taken, what any stream of it may decompress to, +/// how many objects it may bring along and how deep they may nest: it's +/// Blank's own, but it came through the webview +const MAX_PDF: usize = 16 << 20; +const MAX_STREAM: usize = 64 << 20; +const MAX_OBJECTS: usize = 20_000; +const MAX_DEPTH: usize = 64; +/// the largest side of a stamp, in points: PDF's own limit +const MAX_SIDE: f64 = 14_400.; + +/// a stamp copied into the update +#[derive(Debug, Clone, Copy)] +pub struct Imported { + pub id: ObjectId, + /// the size of its BBox, in points + pub width: f64, + pub height: f64, +} + +/// import_stamp copies page 1 of the PDF `pdf` into `incr` as a Form +/// XObject that shows as `[0 0 w h]` (its BBox is the page's box, moved +/// there by its /Matrix); `pdfa1` drops what PDF/A-1 forbids +/// (transparency groups, soft masks), since the PDF it goes into claims it +pub fn import_stamp(incr: &mut IncrementalDocument, pdf: &[u8], pdfa1: bool) -> Result { + if pdf.len() > MAX_PDF { + bail!("the stamp's PDF is too large"); + } + let options = LoadOptions { + max_decompressed_size: Some(MAX_STREAM), + ..Default::default() + }; + let doc = Document::load_mem_with_options(pdf, options).context("can't read a stamp's PDF")?; + if doc.trailer.get(b"Encrypt").is_ok() || doc.is_encrypted() { + bail!("a stamp's PDF is encrypted"); + } + let page = page_id(&doc, 1)?; + let media = inherited(&doc, page, b"CropBox") + .or_else(|| inherited(&doc, page, b"MediaBox")) + .context("a stamp's page has no size")?; + let Some([x0, y0, x1, y1]) = numbers(media, &doc).and_then(|n| <[f64; 4]>::try_from(n).ok()) + else { + bail!("a stamp's page box isn't four numbers"); + }; + let (x0, y0, x1, y1) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + let (width, height) = (x1 - x0, y1 - y0); + if !(width > 0. && height > 0. && width <= MAX_SIDE && height <= MAX_SIDE) { + bail!("a stamp's page has no room in it"); + } + + let resources = inherited(&doc, page, b"Resources") + .and_then(|r| r.as_dict().ok()) + .cloned() + .unwrap_or_default(); + let content = doc + .get_page_content_with_limit(page, MAX_STREAM) + .context("can't read a stamp's content")?; + let content = unmark(&doc, &content, &resources)?; + + let mut copier = Copier { + source: &doc, + pdfa1, + map: HashMap::new(), + queue: vec![], + }; + let resources = copier.copy_dict(incr, &resources, 0)?; + // the content is drawn where the page has it: the BBox is the page's + // box, and the matrix moves it to the origin + let mut form = dictionary! { + "Type" => "XObject", "Subtype" => "Form", + "BBox" => reals(&[x0, y0, x1, y1]), + "Resources" => resources, + }; + if x0 != 0. || y0 != 0. { + form.set("Matrix", reals(&[1., 0., 0., 1., -x0, -y0])); + } + if !pdfa1 { + if let Some(group) = doc + .get_dictionary(page) + .ok() + .and_then(|p| p.get(b"Group").ok()) + .and_then(|g| resolve(&doc, g)) + .and_then(|g| g.as_dict().ok()) + .cloned() + { + let group = copier.copy_dict(incr, &group, 0)?; + form.set("Group", group); + } + } + copier.drain(incr)?; + let mut stream = Stream::new(form, content); + // a stream that doesn't get smaller stays as it is + let _ = stream.compress(); + let id = incr.new_document.add_object(stream); + Ok(Imported { id, width, height }) +} + +/// unmark turns every marked-content sequence with an /MCID (`/P <> BDC`, or a named property list that has one) into `/Artifact BMC`: +/// the stamp's structure elements aren't copied, so nothing may point +/// into them +fn unmark(doc: &Document, content: &[u8], resources: &Dictionary) -> Result> { + // no marked content with properties: an /MCID may also hide in a named + // property list, which the content names but doesn't spell out + if !content.windows(3).any(|w| w == b"BDC") { + return Ok(content.to_vec()); + } + let properties = resources + .get(b"Properties") + .ok() + .and_then(|p| resolve(doc, p)) + .and_then(|p| p.as_dict().ok()); + let has_mcid = |operand: Option<&Object>| -> bool { + let dict = match operand { + Some(Object::Dictionary(d)) => Some(d), + Some(Object::Name(name)) => properties + .and_then(|p| p.get(name).ok()) + .and_then(|p| resolve(doc, p)) + .and_then(|p| p.as_dict().ok()), + Some(Object::Reference(_)) => operand + .and_then(|o| resolve(doc, o)) + .and_then(|o| o.as_dict().ok()), + _ => None, + }; + dict.is_some_and(|d| d.has(b"MCID")) + }; + let mut decoded = Content::decode(content).context("can't read a stamp's content")?; + for operation in &mut decoded.operations { + if operation.operator == "BDC" && has_mcid(operation.operands.get(1)) { + *operation = Operation::new("BMC", vec![Object::Name(b"Artifact".to_vec())]); + } + } + Ok(decoded.encode()?) +} + +/// copies objects of the stamp's PDF into the update, each once, with new +/// ids; references are followed through a queue, so cycles end +struct Copier<'a> { + source: &'a Document, + pdfa1: bool, + /// the stamp's ids to the update's + map: HashMap, + /// objects given an id in the update, to be copied + queue: Vec<(ObjectId, ObjectId)>, +} + +impl Copier<'_> { + /// id_for is the update's id of the stamp's object `old`, queued to be + /// copied the first time; what would make the stamp part of the stamp + /// PDF's own page tree becomes null + fn id_for(&mut self, incr: &mut IncrementalDocument, old: ObjectId) -> Result { + if let Some(new) = self.map.get(&old) { + return Ok(Object::Reference(*new)); + } + let Ok(object) = self.source.get_object(old) else { + return Ok(Object::Null); + }; + let kind = match object { + Object::Dictionary(d) => d.get(b"Type").and_then(Object::as_name).ok(), + _ => None, + }; + if matches!( + kind, + Some(b"Page" | b"Pages" | b"Catalog" | b"StructTreeRoot" | b"StructElem") + ) { + return Ok(Object::Null); + } + if self.map.len() >= MAX_OBJECTS { + bail!("a stamp's PDF has too many objects"); + } + let new = incr.new_document.new_object_id(); + self.map.insert(old, new); + self.queue.push((old, new)); + Ok(Object::Reference(new)) + } + + fn copy(&mut self, incr: &mut IncrementalDocument, o: &Object, depth: usize) -> Result { + if depth > MAX_DEPTH { + bail!("a stamp's PDF nests too deep"); + } + Ok(match o { + Object::Reference(id) => self.id_for(incr, *id)?, + Object::Array(items) => Object::Array( + items + .iter() + .map(|item| self.copy(incr, item, depth + 1)) + .collect::>()?, + ), + Object::Dictionary(d) => Object::Dictionary(self.copy_dict(incr, d, depth + 1)?), + Object::Stream(s) => Object::Stream(self.copy_stream(incr, s, depth + 1)?), + other => other.clone(), + }) + } + + fn copy_dict( + &mut self, + incr: &mut IncrementalDocument, + d: &Dictionary, + depth: usize, + ) -> Result { + let mut out = Dictionary::new(); + for (key, value) in d.iter() { + // the stamp's structure tree isn't copied + if key == b"StructParent" || key == b"StructParents" { + continue; + } + out.set(key.clone(), self.copy(incr, value, depth + 1)?); + } + if self.pdfa1 { + pdfa1_dict(&mut out); + } + Ok(out) + } + + fn copy_stream( + &mut self, + incr: &mut IncrementalDocument, + s: &Stream, + depth: usize, + ) -> Result { + let subtype = s.dict.get(b"Subtype").and_then(Object::as_name).ok(); + let mut stream = s.clone(); + if subtype == Some(b"Form") { + // a form inside the stamp: its marked content too + let resources = s + .dict + .get(b"Resources") + .ok() + .and_then(|r| resolve(self.source, r)) + .and_then(|r| r.as_dict().ok()) + .cloned() + .unwrap_or_default(); + let plain = s + .get_plain_content_with_limit(MAX_STREAM) + .context("can't read a form of a stamp")?; + let unmarked = unmark(self.source, &plain, &resources)?; + if unmarked != plain { + stream.set_plain_content(unmarked); + let _ = stream.compress(); + } + if self.pdfa1 { + stream.dict.remove(b"Group"); + } + } + if self.pdfa1 && subtype == Some(b"Image") && s.dict.has(b"SMask") { + flatten_over_white(self.source, &mut stream); + } + stream.dict = self.copy_dict(incr, &stream.dict, depth + 1)?; + Ok(stream) + } + + /// drain copies every queued object + fn drain(&mut self, incr: &mut IncrementalDocument) -> Result<()> { + while let Some((old, new)) = self.queue.pop() { + let object = self.source.get_object(old)?.clone(); + let copied = self.copy(incr, &object, 0)?; + incr.new_document.objects.insert(new, copied); + } + Ok(()) + } +} + +/// pdfa1_dict takes the transparency out of a graphics state for PDF/A-1: +/// no soft mask, full opacity, the normal blend mode +fn pdfa1_dict(d: &mut Dictionary) { + if d.get(b"Type").and_then(Object::as_name).ok() == Some(b"ExtGState") + || d.has(b"ca") + || d.has(b"CA") + { + if d.has(b"SMask") { + d.set("SMask", Object::Name(b"None".to_vec())); + } + for key in [&b"ca"[..], b"CA"] { + if d.has(key) { + d.set(key.to_vec(), Object::Real(1.0)); + } + } + if d.has(b"BM") { + d.set("BM", Object::Name(b"Normal".to_vec())); + } + } +} + +/// flatten_over_white draws an image with a soft mask over white and drops +/// the mask, for PDF/A-1, which has no transparency: 8-bit samples of one +/// or three components, as krilla writes them. An image Blank can't +/// decode keeps its colour and loses the mask. +fn flatten_over_white(source: &Document, image: &mut Stream) { + let flattened = (|| -> Option> { + let mask = resolve(source, image.dict.get(b"SMask").ok()?)? + .as_stream() + .ok()?; + let dim = |d: &Dictionary, key: &[u8]| d.get(key).ok()?.as_i64().ok(); + let (w, h) = (dim(&image.dict, b"Width")?, dim(&image.dict, b"Height")?); + if dim(&mask.dict, b"Width")? != w + || dim(&mask.dict, b"Height")? != h + || dim(&image.dict, b"BitsPerComponent")? != 8 + || dim(&mask.dict, b"BitsPerComponent")? != 8 + { + return None; + } + let pixels = usize::try_from(w) + .ok()? + .checked_mul(usize::try_from(h).ok()?)?; + let colour = image.get_plain_content_with_limit(MAX_STREAM).ok()?; + let alpha = mask.get_plain_content_with_limit(MAX_STREAM).ok()?; + if pixels == 0 || alpha.len() < pixels || colour.len() % pixels != 0 { + return None; + } + let components = colour.len() / pixels; + if components != 1 && components != 3 { + return None; + } + let mut out = colour; + for (i, sample) in out.iter_mut().enumerate() { + let a = u32::from(alpha[i / components]); + *sample = ((u32::from(*sample) * a + 255 * (255 - a) + 127) / 255) as u8; + } + Some(out) + })(); + image.dict.remove(b"SMask"); + if let Some(samples) = flattened { + image.set_plain_content(samples); + let _ = image.compress(); + } +} diff --git a/src-tauri/src/signing/trust.rs b/src-tauri/src/signing/trust.rs index f9ceced0..f2f5bdc9 100644 --- a/src-tauri/src/signing/trust.rs +++ b/src-tauri/src/signing/trust.rs @@ -65,7 +65,8 @@ pub struct CheckedSignature { pub timestamp_trusted: bool, /// the weak algorithm it relies on ("SHA-1"), if it does pub weak: Option<&'static str>, - /// for SignedAgainLater: who signed next, after the changes + /// for SignedAgainLater, and for a Valid one that later revisions only + /// signed again (Coverage::SignedOnlyAgain): who signed next pub later_signer: Option, pub reason: Option, pub location: Option, @@ -98,7 +99,7 @@ pub fn status(report: &SignatureReport) -> Status { return Status::Invalid; } match report.coverage { - Coverage::WholeFile | Coverage::ValidationData => Status::Valid, + Coverage::WholeFile | Coverage::ValidationData | Coverage::SignedOnlyAgain => Status::Valid, Coverage::SignedAgainLater => Status::SignedAgainLater, Coverage::ChangedAfterSigning => Status::ChangedAfterSigning, Coverage::Unknown => Status::Invalid, @@ -138,7 +139,8 @@ pub fn checked_all( reports .into_iter() .map(|report| { - let later = (status(&report) == Status::SignedAgainLater) + let later = (status(&report) == Status::SignedAgainLater + || report.coverage == Coverage::SignedOnlyAgain) .then(|| end(&report)) .flatten() .and_then(|mine| ends.iter().find(|(end, _)| *end > mine)) diff --git a/src-tauri/src/signing/verify.rs b/src-tauri/src/signing/verify.rs index 1b819e26..b2b5ea54 100644 --- a/src-tauri/src/signing/verify.rs +++ b/src-tauri/src/signing/verify.rs @@ -4,7 +4,7 @@ use crate::signing::certificate::CertificateInfo; use crate::signing::der as d; -use crate::signing::pdf::form_fields; +use crate::signing::pdf::{form_fields, load_bounded, resolve}; use anyhow::{Context, Result}; use cms::content_info::ContentInfo; use cms::signed_data::{SignedData, SignerIdentifier}; @@ -23,6 +23,10 @@ pub enum Coverage { WholeFile, /// only validation data (a DSS) came after it ValidationData, + /// later revisions came that only signed it again: new signatures + /// with their fields, widgets and appearances, empty signature fields + /// filled in (and validation data), all of them signed + SignedOnlyAgain, /// later revisions came, the last of them signed SignedAgainLater, /// later revisions came that no signature covers @@ -564,11 +568,15 @@ fn collect_sig_fields(doc: &Document) -> Vec { /// verify_pdf checks every signature of the PDF `bytes`, in the order of /// its form's fields pub fn verify_pdf(bytes: &[u8]) -> Result> { - let doc = Document::load_mem(bytes).context("can't read the PDF")?; + verify_doc(&load_bounded(bytes)?, bytes) +} + +/// verify_doc is verify_pdf of `bytes` that were read already as `doc` +pub fn verify_doc(doc: &Document, bytes: &[u8]) -> Result> { let header = bytes.windows(5).position(|w| w == b"%PDF-").unwrap_or(0); let effective_len = bytes.len() - bytes.iter().rev().take_while(|b| is_ws(**b)).count(); let mut reports = vec![]; - for sd in collect_sig_fields(&doc) { + for sd in collect_sig_fields(doc) { let mut r = SignatureReport { field: sd.field.clone(), ..Default::default() @@ -784,20 +792,21 @@ pub fn verify_pdf(bytes: &[u8]) -> Result> { .collect(); let last_covered = ends.iter().copied().max().unwrap_or(0); let tail_dss_only = - last_covered < effective_len && tail_is_validation_data(&doc, bytes, header, last_covered); + last_covered < effective_len && tail_is_validation_data(doc, bytes, header, last_covered); for r in &mut reports { if r.byte_range.len() != 4 || !r.checks.first().is_some_and(|c| c.1) { r.coverage = Coverage::Unknown; continue; } let end = (r.byte_range[2] + r.byte_range[3]) as usize; + let signed_later = last_covered >= effective_len || tail_dss_only; r.coverage = if end >= effective_len { Coverage::WholeFile - } else if last_covered >= effective_len { - Coverage::SignedAgainLater } else if tail_dss_only && end == last_covered { Coverage::ValidationData - } else if tail_dss_only { + } else if signed_later && only_signatures_after(doc, bytes, header, end) { + Coverage::SignedOnlyAgain + } else if signed_later { Coverage::SignedAgainLater } else { Coverage::ChangedAfterSigning @@ -806,101 +815,135 @@ pub fn verify_pdf(bytes: &[u8]) -> Result> { Ok(reports) } -/// tail_is_validation_data tells whether everything written after `from` -/// is PAdES validation data: a DSS dictionary, VRI entries, -/// certificates/OCSP/CRL streams and the catalog that points to them, -/// unchanged but for its /DSS. (A heuristic.) An object the signed revision -/// already had may only be written again if that revision's own DSS held -/// it: otherwise a page's content stream written again under its number -/// would pass for a certificate. -fn tail_is_validation_data(doc: &Document, bytes: &[u8], header: usize, from: usize) -> bool { - use lopdf::xref::XrefEntry; - let in_tail = |off: u32| header + off as usize >= from; - // each object written after `from`, with where it starts if not in an - // object stream - let mut ids = vec![]; - for (id, e) in &doc.reference_table.entries { - match e { - XrefEntry::Normal { offset, generation } if in_tail(*offset) => { - ids.push(((*id, *generation), Some(header + *offset as usize))) - } - XrefEntry::Compressed { container, .. } => { - if let Some(XrefEntry::Normal { offset, .. }) = doc.reference_table.get(*container) - { - if in_tail(*offset) { - ids.push(((*id, 0), None)); +/// what was written after a signature, read against the revision it +/// signed: the objects written after `from`, each with where it starts if +/// not in an object stream; the signed revision; and the objects the DSS of +/// each reaches +struct Tail { + ids: Vec<(ObjectId, Option)>, + before: Document, + held_then: std::collections::HashSet, + held_now: std::collections::HashSet, +} + +impl Tail { + /// read reads what `doc`, the whole file `bytes`, wrote after `from`. + /// None if the signed revision can't be read, or if an object it had + /// is gone or points elsewhere: an update that marks a page's content + /// stream free changes the page as surely as one that writes it again. + /// lopdf reads no free entries, so the tail's own cross-reference + /// sections are read for them + fn read(doc: &Document, bytes: &[u8], header: usize, from: usize) -> Option { + use lopdf::xref::XrefEntry; + let in_tail = |off: u32| header + off as usize >= from; + let mut ids = vec![]; + for (id, e) in &doc.reference_table.entries { + match e { + XrefEntry::Normal { offset, generation } if in_tail(*offset) => { + ids.push(((*id, *generation), Some(header + *offset as usize))) + } + XrefEntry::Compressed { container, .. } => { + if let Some(XrefEntry::Normal { offset, .. }) = + doc.reference_table.get(*container) + { + if in_tail(*offset) { + ids.push(((*id, 0), None)); + } } } + _ => {} } - _ => {} } + let before = load_bounded(&bytes[..from]).ok()?; + if !before.objects.keys().all(|id| doc.objects.contains_key(id)) { + return None; + } + let numbers: std::collections::HashSet = + before.objects.keys().map(|(n, _)| *n).collect(); + let had = |number: u32| numbers.contains(&number); + let kept = tail_entries(bytes, header, from)? + .iter() + .all(|&(number, entry)| { + let then = before.reference_table.get(number); + match entry { + _ if number == 0 => true, + TailEntry::Free => !had(number), + TailEntry::Normal { offset, generation } if header + offset < from => matches!( + then, + Some(XrefEntry::Normal { offset: o, generation: g }) + if *o as usize == offset && *g == generation + ), + TailEntry::Compressed { container } + if doc.reference_table.get(container).is_some_and( + |e| !matches!(e, XrefEntry::Normal { offset, .. } if in_tail(*offset)), + ) => + { + matches!( + then, + Some(XrefEntry::Compressed { container: c, .. }) if *c == container + ) + } + _ => true, + } + }); + if !kept { + return None; + } + Some(Tail { + ids, + held_then: dss_objects(&before), + held_now: dss_objects(doc), + before, + }) } - if ids.is_empty() { - return false; - } - let Ok(root) = doc.trailer.get(b"Root").and_then(Object::as_reference) else { - return false; - }; - let Ok(before) = Document::load_mem(&bytes[..from]) else { - return false; - }; - // the catalog may only have gained (or changed) its /DSS - let catalog_ok = || -> bool { - let (Ok(now), Ok(then)) = (doc.get_dictionary(root), before.get_dictionary(root)) else { - return false; - }; - let strip = |d: &lopdf::Dictionary| { - let mut d = d.clone(); - d.remove(b"DSS"); - format!("{d:?}") - }; - now.has(b"DSS") && strip(now) == strip(then) - }; - // nothing the signed revision had may have gone, nor point elsewhere: - // an update that marks a page's content stream free changes the page as - // surely as one that writes it again. lopdf reads no free entries, so - // the tail's own cross-reference sections are read here - if !before.objects.keys().all(|id| doc.objects.contains_key(id)) { - return false; - } - let Some(entries) = tail_entries(bytes, header, from) else { - return false; - }; - let numbers: std::collections::HashSet = before.objects.keys().map(|(n, _)| *n).collect(); - let had = |number: u32| numbers.contains(&number); - let kept = entries.iter().all(|&(number, entry)| { - let then = before.reference_table.get(number); - match entry { - _ if number == 0 => true, - TailEntry::Free => !had(number), - TailEntry::Normal { offset, generation } if header + offset < from => matches!( - then, - Some(lopdf::xref::XrefEntry::Normal { offset: o, generation: g }) - if *o as usize == offset && *g == generation - ), - TailEntry::Compressed { container } - if doc - .reference_table - .get(container) - .is_some_and(|e| !matches!(e, lopdf::xref::XrefEntry::Normal { offset, .. } if header + *offset as usize >= from)) => + + /// validation_data tells whether the object `id` of `doc` (not the + /// catalog), written at `at`, is PAdES validation data that may be + /// there: a DSS dictionary, VRI entries, certificate, OCSP and CRL + /// streams, or a stream of objects or of cross-references. An object + /// the signed revision already had may only be written again if that + /// revision's own DSS held it, and a new one only if the DSS now holds + /// it: otherwise a page's content stream written again under its number + /// would pass for a certificate + fn validation_data( + &self, + doc: &Document, + bytes: &[u8], + id: ObjectId, + at: Option, + ) -> bool { + match doc.objects.get(&id) { + Some(Object::Stream(st)) + if matches!( + st.dict.get(b"Type").and_then(Object::as_name).ok(), + Some(b"ObjStm") | Some(b"XRef") + ) => { - matches!( - then, - Some(lopdf::xref::XrefEntry::Compressed { container: c, .. }) if *c == container - ) + true + } + Some(object) => { + let held = if self.before.objects.contains_key(&id) { + &self.held_then + } else { + &self.held_now + }; + held.contains(&id) && validation_shape(object) } - _ => true, + // the cross-reference stream itself isn't kept as an object: + // it's the one at `at` that says it is one, and new + None => !self.before.objects.contains_key(&id) && xref_stream_at(bytes, at), } - }); - if !kept { - return false; } - let held_then = dss_objects(&before); - let held_now = dss_objects(doc); +} + +/// validation_shape tells whether `object` looks like validation data: a +/// DSS, a VRI or one of its entries, an array of references, or a stream +/// with no /Type or /Subtype (a certificate, OCSP response or CRL) +fn validation_shape(object: &Object) -> bool { let keys_within = |d: &lopdf::Dictionary, allowed: &[&[u8]]| { !d.is_empty() && d.iter().all(|(k, _)| allowed.contains(&k.as_slice())) }; - let validation_data = |object: &Object| match object { + match object { Object::Dictionary(d) => { keys_within(d, &[b"Type", b"Certs", b"OCSPs", b"CRLs", b"VRI"]) && (d.get(b"Type").and_then(Object::as_name).ok() == Some(b"DSS") @@ -923,47 +966,197 @@ fn tail_is_validation_data(doc: &Document, bytes: &[u8], header: usize, from: us st.dict.get(b"Type").and_then(Object::as_name).is_err() && !st.dict.has(b"Subtype") } _ => false, + } +} + +/// xref_stream_at tells whether the object at `at` of `bytes` says it is a +/// cross-reference stream +fn xref_stream_at(bytes: &[u8], at: Option) -> bool { + let Some(rest) = at.and_then(|at| bytes.get(at..)) else { + return false; }; - // the cross-reference stream itself isn't kept as an object: it's the - // one at `at` that says it is one - let xref_stream = |at: Option| { - let Some(at) = at else { - return false; - }; - let Some(rest) = bytes.get(at..) else { - return false; - }; - let head = &rest[..rest.len().min(512)]; - let dict_end = head - .windows(6) - .position(|w| w == b"stream") - .unwrap_or(head.len()); - head[..dict_end].windows(5).any(|w| w == b"/XRef") + let head = &rest[..rest.len().min(512)]; + let dict_end = head + .windows(6) + .position(|w| w == b"stream") + .unwrap_or(head.len()); + head[..dict_end].windows(5).any(|w| w == b"/XRef") +} + +/// without is `d` as a string to compare, without the keys `keys` +fn without(d: &lopdf::Dictionary, keys: &[&[u8]]) -> String { + let mut d = d.clone(); + for key in keys { + d.remove(key); + } + format!("{d:?}") +} + +/// tail_is_validation_data tells whether everything written after `from` +/// is PAdES validation data (see Tail::validation_data) and the catalog +/// that points to it, unchanged but for its /DSS. (A heuristic.) +fn tail_is_validation_data(doc: &Document, bytes: &[u8], header: usize, from: usize) -> bool { + let Some(tail) = Tail::read(doc, bytes, header, from) else { + return false; }; - ids.iter().all(|&(id, at)| { - if id == root { - return catalog_ok(); + let Ok(root) = doc.trailer.get(b"Root").and_then(Object::as_reference) else { + return false; + }; + // the catalog may only have gained (or changed) its /DSS + let catalog_ok = || match (doc.get_dictionary(root), tail.before.get_dictionary(root)) { + (Ok(now), Ok(then)) => { + now.has(b"DSS") && without(now, &[b"DSS"]) == without(then, &[b"DSS"]) } - match doc.objects.get(&id) { - Some(Object::Stream(st)) - if matches!( - st.dict.get(b"Type").and_then(Object::as_name).ok(), - Some(b"ObjStm") | Some(b"XRef") - ) => - { - true + _ => false, + }; + !tail.ids.is_empty() + && tail.ids.iter().all(|&(id, at)| { + if id == root { + catalog_ok() + } else { + tail.validation_data(doc, bytes, id, at) } - Some(object) => { - let held = if before.objects.contains_key(&id) { - &held_then - } else { - &held_now + }) +} + +/// only_signatures_after tells whether everything written after `from` +/// only signs the PDF again, as Blank's countersigning and other signers +/// write it: new signature fields with their widgets, appearances (and +/// what those draw with) and signature values; empty signature fields of +/// the PDF filled in (their /V, the widget's /AP, /F and /AS); the pages' +/// /Annots and the form's /Fields grown by signature widgets and fields +/// only; the form's /SigFlags; the catalog's /AcroForm and /DSS; and +/// validation data. Anything else that changed (a field's value, a page's +/// content, an annotation that isn't a signature's) makes it false. +/// An object the signed revision had that is none of these passes as +/// validation data only as tail_is_validation_data lets it. (A heuristic, +/// as tail_is_validation_data.) +fn only_signatures_after(doc: &Document, bytes: &[u8], header: usize, from: usize) -> bool { + let Some(tail) = Tail::read(doc, bytes, header, from) else { + return false; + }; + let prev = &tail.before; + let root_of = |doc: &Document| doc.trailer.get(b"Root").and_then(Object::as_reference).ok(); + let Some(root) = root_of(doc).filter(|root| root_of(prev) == Some(*root)) else { + return false; + }; + let name = |d: &lopdf::Dictionary, key: &[u8]| -> Option> { + d.get(key) + .and_then(Object::as_name) + .ok() + .map(<[u8]>::to_vec) + }; + let dict_in = |doc: &'_ Document, o: &Object| -> Option { + resolve(doc, o).and_then(|o| o.as_dict().ok()).cloned() + }; + // a signature field: /FT /Sig, its own or, for a widget, its parent's + let is_signature = |doc: &Document, d: &lopdf::Dictionary| { + name(d, b"FT").as_deref() == Some(b"Sig") + || d.get(b"Parent") + .ok() + .and_then(|p| dict_in(doc, p)) + .is_some_and(|p| name(&p, b"FT").as_deref() == Some(b"Sig")) + }; + let is_signature_widget = |o: &Object| { + dict_in(doc, o).is_some_and(|d| { + name(&d, b"Subtype").as_deref() == Some(b"Widget") && is_signature(doc, &d) + }) + }; + let is_signature_field = + |o: &Object| dict_in(doc, o).is_some_and(|d| name(&d, b"FT").as_deref() == Some(b"Sig")); + // `now` is `before` with entries added at its end, each `added_ok` + let grown = + |now: Option<&Object>, before: Option<&Object>, added_ok: &dyn Fn(&Object) -> bool| { + let list = |doc: &'_ Document, o: Option<&Object>| -> Option> { + match o { + None => Some(vec![]), + Some(o) => resolve(doc, o).and_then(|o| o.as_array().ok()).cloned(), + } + }; + let (Some(now), Some(before)) = (list(doc, now), list(prev, before)) else { + return false; + }; + now.len() >= before.len() + && format!("{:?}", &now[..before.len()]) == format!("{before:?}") + && now[before.len()..].iter().all(added_ok) + }; + // the form: as it was but for its /Fields, grown by signature fields, + // and its /SigFlags + let form_ok = |now: Option<&Object>, before: Option<&Object>| { + let now = now.and_then(|o| dict_in(doc, o)).unwrap_or_default(); + let before = before.and_then(|o| dict_in(prev, o)).unwrap_or_default(); + without(&now, &[b"Fields", b"SigFlags"]) == without(&before, &[b"Fields", b"SigFlags"]) + && grown( + now.get(b"Fields").ok(), + before.get(b"Fields").ok(), + &is_signature_field, + ) + }; + let prev_catalog = prev.get_dictionary(root).ok(); + let reference = |d: Option<&lopdf::Dictionary>, key: &[u8]| { + d.and_then(|d| d.get(key).ok()) + .and_then(|o| o.as_reference().ok()) + }; + let prev_form_id = reference(prev_catalog, b"AcroForm"); + let prev_form = prev_form_id.and_then(|id| prev.get_dictionary(id).ok()); + let prev_fields_id = reference( + prev_form + .or(prev_catalog.and_then(|c| c.get(b"AcroForm").ok().and_then(|f| f.as_dict().ok()))), + b"Fields", + ); + let prev_pages: Vec = prev.get_pages().into_values().collect(); + let prev_annots: Vec = prev_pages + .iter() + .filter_map(|page| reference(prev.get_dictionary(*page).ok(), b"Annots")) + .collect(); + // an empty signature field (or its widget) the PDF had + let empty_signature = |d: &lopdf::Dictionary| is_signature(prev, d) && !d.has(b"V"); + + !tail.ids.is_empty() + && tail.ids.iter().all(|&(id, at)| { + let id = &id; + let now = doc.objects.get(id); + // new objects count only through what refers to them, which + // is checked here + let Some(before) = prev.objects.get(id) else { + return true; + }; + let (Some(Object::Dictionary(now_dict)), Object::Dictionary(before_dict)) = + (now, before) + else { + return match (now, before) { + (Some(Object::Array(_)), Object::Array(_)) if prev_annots.contains(id) => { + grown(now, Some(before), &is_signature_widget) + } + (Some(Object::Array(_)), Object::Array(_)) if prev_fields_id == Some(*id) => { + grown(now, Some(before), &is_signature_field) + } + _ => tail.validation_data(doc, bytes, *id, at), }; - held.contains(&id) && validation_data(object) + }; + if *id == root { + without(now_dict, &[b"AcroForm", b"DSS"]) + == without(before_dict, &[b"AcroForm", b"DSS"]) + && form_ok( + now_dict.get(b"AcroForm").ok(), + before_dict.get(b"AcroForm").ok(), + ) + } else if prev_form_id == Some(*id) { + form_ok(now, Some(before)) + } else if prev_pages.contains(id) { + without(now_dict, &[b"Annots"]) == without(before_dict, &[b"Annots"]) + && grown( + now_dict.get(b"Annots").ok(), + before_dict.get(b"Annots").ok(), + &is_signature_widget, + ) + } else if empty_signature(before_dict) { + let filled: &[&[u8]] = &[b"V", b"AP", b"F", b"AS"]; + without(now_dict, filled) == without(before_dict, filled) + } else { + tail.validation_data(doc, bytes, *id, at) } - None => !before.objects.contains_key(&id) && xref_stream(at), - } - }) + }) } /// an entry of a cross-reference section, as written @@ -984,6 +1177,10 @@ enum TailEntry { /// the sections it points to start (/Prev, /XRefStm) type Section = (Vec<(u32, TailEntry)>, Vec); +/// how long a cross-reference stream may be, decompressed: 7 bytes an entry +/// for over two million objects +const MAX_XREF_STREAM: usize = 16 << 20; + /// how many sections an update's chain of cross-references may have const MAX_SECTIONS: usize = 256; @@ -1091,7 +1288,7 @@ fn xref_stream(section: &[u8]) -> Option
{ ) .as_bytes(), ); - let doc = Document::load_mem(&pdf).ok()?; + let doc = load_bounded(&pdf).ok()?; let stream = doc.get_object((1, 0)).ok()?.as_stream().ok()?; let dict = &stream.dict; if dict.get(b"Type").and_then(Object::as_name).ok() != Some(b"XRef") { @@ -1113,7 +1310,9 @@ fn xref_stream(section: &[u8]) -> Option
{ } let index = integers(b"Index").unwrap_or_else(|| vec![0, size]); let content = if dict.has(b"Filter") { - stream.decompressed_content().ok()? + stream + .decompressed_content_with_limit(MAX_XREF_STREAM) + .ok()? } else { stream.content.clone() }; diff --git a/src/commandList.test.ts b/src/commandList.test.ts index f14c0c90..396474db 100644 --- a/src/commandList.test.ts +++ b/src/commandList.test.ts @@ -25,6 +25,7 @@ const DIALOGS = new Set([ CommandIdentifier.APP_ABOUT, CommandIdentifier.PAGE_SETUP, CommandIdentifier.APP_SETTINGS, + CommandIdentifier.PDF_SIGN, ]); // words that keep their capital inside a label diff --git a/src/commandList.ts b/src/commandList.ts index 40a8dd40..7c9d6f20 100644 --- a/src/commandList.ts +++ b/src/commandList.ts @@ -125,6 +125,19 @@ const COMMANDS: { [K in CommandIdentifier]: Omit } = { icon: "doc", aliases: ["export", "docx", "office"], }, + [C.PDF_SIGN]: { + group: "File", + label: "Sign…", + icon: "seal", + aliases: ["sign", "signature", "stamp", "initials", "received", "contract"], + }, + [C.PDF_NEXT]: { + group: "File", + label: "Next place to sign", + short: "Next", + icon: "chevron-right", + aliases: ["next", "place", "field", "fill", "form", "sign here"], + }, [C.EXPORT_SIGNED_PDF]: { group: "Export", label: "Export signed PDF…", diff --git a/src/config.ts b/src/config.ts index 0e53857f..5e90c1cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -104,6 +104,8 @@ export enum CommandIdentifier { VIEW_ZOOM_FIT = "view.zoom_fit", MENU_MAIN = "menu.main", EDIT_FIND = "edit.find", + PDF_SIGN = "pdf.sign", + PDF_NEXT = "pdf.next", } // Replacements typed text → replacement, keyed by ISO 639-1 language code. @@ -254,6 +256,9 @@ const defaultConfig: Config = { [CommandIdentifier.VIEW_ZOOM_FIT]: "", [CommandIdentifier.MENU_MAIN]: "Mod-k", [CommandIdentifier.EDIT_FIND]: "Mod-f", + // in a PDF tab, Save's key signs it too + [CommandIdentifier.PDF_SIGN]: "", + [CommandIdentifier.PDF_NEXT]: "", }, autocorrect: { arrows: true, diff --git a/src/editor/commands/exportAs.ts b/src/editor/commands/exportAs.ts index 4536cf40..c16289a8 100644 --- a/src/editor/commands/exportAs.ts +++ b/src/editor/commands/exportAs.ts @@ -11,7 +11,7 @@ import { type exporterFunc, } from "../../exporters"; import { describePaper, layoutWarnings } from "../../layout/describe"; -import { resolveLayout } from "../../layout/resolve"; +import { type Layout, resolveLayout } from "../../layout/resolve"; import { localeUnit } from "../../layout/paper"; import { frontmatterOf } from "../../markdown"; import { extname } from "../../paths"; @@ -59,14 +59,15 @@ export interface ExportRequest { suffix?: string; // more of the context: the pages chosen to print, or the signature block // a signed PDF fills in - context?: Pick; + context?: Pick; // told once the file is written, e.g. to remember where the user signed written?: () => void; // what the notification says after what was exported, e.g. that the PDF // is signed digitally done?: string; // what is written instead of the export's contents, e.g. the PDF signed - finish?: (result: ExportResult) => Promise; + // with the page it was laid out on + finish?: (result: ExportResult, layout: Layout) => Promise; } /** @@ -149,7 +150,10 @@ export const runExport = async ( ); const result = await exporter(state, { docPath, layout, ...context }); const { warnings, pages } = result; - await writeFile(dest, finish ? await finish(result) : result.contents); + await writeFile( + dest, + finish ? await finish(result, layout) : result.contents, + ); written?.(); // e.g. "Exported 3 A4 pages", or for Word, which lays out the pages diff --git a/src/editor/commands/settings.ts b/src/editor/commands/settings.ts index 568651fa..ce3706bb 100644 --- a/src/editor/commands/settings.ts +++ b/src/editor/commands/settings.ts @@ -13,9 +13,16 @@ import { export const openSettings = (section?: SettingsSection): Command => (_state, dispatch) => { - if (dispatch && !settingsDialog.value) { - if (section) settingsSection.value = section; - settingsDialog.value = {}; - } + if (dispatch) showSettings(section); return true; }; + +/** + * showSettings opens the settings dialog, at `section` if given, unless + * it's open + */ +export const showSettings = (section?: SettingsSection) => { + if (settingsDialog.value) return; + if (section) settingsSection.value = section; + settingsDialog.value = {}; +}; diff --git a/src/editor/commands/signing.test.ts b/src/editor/commands/signing.test.ts index cc920305..756e6965 100644 --- a/src/editor/commands/signing.test.ts +++ b/src/editor/commands/signing.test.ts @@ -12,12 +12,15 @@ import { signatureDialog, signDialog, signingCertificate, + type SignChoices, + signingInitials, signingPlace, } from "../../state"; import { config } from "../../config"; import { flushPromises } from "../../test/async"; import { createState, createTestView, doc, p } from "../../test/editor"; import type { SavedMark } from "../../signing/profile"; +import { pageGeometry } from "../../layout/resolve"; import { editMark, exportSigned, openSignatureDialog } from "./signing"; const saveSettings = vi.hoisted(() => vi.fn()); @@ -45,6 +48,9 @@ vi.mock("../../signing/ipc", async (importOriginal) => ({ ...(await importOriginal()), ...ipc, })); +const stampPdf = vi.hoisted(() => vi.fn()); +const releaseStamp = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../../pdf/stampEngine", () => ({ stampPdf, releaseStamp })); vi.mock("../../signing/certificate", () => ({ loadCertificate: vi.fn(async () => {}), openCertificateDialog: vi.fn(), @@ -69,6 +75,22 @@ const run = (...blocks: Parameters) => { }; const BOX = { page: 0, x: 70, y: 600, width: 230, height: 70 }; +// what the Sign dialog hands on +const choices = ( + pos: number, + place: string, + passphrase: string | null, + more: Partial = {}, +): SignChoices => ({ + pos, + place, + reason: "", + passphrase, + initials: { on: false, side: "right" }, + open: true, + ...more, +}); + const withName = (name: string) => { config.value = { ...config.value, @@ -148,7 +170,7 @@ describe("signing", () => { run(p("Text"), signature({ role: "Tenant" })); await flushPromises(); const { targets } = signDialog.value!; - signDialog.value!.submit(targets[0].pos, "Hamburg", null); + signDialog.value!.submit(choices(targets[0].pos, "Hamburg", null)); await flushPromises(); expect(save).toHaveBeenCalledWith( expect.objectContaining({ defaultPath: "/docs/lease signed.pdf" }), @@ -173,7 +195,9 @@ describe("signing", () => { vi.mocked(save).mockResolvedValue(null); run(p("Text"), signature()); await flushPromises(); - signDialog.value!.submit(signDialog.value!.targets[0].pos, "Hamburg", null); + signDialog.value!.submit( + choices(signDialog.value!.targets[0].pos, "Hamburg", null), + ); await flushPromises(); expect(signingPlace.value).toBe("Berlin"); }); @@ -189,7 +213,7 @@ describe("signing", () => { const request = signDialog.value!; expect(request.chosen).toBe(request.targets[1].pos); // a block that names nobody is signed in the user's name - request.submit(request.targets[0].pos, "", null); + request.submit(choices(request.targets[0].pos, "", null)); await flushPromises(); expect(toPDF.mock.calls[0][1].signed.signing.name).toBe("Ada Lovelace"); }); @@ -200,7 +224,7 @@ describe("signing", () => { const { targets } = signDialog.value!; // typed meanwhile, before the block view.dispatch(view.state.tr.insertText("More ", 1)); - signDialog.value!.submit(targets[0].pos, "", null); + signDialog.value!.submit(choices(targets[0].pos, "", null)); await flushPromises(); const [state, context] = toPDF.mock.calls[0]; expect(state.doc).toBe(view.state.doc); @@ -220,19 +244,27 @@ describe("signing", () => { await flushPromises(); const request = signDialog.value!; expect(await request.check("wrong")).toMatch( - /^The passphrase is wrong\. Forgot it\? Sign with a picture only/, + /^The passphrase is wrong\. Forgot it\? Sign with no digital signature/, ); ipc.checkPassphrase.mockResolvedValue(undefined); expect(await request.check("right")).toBeNull(); - request.submit(request.targets[0].pos, "Hamburg", "right"); - await flushPromises(); - expect(ipc.signPdf).toHaveBeenCalledWith(new Uint8Array([1]), "right", { - page: 1, - rect: [70, 600, 230, 70], - location: "Hamburg", - reason: null, - timestampServer: null, - }); + request.submit(choices(request.targets[0].pos, "Hamburg", "right")); + await flushPromises(); + expect(ipc.signPdf).toHaveBeenCalledWith( + { + passphrase: "right", + source: { kind: "body" }, + plan: { + stamps: [], + signature: { kind: "new", page: 1, rect: [70, 600, 230, 70] }, + others: [], + location: "Hamburg", + reason: null, + timestampServer: null, + }, + }, + [new Uint8Array([1])], + ); // the signed PDF is what is written expect(writeFile).toHaveBeenCalledWith( "/docs/lease signed.pdf", @@ -240,6 +272,150 @@ describe("signing", () => { ); }); + describe("with other signers", () => { + const OTHER = { page: 1, x: 300, y: 100, width: 200, height: 60 }; + const blocks = () => + run( + p("Text"), + signature({ role: "Tenant" }), + signature({ role: "Landlord", name: "Bea Sample" }), + signature({ role: "Witness" }), + ); + beforeEach(() => { + toPDF.mockImplementation(async (_state, context) => ({ + contents: new Uint8Array([1]), + warnings: [], + pages: 2, + signatureBox: BOX, + // the second one isn't on the pages + otherBoxes: context.others.map((_: number, at: number) => + at === 1 ? null : OTHER, + ), + })); + ipc.signPdf.mockResolvedValue(new Uint8Array([2])); + }); + + it("gives the others empty fields, named after who signs, beside the signature", async () => { + blocks(); + await flushPromises(); + const request = signDialog.value!; + expect(request.mode).toBe("export"); + request.submit(choices(request.targets[0].pos, "", "right")); + await flushPromises(); + // the export gives the boxes of the other blocks + const context = toPDF.mock.calls[0][1]; + expect(context.others).toEqual([ + request.targets[1].pos, + request.targets[2].pos, + ]); + const [asked, parts] = ipc.signPdf.mock.calls[0]; + expect(asked.plan.others).toEqual([ + { page: 2, rect: [300, 100, 200, 60], name: "Bea Sample" }, + ]); + expect(asked.plan.signature).toEqual({ + kind: "new", + page: 1, + rect: [70, 600, 230, 70], + }); + expect(parts).toEqual([new Uint8Array([1])]); + expect(writeFile).toHaveBeenCalledWith( + "/docs/lease signed.pdf", + new Uint8Array([2]), + ); + }); + + it("names a block without a name by its role, and adds the fields without a digital signature", async () => { + blocks(); + await flushPromises(); + const request = signDialog.value!; + request.submit( + choices(request.targets[1].pos, "", null, { reason: "I agree" }), + ); + await flushPromises(); + const [asked] = ipc.signPdf.mock.calls[0]; + // stamps and fields alone + expect(asked.passphrase).toBeNull(); + expect(asked.plan.signature).toEqual({ kind: "invisible" }); + expect( + asked.plan.others.map((other: { name: string }) => other.name), + ).toEqual(["Tenant"]); + expect(asked.plan.timestampServer).toBeNull(); + expect(writeFile).toHaveBeenCalledWith( + "/docs/lease signed.pdf", + new Uint8Array([2]), + ); + }); + }); + + it("puts the initials on every page, in the corner chosen, and remembers it", async () => { + signingInitials.value = { on: false, side: "right" }; + savedMarks.value = { signature: SIGNATURE, initials: SIGNATURE }; + stampPdf.mockResolvedValue(new Uint8Array([7])); + ipc.signPdf.mockResolvedValue(new Uint8Array([2])); + run(p("Text"), signature()); + await flushPromises(); + const request = signDialog.value!; + expect(request.initials).toMatchObject({ on: false, side: "right" }); + request.submit( + choices(request.targets[0].pos, "", null, { + reason: "I agree", + initials: { on: true, side: "left" }, + }), + ); + await flushPromises(); + expect(stampPdf).toHaveBeenCalledWith( + expect.objectContaining({ image: "initials" }), + 56, + 28, + ); + const { height } = pageGeometry(toPDF.mock.calls[0][1].layout); + const [asked, parts] = ipc.signPdf.mock.calls[0]; + expect(asked.plan.stamps).toEqual([ + { part: 1, page: 1, rect: [36, height - 64, 56, 28], repeat: true }, + ]); + expect(asked.plan.reason).toBe("I agree"); + expect(parts).toEqual([new Uint8Array([1]), new Uint8Array([7])]); + expect(signingInitials.value).toEqual({ on: true, side: "left" }); + signingInitials.value = { on: false, side: "right" }; + }); + + it("writes the PDF as the engine made it without a digital signature, others or initials", async () => { + run(p("Text"), signature()); + await flushPromises(); + signDialog.value!.submit( + choices(signDialog.value!.targets[0].pos, "", null), + ); + await flushPromises(); + expect(ipc.signPdf).not.toHaveBeenCalled(); + expect(writeFile).toHaveBeenCalledWith( + "/docs/lease signed.pdf", + new Uint8Array([1]), + ); + }); + + it("gives the reason to the digital signature", async () => { + toPDF.mockResolvedValue({ + contents: new Uint8Array([1]), + warnings: [], + pages: 1, + signatureBox: BOX, + }); + ipc.signPdf.mockResolvedValue(new Uint8Array([2])); + run(p("Text"), signature()); + await flushPromises(); + signDialog.value!.submit( + choices(signDialog.value!.targets[0].pos, "", "right", { + reason: "I agree to these terms", + }), + ); + await flushPromises(); + expect(ipc.signPdf.mock.calls[0][0].plan).toMatchObject({ + reason: "I agree to these terms", + stamps: [], + others: [], + }); + }); + it("writes nothing when signing fails, and says why", async () => { toPDF.mockResolvedValue({ contents: new Uint8Array([1]), @@ -253,7 +429,9 @@ describe("signing", () => { }); run(p("Text"), signature()); await flushPromises(); - signDialog.value!.submit(signDialog.value!.targets[0].pos, "", "right"); + signDialog.value!.submit( + choices(signDialog.value!.targets[0].pos, "", "right"), + ); await flushPromises(); expect(writeFile).not.toHaveBeenCalled(); expect(sendNotification).toHaveBeenCalledWith( @@ -276,7 +454,9 @@ describe("signing", () => { }); run(p("Text"), signature()); await flushPromises(); - signDialog.value!.submit(signDialog.value!.targets[0].pos, "", "right"); + signDialog.value!.submit( + choices(signDialog.value!.targets[0].pos, "", "right"), + ); await flushPromises(); expect(writeFile).not.toHaveBeenCalled(); expect(vi.mocked(sendNotification).mock.lastCall![0]).toMatchObject({ @@ -292,7 +472,7 @@ describe("signing", () => { await flushPromises(); const request = signDialog.value!; activeTabId.value = "two"; - request.submit(request.targets[0].pos, "", null); + request.submit(choices(request.targets[0].pos, "", null)); await flushPromises(); expect(save).not.toHaveBeenCalled(); expect(toPDF).not.toHaveBeenCalled(); @@ -324,7 +504,7 @@ describe("signing", () => { const { targets } = signDialog.value!; // the first block taken out: the second moves to another position view.dispatch(view.state.tr.delete(targets[0].pos, targets[0].pos + 1)); - signDialog.value!.submit(targets[1].pos, "", null); + signDialog.value!.submit(choices(targets[1].pos, "", null)); await flushPromises(); expect(toPDF).not.toHaveBeenCalled(); expect(sendNotification).toHaveBeenCalledWith( @@ -356,7 +536,7 @@ describe("signing", () => { const request = signDialog.value!; expect(request.signer).toBe("Ada Lovelace"); expect(request.targets[0].place).toBe("Paris"); - request.submit(request.targets[0].pos, "Hamburg", "right"); + request.submit(choices(request.targets[0].pos, "Hamburg", "right")); await flushPromises(); expect(toPDF.mock.calls[0][1].signed.signing).toMatchObject({ name: "Ada Lovelace", @@ -364,8 +544,10 @@ describe("signing", () => { }); // the native side names the certificate's holder, never the block's // name - expect(ipc.signPdf.mock.calls[0][2]).toMatchObject({ location: "Paris" }); - expect(ipc.signPdf.mock.calls[0][2]).not.toHaveProperty("name"); + expect(ipc.signPdf.mock.calls[0][0].plan).toMatchObject({ + location: "Paris", + }); + expect(ipc.signPdf.mock.calls[0][0].plan).not.toHaveProperty("name"); expect(signingPlace.value).toBe("Paris"); signingCertificate.value = undefined; }); @@ -373,7 +555,9 @@ describe("signing", () => { it("never signs a PDF without the block's place for the signature", async () => { run(p("Text"), signature()); await flushPromises(); - signDialog.value!.submit(signDialog.value!.targets[0].pos, "", "right"); + signDialog.value!.submit( + choices(signDialog.value!.targets[0].pos, "", "right"), + ); await flushPromises(); expect(ipc.signPdf).not.toHaveBeenCalled(); expect(writeFile).not.toHaveBeenCalled(); @@ -386,6 +570,8 @@ describe("signing", () => { await flushPromises(); expect(signDialog.value!.title).toBe("offer"); expect(signDialog.value!.warnings[0]).toContain("Word"); + // Sign… waits for the user to say they checked every page + expect(signDialog.value!.confirmPages).toBe(true); }); it("asks for the signature first when there is none", async () => { diff --git a/src/editor/commands/signing.ts b/src/editor/commands/signing.ts index 44f5880f..63787646 100644 --- a/src/editor/commands/signing.ts +++ b/src/editor/commands/signing.ts @@ -16,10 +16,19 @@ import { savedMarks, signatureDialog, signDialog, + type SignDialogRequest, signingCertificate, + type SignChoices, signingDates, + signingInitials, signingPlace, } from "../../state"; +import { type Layout, pageGeometry } from "../../layout/resolve"; +import { isHeld } from "../../pdf/stampBooks"; +import { releaseStamp, stampPdf } from "../../pdf/stampEngine"; +import { INITIALS_BOX, INITIALS_STAMP } from "../../signing/initials"; +import { type ExportSigning, exportRequest } from "../../signing/exportPlan"; +import { openSettings } from "./settings"; import { basename } from "../../paths"; import { PDF_FILTER } from "../../formats"; import { toPDF } from "../../exporters"; @@ -116,23 +125,81 @@ export const editMark = return true; }; -// what the notifications of a signed export are titled -const TITLE = "Signed PDF"; +// what the notifications of signing are titled, of an export and of a PDF +// tab alike +export const SIGNED_PDF_TITLE = "Signed PDF"; +const TITLE = SIGNED_PDF_TITLE; /** * signerName returns the name the user signs as: their certificate's, which * a digital signature carries, else the one in Settings ▸ Signing */ -const signerName = () => +export const signerName = () => signingCertificate.value?.name || config.value.signing.name; +/** + * todayText returns today's date as the dates next to signatures are + * written: Settings ▸ Signing ▸ Date, in the language + */ +export const todayText = () => + formatSigningDate( + new Date(), + signingDates.value.language, + signingDates.value.format, + ); + +/** + * signDialogBase returns what the Sign dialog's request is in both flows: + * the place, the signer, today's date, the passphrase's check, the dialogs + * it opens, and `refocus` on cancel + */ +export const signDialogBase = ( + refocus: () => void, +): Pick< + SignDialogRequest, + | "place" + | "signer" + | "today" + | "check" + | "createCertificate" + | "createInitials" + | "cancel" +> => ({ + place: signingPlace.value, + signer: signerName(), + today: { + date: todayText(), + language: languageName(signingDates.value.language), + }, + check: async (passphrase) => { + try { + await checkPassphrase(passphrase); + return null; + } catch (error) { + logWarning("the passphrase didn't open the key", signingKind(error)); + return passphraseProblem(error); + } + }, + createCertificate: (opener) => { + const back = focusBack(opener); + // once there's a certificate, the Sign dialog takes the focus to its + // passphrase itself + void openCertificateDialog("create", () => { + if (!signingCertificate.value) back(); + }); + }, + createInitials: (opener) => + void openSignatureDialog("initials", focusBack(opener)), + cancel: refocus, +}); + /** * passphraseProblem says why a passphrase didn't open the user's key, with * a way on for a forgotten one */ -const passphraseProblem = (error: unknown) => +export const passphraseProblem = (error: unknown) => signingKind(error) === "wrongPassphrase" - ? "The passphrase is wrong. Forgot it? Sign with a picture only, or remove your certificate in Settings ▸ Signing and create a new one." + ? "The passphrase is wrong. Forgot it? Sign with no digital signature, or remove your certificate in Settings ▸ Signing and create a new one." : signingMessage(error, "Blank couldn't open your key"); /** @@ -184,53 +251,36 @@ export const exportSigned = (): Command => (state, dispatch, view) => { const name = config.value.signing.name; const named = file.path ?? file.importedFrom; signDialog.value = { + ...signDialogBase(refocus), + mode: "export", title: named ? basename(named).replace(/\.[^.]+$/, "") : "Untitled", targets, chosen: chosenOf(targets, name), - place: signingPlace.value, - signer: signerName(), - today: { - date: formatSigningDate( - new Date(), - signingDates.value.language, - signingDates.value.format, - ), - language: languageName(signingDates.value.language), - }, warnings: file.importedFrom ? [ "Blank's layout of this Word document can differ from Word's. Check every page before you send it, or ask the sender for a PDF.", ] : [], - check: async (passphrase) => { - try { - await checkPassphrase(passphrase); - return null; - } catch (error) { - logWarning("the passphrase didn't open the key", signingKind(error)); - return passphraseProblem(error); - } - }, - createCertificate: (opener) => { - const back = focusBack(opener); - // once there's a certificate, the Sign dialog takes the focus to - // its passphrase itself - void openCertificateDialog("create", () => { - if (!signingCertificate.value) back(); - }); + confirmPages: !!file.importedFrom, + initials: signingInitials.value, + // the block shows the signature + stamped: true, + emptyFields: 0, + manage: () => { + refocus(); + openSettings("signing")(view.state, view.dispatch); }, - submit: (pos, place, passphrase) => { + submit: (choices) => { refocus(); - if (!shown()) return; + if (!shown() || choices.pos === null) return; + const pos = choices.pos; signNow(view, file, request, { + ...choices, pos, index: targets.findIndex((target) => target.pos === pos), png: signature.png, - place, - passphrase, }); }, - cancel: refocus, }; }; void Promise.all([loadMarks(), loadCertificate()]) @@ -253,7 +303,8 @@ export const exportSigned = (): Command => (state, dispatch, view) => { /** * signNow exports the document as it is now, the dialogs closed, with the * block chosen filled in: the one still at `pos`, else the one of its - * number among them; with neither, it signs nothing + * number among them; with neither, it signs nothing. The other blocks get + * empty signature fields, named after who signs them. */ const signNow = ( view: EditorView, @@ -264,14 +315,10 @@ const signNow = ( index, png, place, + reason, passphrase, - }: { - pos: number; - index: number; - png: string; - place: string; - passphrase: string | null; - }, + initials, + }: SignChoices & { pos: number; index: number; png: string }, ) => { const state = view.state; const targets = targetsOf(state.doc); @@ -283,6 +330,7 @@ const signNow = ( }); return; } + const others = targets.filter((other) => other !== target); // the place the page shows: the block's own, else where the user signs const shownPlace = target.place || place; const signer = signerName(); @@ -307,44 +355,57 @@ const signNow = ( ...signingDates.value, }, }, + ...(others.length ? { others: others.map((other) => other.pos) } : {}), }, written: () => { if (shownPlace) signingPlace.value = shownPlace; + signingInitials.value = initials; announce("Signed PDF exported"); }, done: how, - ...(passphrase === null - ? {} - : { - finish: (result: ExportResult) => - signExport(result, passphrase, shownPlace), - }), + finish: (result: ExportResult, layout: Layout) => + finishExport(result, { + passphrase, + place: shownPlace, + reason, + timestampServer: config.value.signing.timestampServer, + signatureBox: result.signatureBox, + others: others.map((other, at) => ({ + name: other.name || other.role, + box: result.otherBoxes?.[at] ?? null, + })), + initials: initials.on ? initials.side : null, + page: pageGeometry(layout), + }), }); }; /** - * signExport signs an exported PDF with the user's key, its field over the - * signature block the export filled in, signed at `place`; the native side - * names the certificate's holder as who signed it. A failure stops the - * export with what went wrong, so nothing unsigned is written under the - * signed name. + * finishExport adds to an exported PDF what the engine didn't write: the + * user's initials on every page, the others' empty signature fields and, + * with the passphrase, the digital signature, its field over the block the + * export filled in. Without any of them the PDF stays as the engine wrote + * it. A failure stops the export with what went wrong, so nothing unsigned + * is written under the signed name. */ -const signExport = async ( - result: ExportResult, - passphrase: string, - place: string, -) => { - const box = result.signatureBox; - if (!box) throw new Error("the signature block isn't in the PDF"); - const timestampServer = config.value.signing.timestampServer; +const finishExport = async (result: ExportResult, signing: ExportSigning) => { + const asked = exportRequest(signing); + if (!asked) return result.contents; + const parts = [result.contents]; try { - return await signPdf(result.contents, passphrase, { - page: box.page + 1, - rect: [box.x, box.y, box.width, box.height], - location: place || null, - reason: null, - timestampServer: timestampServer || null, - }); + if (asked.initials) { + await loadMarks(); + parts.push( + await stampPdf(INITIALS_STAMP, INITIALS_BOX.width, INITIALS_BOX.height), + ); + } + } finally { + // its engine stays while a PDF tab's initials look the same + if (asked.initials && !isHeld(INITIALS_STAMP)) + void releaseStamp(INITIALS_STAMP).catch(() => {}); + } + try { + return await signPdf(asked.request, parts); } catch (error) { throw new Error(signingFailure(error), { cause: error }); } @@ -354,7 +415,7 @@ const signExport = async ( * signingFailure says why a PDF wasn't signed, that nothing was saved, and, * when the timestamp server gave no timestamp, how to sign without it */ -const signingFailure = (error: unknown) => { +export const signingFailure = (error: unknown) => { const message = signingMessage(error, "Blank couldn't sign the PDF").replace( /\.?$/, ".", diff --git a/src/editor/plugins/keymap.ts b/src/editor/plugins/keymap.ts index b85f1977..ea3d6ccf 100644 --- a/src/editor/plugins/keymap.ts +++ b/src/editor/plugins/keymap.ts @@ -83,6 +83,8 @@ import { import { toggleQuote } from "../commands/quote"; import { focusStop } from "../../state"; import { forPdf, showSignatures } from "../../pdf/commands"; +import { nextShownPlace } from "../../pdf/placeActions"; +import { signShownPdf } from "../../pdf/sign"; import { setTextblock } from "../commands/setTextblock"; import { clearRecentFiles } from "../commands/recentFiles"; import { zoomBy, zoomFit } from "../commands/zoom"; @@ -221,6 +223,8 @@ const commandMap: { [key in CommandIdentifier]: Command } = { [CommandIdentifier.VIEW_ZOOM_FIT]: zoomFit(), [CommandIdentifier.MENU_MAIN]: openMainMenu(), [CommandIdentifier.EDIT_FIND]: openFind(), + [CommandIdentifier.PDF_SIGN]: signShownPdf(), + [CommandIdentifier.PDF_NEXT]: nextShownPlace(), }; // keys that run a command besides its own, which can't be changed in diff --git a/src/editor/requests.ts b/src/editor/requests.ts index c380cb95..f0d2c87a 100644 --- a/src/editor/requests.ts +++ b/src/editor/requests.ts @@ -11,6 +11,7 @@ import { outlinePeek, pageSetup, printDialog, + placeDialog, signDialog, tablePicker, tableToolbar, @@ -47,6 +48,7 @@ export const closeRequests = () => { close(printDialog, (request) => request.cancel()); close(unsavedDialog, (request) => request.cancel()); close(signDialog, (request) => request.cancel()); + close(placeDialog, (request) => request.cancel()); close(tocPopover, (request) => request.close()); close(diagramPopover, (request) => request.close()); close(signaturePopover, (request) => request.close()); diff --git a/src/editor/tabs.test.ts b/src/editor/tabs.test.ts index 5ba53609..3d2a5a42 100644 --- a/src/editor/tabs.test.ts +++ b/src/editor/tabs.test.ts @@ -10,6 +10,8 @@ import { sendNotification } from "@tauri-apps/plugin-notification"; import { schema } from "../markdown"; import { closePdf, openPdf } from "../pdf/ipc"; +import { changeStamps, resetStampBooks, stampsOf } from "../pdf/stampBooks"; +import { type Stamp, stampSpec } from "../pdf/stampModel"; import { activeTab, activeTabId, @@ -29,6 +31,7 @@ import { import { flushPromises } from "../test/async"; import { createTestView, doc, p } from "../test/editor"; import { mockTauriPath } from "../test/tauri"; +import { CHECKING } from "../pdf/toolRules"; import { activateTab, bootTabs, @@ -139,6 +142,7 @@ beforeEach(async () => { vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); vi.mocked(closePdf).mockResolvedValue(); + resetStampBooks(); }); afterEach(() => { @@ -894,4 +898,88 @@ describe("PDF tabs", () => { expect(closePdf).toHaveBeenCalledWith(id); expect(shownPdf.value).toBeNull(); }); + + const stamp = (over: Partial = {}): Stamp => ({ + id: "s1", + kind: "signature", + page: 0, + rect: [100, 100, 180, 60], + natural: [180, 60], + spec: stampSpec("signature"), + ...over, + }); + + it("has unsaved changes while stamps are placed on it", async () => { + await boot(); + await openPaths(["/c.pdf"]); + const id = activeTabId.value!; + + changeStamps(id, [stamp()]); + await nextTick(); + expect(activeTab.value?.unsaved).toBe(true); + + changeStamps(id, []); + await nextTick(); + expect(activeTab.value?.unsaved).toBe(false); + }); + + it("asks to sign a PDF with stamps before it closes", async () => { + await boot(); + await openPaths(["/c.pdf"]); + const id = activeTabId.value!; + changeStamps(id, [stamp()]); + await nextTick(); + + void closeTabs([id]); + await flushPromises(); + expect(unsavedDialog.value).toMatchObject({ + title: "Sign “c” before you close it?", + text: "What you filled in and placed on its pages is lost when you close it without signing.", + saveLabel: "Sign…", + discardLabel: "Discard", + }); + // Sign… keeps it open, to sign it + unsavedDialog.value!.save(); + await flushPromises(); + expect(tabs.value.some((tab) => tab.id === id)).toBe(true); + // it signs: the PDF's rules aren't read in this test, so it says it waits + expect(announcement.value?.text).toBe(CHECKING); + + void closeTabs([id]); + await flushPromises(); + unsavedDialog.value!.discard(); + await flushPromises(); + expect(tabs.value.some((tab) => tab.id === id)).toBe(false); + expect(stampsOf(id)).toEqual([]); + }); + + it("brings back the stamps of a restored PDF tab once it opens", async () => { + await stored([{ id: "a", kind: "pdf", path: "/c.pdf", unsaved: true }]); + await localforage.setItem("stamps:a", { + sha256: "c1", + stamps: [stamp()], + }); + + await boot(); + await flushPromises(); + + expect(stampsOf("a")).toEqual([stamp()]); + expect(activeTab.value?.unsaved).toBe(true); + }); + + it("drops the stamps on pages a changed PDF no longer has", async () => { + await stored([{ id: "a", kind: "pdf", path: "/c.pdf", unsaved: true }]); + await localforage.setItem("stamps:a", { + sha256: "older", + stamps: [stamp(), stamp({ id: "far", page: 3 })], + }); + + await boot(); + await flushPromises(); + + expect(stampsOf("a").map((one) => one.id)).toEqual(["s1"]); + expect(announcement.value?.text).toBe( + "c.pdf changed since you last had it open; 1 stamp was on pages it no longer has", + ); + }); }); diff --git a/src/editor/tabs.ts b/src/editor/tabs.ts index 8f1cd540..94b06cec 100644 --- a/src/editor/tabs.ts +++ b/src/editor/tabs.ts @@ -17,6 +17,8 @@ import { } from "../pdf/document"; import { closePdf } from "../pdf/ipc"; import { pdfPlaceholder } from "../pdf/placeholder"; +import { forgetStamps, hasChanges, restoreStamps } from "../pdf/stampBooks"; +import { signReceived } from "../pdf/sign"; import { timed } from "../engine/perf"; import { errorMessage } from "../errors"; import { bootScope } from "../scope"; @@ -33,8 +35,11 @@ import { pageHoverBlock, pageScrollRequest, path, + placingStamp, rememberFile, + selectedStamp, shownPdf, + stampBooks, spellchecker, type Tab, tabAnnouncement, @@ -48,6 +53,7 @@ import { import { backupTabDocument, loadSession, + loadStamps, loadTabDocument, storeDocument, } from "../storage"; @@ -132,14 +138,17 @@ const docOf = (id: string): Node | undefined => /** * refresh sets whether the tab `id` has changes that aren't saved: its * document differs from the one last opened or saved, so undoing back to it - * clears the dot + * clears the dot; a PDF tab has them while stamps are placed on it or its + * form is filled in, which signing it keeps */ const refresh = (id: string) => { const tab = tabById(id); const kept = documents.get(id); const doc = docOf(id); if (!tab || !kept || !doc) return; - const unsaved = kept.baseline === null || !doc.eq(kept.baseline); + const unsaved = kept.pdf + ? hasChanges(id) + : kept.baseline === null || !doc.eq(kept.baseline); if (unsaved !== tab.unsaved) updateTab(id, { unsaved }); }; @@ -342,6 +351,8 @@ const showTab = (id: string, next: TabDocument, keepLeaving = true) => { } // what pointed into the document that leaves pageScrollRequest.value = null; + selectedStamp.value = null; + placingStamp.value = null; pageDropCaret.value = null; pageDropGap.value = null; pageHoverBlock.value = null; @@ -471,6 +482,10 @@ const openInBackground = (id: string, document: TabDocument) => { if (document.pdf !== opening && shownPdf.value !== opening) return; document.pdf = pdf; if (id === activeTabId.value) shownPdf.value = pdf; + // the stamps kept of it last time, the first time it opens + void loadStamps(id).then((stored) => { + if (documents.get(id) === document) restoreStamps(id, pdf, stored); + }); }); }; @@ -683,6 +698,28 @@ const askToSave = (tab: Tab) => }; }); +/** + * askToSign asks whether to sign the PDF of `tab` before it closes, which + * keeps the stamps placed on it; "save" signs it + */ +const askToSign = (tab: Tab) => + new Promise<"save" | "discard" | "cancel">((resolve) => { + const answer = (choice: "save" | "discard" | "cancel") => () => { + if (!uiTakesFocus.value) view?.focus(); + resolve(choice); + }; + unsavedDialog.value = { + label: tabLabel(tab), + title: `Sign “${tabLabel(tab)}” before you close it?`, + text: "What you filled in and placed on its pages is lost when you close it without signing.", + saveLabel: "Sign…", + discardLabel: "Discard", + save: answer("save"), + discard: answer("discard"), + cancel: answer("cancel"), + }; + }); + /** * removeTab takes the tab `id` out of the row. The tab to its right shows * then, or the one to its left, unless `next` names another; the last one @@ -704,8 +741,10 @@ const removeTab = async ( closedPaths.push(tab.path); if (closedPaths.length > MAX_CLOSED) closedPaths.shift(); } - if (documents.get(id)?.pdf) + if (documents.get(id)?.pdf) { closePdf(id).catch((error) => console.warn("can't close a PDF", error)); + forgetStamps(id); + } if (id !== activeTabId.value) { documents.delete(id); tabs.value = without(tabs.value); @@ -736,7 +775,16 @@ const removeTab = async ( const closeTab = async (id: string): Promise => { const tab = tabById(id); if (!tab) return true; - if (tab.unsaved) { + if (tab.unsaved && isPdfTab(tab)) { + await switchTo(id); + const choice = await askToSign(tab); + if (choice === "cancel") return false; + // signing keeps it open: the signed PDF opens in a tab of its own + if (choice === "save") { + signReceived(); + return false; + } + } else if (tab.unsaved) { await switchTo(id); const choice = await askToSave(tab); if (choice === "cancel") return false; @@ -916,6 +964,11 @@ export const bootTabs = ( checked = doc; refresh(id); }); + // a PDF tab's dot follows its stamps + watch(stampBooks, (books, before) => { + for (const id of new Set([...books.keys(), ...(before?.keys() ?? [])])) + if (books.get(id) !== before?.get(id)) refresh(id); + }); started(); onScopeDispose(() => { unlisten?.(); diff --git a/src/engine/__fixtures__/contract.json b/src/engine/__fixtures__/contract.json index ca15781c..38ad6efb 100644 --- a/src/engine/__fixtures__/contract.json +++ b/src/engine/__fixtures__/contract.json @@ -626,5 +626,34 @@ "date": "1 January 2026", "file": "contract" } + }, + "stamp": { + "items": [ + { + "kind": "stamp", + "pos": 0, + "src": "blank:initials", + "width": 60, + "height": 30, + "alt": "Initials", + "lines": [ + "12 October 2026", + "Berlin" + ], + "size": 9, + "align": "center" + } + ], + "settings": { + "width": 120, + "height": 60, + "margins": { + "top": 0, + "right": 0, + "bottom": 0, + "left": 0 + }, + "stamp": true + } } } diff --git a/src/engine/boxes.ts b/src/engine/boxes.ts index 82f75896..be0a06f0 100644 --- a/src/engine/boxes.ts +++ b/src/engine/boxes.ts @@ -27,3 +27,20 @@ export const rectsOf = (values: Float32Array): PageBox[] => { } return rects; }; + +/** + * signingBoxes returns where a signed export's signature blocks are, for + * its result: the one filled in (at `signed`) and the others (at `others`, + * null for one not on the pages), each the first box `boxesOf` gives the + * block at a position, on the shared engine and in the PDF worker alike + */ +export const signingBoxes = ( + boxesOf: (pos: number) => PageBox[], + signed: number | undefined, + others: readonly number[] | undefined, +): { signatureBox?: PageBox; otherBoxes?: (PageBox | null)[] } => ({ + ...(signed === undefined ? {} : { signatureBox: boxesOf(signed)[0] }), + ...(others + ? { otherBoxes: others.map((pos) => boxesOf(pos)[0] ?? null) } + : {}), +}); diff --git a/src/engine/contract.test.ts b/src/engine/contract.test.ts index 7a24edd8..87861225 100644 --- a/src/engine/contract.test.ts +++ b/src/engine/contract.test.ts @@ -27,6 +27,7 @@ import { tr, ul, } from "../test/editor"; +import { stampItem, stampSettings } from "../pdf/stamps"; import { diagram } from "../test/sources"; import { settingsOf } from "./engine"; import { flatten } from "./flatten"; @@ -201,7 +202,22 @@ describe("the engine's contract", () => { now: new Date("2026-01-01T00:00:00Z"), locale: "en-GB", }); - const sent = { items, settings: settingsOf(layout, fields) }; + // and a stamp engine's page: initials over two lines + const stamp = { + items: [ + stampItem( + { + image: "initials", + lines: ["12 October 2026", "Berlin"], + size: 9, + align: "center", + }, + { source: { kind: "image" }, png: "", width: 60, height: 30 }, + ), + ], + settings: stampSettings(120, 60), + }; + const sent = { items, settings: settingsOf(layout, fields), stamp }; expect(items.some((item) => item.barsContinue)).toBe(true); await expect(JSON.stringify(sent, null, 2) + "\n").toMatchFileSnapshot( "./__fixtures__/contract.json", diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 8a6336ec..8aa2d4f0 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -481,6 +481,22 @@ export class PageEngine { }); } + /** + * setContent lays out `items` on the page of `settings`, as model.rs reads + * them, e.g. a stamp's (src/pdf/stampEngine.ts), which aren't flattened + * from a document: the next sync starts over + */ + setContent(settings: object, items: readonly object[]) { + this.call(undefined, () => { + this.doc = null; + this.records = []; + this.blockStarts = []; + this.settings = ""; + this.raw.setSettings(JSON.stringify(settings)); + this.raw.setItems(JSON.stringify(items)); + }); + } + /** * sync hands the engine what changed in the document since the last sync: * with `changes`, only the top-level blocks they touch are flattened diff --git a/src/engine/pdf.test.ts b/src/engine/pdf.test.ts index bc71ce92..483ee618 100644 --- a/src/engine/pdf.test.ts +++ b/src/engine/pdf.test.ts @@ -256,6 +256,48 @@ describe("a signed PDF", () => { }, ); + it("says where the other signature blocks are, for their empty fields", async () => { + const state = EditorState.create({ + schema, + doc: parseMarkdown( + [ + "The tenant rents the flat.", + '', + "", + '', + ].join("\n\n"), + ), + }); + const blocks: number[] = []; + state.doc.forEach((node, offset) => { + if (node.type.name === "signature") blocks.push(offset); + }); + const { signatureBox, otherBoxes } = await toPDF(state, { + docPath: null, + layout: testLayout(), + signed: { + pos: blocks[0], + src: `data:image/png;base64,${IMAGES.png}`, + signing: { + name: "Ada", + place: "", + date: new Date(2026, 9, 8), + format: "iso", + language: "en", + }, + }, + others: [blocks[1], 9999], + }); + expect(signatureBox?.page).toBe(0); + expect(otherBoxes).toHaveLength(2); + // the landlord's block on the second page, where the signed one starts + expect(otherBoxes![0]).toMatchObject({ page: 1, x: signatureBox!.x }); + expect(otherBoxes![0]!.width).toBeGreaterThan(100); + expect(otherBoxes![0]!.height).toBeGreaterThan(20); + // a position without a block has no box + expect(otherBoxes![1]).toBeNull(); + }); + it("isn't written without the signature's picture", async () => { const state = signedState(); await expect( diff --git a/src/engine/pdf.ts b/src/engine/pdf.ts index 6e909c35..7fd6b20c 100644 --- a/src/engine/pdf.ts +++ b/src/engine/pdf.ts @@ -28,6 +28,7 @@ import { newEngine, settingsOf, } from "./engine"; +import { signingBoxes } from "./boxes"; import { fallbackFonts, findFonts } from "./fallback"; import { language, signingDates } from "../state"; import { @@ -82,6 +83,8 @@ interface Prepared { // the diagram module's drawings, by the src of the image each is drawn // in place of drawings: Map; + // the positions of the other signature blocks, whose boxes it gives + others?: number[]; } /** @@ -104,6 +107,7 @@ const onSharedEngine = async ({ output, flattening, drawings, + others, }: Prepared): Promise => { const { signed } = flattening; const engine = await newEngine(true); @@ -141,9 +145,7 @@ const onSharedEngine = async ({ ...written, missing: engine.missing(), warnings: engine.pdfWarnings(), - ...(signed - ? { signatureBox: engine.boxes(signed.pos, signed.pos + 1)[0] } - : {}), + ...signingBoxes((pos) => engine.boxes(pos, pos + 1), signed?.pos, others), }; } finally { engine.free(); @@ -223,6 +225,7 @@ const jobOf = ( output, flattening, drawings, + others, }: Prepared, fonts: Uint8Array[], ): PdfJob => ({ @@ -249,6 +252,7 @@ const jobOf = ( ? { sheets: JSON.stringify(output.sheets) } : { pages: output.pages }), ...(flattening.signed ? { signedPos: flattening.signed.pos } : {}), + ...(others ? { otherPos: others } : {}), }); /** @@ -376,7 +380,7 @@ export const describeWarnings = ( */ const write = async ( state: EditorState, - { docPath, layout, signed }: Omit, + { docPath, layout, signed, others }: Omit, output: Output, ): Promise => { // what the sources make, e.g. diagrams, drawn before they're laid out, @@ -423,6 +427,7 @@ const write = async ( output, flattening: { signed: signed ?? null, dates: signingDates.value }, drawings, + ...(others ? { others } : {}), }; let result: PdfResult | null = null; if (!engineInstanceBroken()) { @@ -452,6 +457,7 @@ const write = async ( ], pages: result.pages, signatureBox: result.signatureBox, + ...(result.otherBoxes ? { otherBoxes: result.otherBoxes } : {}), }; }; diff --git a/src/engine/pdfJob.test.ts b/src/engine/pdfJob.test.ts index f9974ac4..ab556b94 100644 --- a/src/engine/pdfJob.test.ts +++ b/src/engine/pdfJob.test.ts @@ -4,6 +4,7 @@ import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { documentFields } from "../layout/bands"; +import { schema } from "../markdown"; import { doc, h, p } from "../test/editor"; import { testEngine } from "../test/engine"; import { testLayout } from "../test/layout"; @@ -113,6 +114,24 @@ describe("writePdf", () => { expect(() => writePdf(LayoutEngine, jobOf().job)).toThrow("unreachable"); }); + it("gives the boxes of the signature blocks asked for", () => { + testEngine(); + const block = () => schema.nodes.signature.create({ role: "Tenant" }); + // the blocks at 0 and 1 + const { job } = jobOf(doc(block(), block())); + const result = writePdf(LayoutEngine, { + ...job, + signedPos: 0, + otherPos: [1, 9999], + }); + expect(result.signatureBox).toMatchObject({ page: 0 }); + expect(result.otherBoxes).toEqual([ + { ...result.signatureBox, y: expect.any(Number) }, + null, + ]); + expect(result.otherBoxes![0]!.y).toBeGreaterThan(result.signatureBox!.y); + }); + it("tells which characters no font has", () => { testEngine(); const { job } = jobOf(doc(p("中文"))); diff --git a/src/engine/pdfJob.ts b/src/engine/pdfJob.ts index f4b4843c..1db1e659 100644 --- a/src/engine/pdfJob.ts +++ b/src/engine/pdfJob.ts @@ -1,6 +1,6 @@ import type { ImageMime } from "../images/mime"; import type { LayoutEngine } from "./wasm/blank_layout.js"; -import { type PageBox, rectsOf } from "./boxes"; +import { type PageBox, rectsOf, signingBoxes } from "./boxes"; // A PDF written by an engine of its own, from what the main thread prepared: // the fonts, the images, the settings and the flattened items. The PDF @@ -32,6 +32,9 @@ export interface PdfJob { // the position of the signature block a signed PDF fills in, whose box // the result gives signedPos?: number; + // the positions of the other signature blocks, whose boxes the result + // gives, for the empty fields of the others who sign + otherPos?: number[]; } // what an image's file is, as the engine's addImage takes it: a PNG or a @@ -58,6 +61,9 @@ export interface PdfResult { warnings: PdfWarning[]; // where the filled in signature block is, for the signature's field signatureBox?: PageBox; + // where the other signature blocks are, in the order of `otherPos`; null + // for one the PDF doesn't show + otherBoxes?: (PageBox | null)[]; } /** @@ -110,14 +116,15 @@ export const writePdf = ( ), pages: job.pages?.length ?? engine.pageCount(), }; - const signed = job.signedPos; return { ...written, missing: engine.missing(), warnings: JSON.parse(engine.pdfWarnings()) as PdfWarning[], - ...(signed === undefined - ? {} - : { signatureBox: rectsOf(engine.boxes(signed, signed + 1))[0] }), + ...signingBoxes( + (pos) => rectsOf(engine.boxes(pos, pos + 1)), + job.signedPos, + job.otherPos, + ), }; } finally { try { diff --git a/src/engine/wasm/blank_layout_bg.wasm b/src/engine/wasm/blank_layout_bg.wasm index a93899b3..e3677e36 100644 Binary files a/src/engine/wasm/blank_layout_bg.wasm and b/src/engine/wasm/blank_layout_bg.wasm differ diff --git a/src/exporters/types.ts b/src/exporters/types.ts index fca0e286..f9431aa4 100644 --- a/src/exporters/types.ts +++ b/src/exporters/types.ts @@ -14,6 +14,9 @@ export interface ExportContext { pages?: number[]; // for a signed PDF: the signature block to fill in signed?: SignedBlock; + // for a signed PDF: the positions of the other signature blocks, whose + // boxes the result gives + others?: number[]; } export interface ExportResult { @@ -25,6 +28,9 @@ export interface ExportResult { // for a signed PDF: where the filled in signature block is, in points // from the top left of its page (counted from 0) signatureBox?: PageBox; + // where the blocks of `others` are, in their order, the same way; null + // for one the PDF doesn't show + otherBoxes?: (PageBox | null)[]; } export type exporterFunc = ( diff --git a/src/pdf/commands.test.ts b/src/pdf/commands.test.ts index acf229d8..8da01b33 100644 --- a/src/pdf/commands.test.ts +++ b/src/pdf/commands.test.ts @@ -47,7 +47,7 @@ describe("commands in a PDF tab", () => { expect(pdfRefusal(CommandIdentifier.EDIT_FIND)).toBe( "Blank can't search PDFs yet", ); - expect(pdfRefusal(CommandIdentifier.FILE_SAVE)).toMatch(/nothing to save/); + expect(pdfRefusal(CommandIdentifier.FILE_SAVE)).toMatch(/Sign the PDF/); expect(pdfRefusal(CommandIdentifier.TAB_CLOSE)).toBeNull(); expect(pdfRefusal(CommandIdentifier.APP_SETTINGS)).toBeNull(); }); diff --git a/src/pdf/commands.ts b/src/pdf/commands.ts index f8bd3db0..e4de41cf 100644 --- a/src/pdf/commands.ts +++ b/src/pdf/commands.ts @@ -4,11 +4,15 @@ import { commandLabel } from "../commandList"; import { CommandIdentifier } from "../config"; import { activeTab, + activeTabId, announce, isPdfTab, shownPdf, signaturesPopover, } from "../state"; +import { signShownPdf } from "./sign"; +import { stepStamps } from "./placeActions"; +import { canRedoStamps, canUndoStamps } from "./stampBooks"; // What the commands do in a PDF tab, whose editor holds no text (see // placeholder.ts): those of the window, the tabs and the app run as @@ -42,11 +46,17 @@ const RUN_IN_PDF = new Set([ CommandIdentifier.APP_SHORTCUTS, CommandIdentifier.APP_GUIDE, CommandIdentifier.APP_ABOUT, + CommandIdentifier.PDF_SIGN, + CommandIdentifier.PDF_NEXT, ]); -// what signing says in a PDF tab: received PDFs are signed with PR 3b +// what Save says in a PDF tab, whose key signs it +export const SIGN_TO_KEEP = "Sign the PDF to keep what you placed on it"; + +// what signing your own documents says in a PDF tab, which is signed with +// its signing toolbar export const SIGN_RECEIVED_REFUSAL = - "Blank can't sign PDFs you received yet. Sign your own documents with Export signed PDF."; + "In a PDF, place your signature from the signing toolbar, then choose Sign on it. Export signed PDF signs your own documents."; /** * pdfRefusal returns what the command `id` says in a PDF tab instead of @@ -57,12 +67,19 @@ export const SIGN_RECEIVED_REFUSAL = export const pdfRefusal = (id: CommandIdentifier): string | null => { if (RUN_IN_PDF.has(id)) return null; switch (id) { + // Save's key signs it, which keeps what was placed on it (forPdf) case CommandIdentifier.FILE_SAVE: case CommandIdentifier.FILE_SAVE_AS: - return "Blank doesn't change PDFs, so there's nothing to save"; + return SIGN_TO_KEEP; + // the stamps' history (forPdf) case CommandIdentifier.UNDO: + return canUndoStamps(activeTabId.value) + ? null + : "There's nothing to undo in this PDF"; case CommandIdentifier.REDO: - return "There's nothing to undo in a PDF"; + return canRedoStamps(activeTabId.value) + ? null + : "There's nothing to redo in this PDF"; case CommandIdentifier.INSERT_BLOCK: case CommandIdentifier.VIEW_BLOCKS: return "Blocks go into your own documents"; @@ -103,15 +120,36 @@ export const pdfRefusal = (id: CommandIdentifier): string | null => { } }; +// what Save, Undo and Redo do in a PDF tab: sign it, and step through the +// history of its stamps +const stampHistory = + (undo: boolean): Command => + (_state, dispatch) => { + if (dispatch && activeTabId.value) stepStamps(activeTabId.value, undo); + return true; + }; +const IN_PDF: Partial> = { + [CommandIdentifier.UNDO]: stampHistory(true), + [CommandIdentifier.REDO]: stampHistory(false), +}; + +const sign = signShownPdf(); + /** * forPdf returns `command` as the command `id` runs: in a PDF tab, one that - * doesn't run there can't (`can` is false), and its key says why + * doesn't run there can't (`can` is false), and its key says why; Undo and + * Redo go through its stamps' history, and Save's key signs it (the menu + * offers Sign… for that) */ export const forPdf = (id: CommandIdentifier, command: Command): Command => (state, dispatch, view) => { - const refusal = isPdfTab(activeTab.value) ? pdfRefusal(id) : null; - if (refusal === null) return command(state, dispatch, view); + const inPdf = isPdfTab(activeTab.value); + if (inPdf && dispatch && id === CommandIdentifier.FILE_SAVE) + return sign(state, dispatch, view); + const refusal = inPdf ? pdfRefusal(id) : null; + if (refusal === null) + return ((inPdf && IN_PDF[id]) || command)(state, dispatch, view); if (!dispatch) return false; announce(refusal); return true; diff --git a/src/pdf/fills.test.ts b/src/pdf/fills.test.ts new file mode 100644 index 00000000..b4b1c63c --- /dev/null +++ b/src/pdf/fills.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + announcement, + fieldProblem, + pageRevisions, + shownPdfRules, + signingTabs, + stampBooks, +} from "../state"; +import { flushPromises } from "../test/async"; +import { + FORM_FIELDS, + pdfDocument, + pdfRulesOf, + resetPdfState, + showPdf, +} from "../test/pdf"; +import { + commitFill, + FORM_FAILED, + forgetFills, + pageRevision, + syncFills, +} from "./fills"; +import { type Fill, setFills } from "./ipc"; +import { fillsOf, redoStamps, undoStamps } from "./stampBooks"; +import { CHECKING, FIELD_LOCKED, RESTRICTED, SIGNING } from "./toolRules"; + +// The fills of a PDF tab's form (fills.ts): one after the other, each a +// step of the tab's history, refused where the field can't take them, and +// sent again whenever the history holds another set than the helper. + +vi.mock("./ipc", async (original) => ({ + ...(await original()), + setFills: vi.fn(), +})); + +vi.mock("./stampEngine", () => ({ releaseStamp: vi.fn(async () => {}) })); + +vi.mock("../engine/engine", async (original) => ({ + ...(await original()), + engineless: () => false, +})); + +const [name, agree] = FORM_FIELDS; +const pdf = pdfDocument(2); +const past = () => stampBooks.value.get("t")?.book.past.length ?? 0; + +// a call of setFills that answers once `answer` is called +const held = () => { + let answer: (pages: number[]) => void = () => {}; + vi.mocked(setFills).mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)), + ); + return (pages: number[]) => answer(pages); +}; + +describe("the fills of a PDF's form", () => { + beforeEach(() => { + vi.mocked(setFills).mockResolvedValue([0]); + showPdf(pdf); + }); + + afterEach(() => { + resetPdfState(); + }); + + it("fill fields one after another, each on the set before it", async () => { + const first = held(); + const second = held(); + const ada = commitFill(name, "Ada"); + const ticked = commitFill(agree, true); + await flushPromises(); + // the second waits for the first + expect(setFills).toHaveBeenCalledTimes(1); + first([0]); + await flushPromises(); + second([0]); + expect(await ada).toBe(true); + expect(await ticked).toBe(true); + const both: Fill[] = [ + { field: "name", value: "Ada" }, + { field: "agree", value: true }, + ]; + expect(vi.mocked(setFills).mock.lastCall).toEqual(["t", both]); + expect(fillsOf("t")).toEqual(both); + expect(past()).toBe(2); + }); + + it("make no step for what a field holds already, e.g. Enter then leaving it", async () => { + await Promise.all([commitFill(name, "Ada"), commitFill(name, "Ada")]); + expect(setFills).toHaveBeenCalledTimes(1); + expect(past()).toBe(1); + }); + + it("draw the pages a fill changed again", async () => { + vi.mocked(setFills).mockResolvedValue([1]); + await commitFill(name, "Ada"); + expect(pageRevision("t", 0)).toBe(0); + expect(pageRevision("t", 1)).toBe(1); + // a closed tab's go + forgetFills(new Set()); + expect(pageRevisions.value.size).toBe(0); + }); + + it("keep the last set when one is refused, and say why under the field", async () => { + vi.mocked(setFills).mockRejectedValue({ + kind: "missingGlyphs", + field: "name", + }); + expect(await commitFill(name, "Łukasz Ωμέγα")).toBe(false); + // naming the first character the font can't show + const text = "This field's font can't show “Ł”."; + expect(fieldProblem.value).toEqual({ tab: "t", field: "name", text }); + expect(announcement.value?.text).toBe(text); + expect(fillsOf("t")).toEqual([]); + expect(past()).toBe(0); + }); + + it("refuse fields Blank can't fill in now, and say why", async () => { + const refused = async (why: string) => { + expect(await commitFill(name, "Ada")).toBe(false); + expect(announcement.value?.text).toBe(why); + expect(setFills).not.toHaveBeenCalled(); + }; + shownPdfRules.value = { + sha256: "abc", + rules: pdfRulesOf({ locked: ["name"] }), + }; + await refused(FIELD_LOCKED); + shownPdfRules.value = null; + await refused(CHECKING); + shownPdfRules.value = { + sha256: "abc", + rules: pdfRulesOf({ encrypted: true }), + }; + await refused(RESTRICTED); + shownPdfRules.value = { sha256: "abc", rules: pdfRulesOf() }; + signingTabs.value = new Set(["t"]); + await refused(SIGNING); + }); + + it("send the set undo and redo bring back", async () => { + await commitFill(name, "Ada"); + undoStamps("t"); + await syncFills(); + expect(vi.mocked(setFills).mock.lastCall).toEqual(["t", []]); + redoStamps("t"); + await syncFills(); + expect(vi.mocked(setFills).mock.lastCall).toEqual([ + "t", + [{ field: "name", value: "Ada" }], + ]); + // the helper holds it: nothing to send + await syncFills(); + expect(setFills).toHaveBeenCalledTimes(3); + }); + + it("say when the helper didn't take the set, and send it again later", async () => { + await commitFill(name, "Ada"); + undoStamps("t"); + vi.mocked(setFills).mockRejectedValueOnce("crashed"); + await syncFills(); + expect(announcement.value?.text).toBe(FORM_FAILED); + await syncFills(); + expect(setFills).toHaveBeenCalledTimes(3); + expect(vi.mocked(setFills).mock.lastCall).toEqual(["t", []]); + }); +}); diff --git a/src/pdf/fills.ts b/src/pdf/fills.ts new file mode 100644 index 00000000..5072b2db --- /dev/null +++ b/src/pdf/fills.ts @@ -0,0 +1,202 @@ +import { logWarning } from "../log"; +import { + activeTabId, + announce, + fieldProblem, + pageRevisions, + shownPdf, +} from "../state"; +import { + failedField, + failureOf, + type Fill, + type OpenedPdf, + setFills, +} from "./ipc"; +import { fieldValue, missingGlyphs, NOT_TAKEN } from "./placeModel"; +import type { PdfField } from "./ipc"; +import { shownContext } from "./placesRead"; +import { changeSheet, fillsOf } from "./stampBooks"; +import { withFill } from "./stampModel"; +import { fieldRefusal } from "./toolRules"; + +export const FILL_FAILED = "Blank couldn't fill in this field"; +export const FORM_FAILED = "Blank couldn't fill in this PDF's form"; + +// The fills of a PDF tab's form: MuPDF in the tab's helper writes them +// (`setFills`, always the whole set) and draws their pages again, which +// show anew through `pageRevisions`. A fill is one step of the tab's +// history, next to its stamps; undo, redo, a restore and a helper opened +// afresh (Open again) send the set the history holds (`syncFills`). + +// the set each tab's helper holds, for the PDF it opened +const sent = new Map(); + +const same = (a: readonly Fill[], b: readonly Fill[]) => + a.length === b.length && + a.every( + (fill, at) => fill.field === b[at].field && fill.value === b[at].value, + ); + +/** + * redraw has the pages `pages` (from 0) of the tab `tab` drawn again + */ +const redraw = (tab: string, pages: readonly number[]) => { + if (!pages.length) return; + const next = new Map(pageRevisions.value); + for (const page of pages) { + const key = `${tab}:${page}`; + next.set(key, (next.get(key) ?? 0) + 1); + } + pageRevisions.value = next; +}; + +/** + * pageRevision returns how often the page `page` of the tab `tab` was + * filled in anew, part of its bitmaps' keys + */ +export const pageRevision = (tab: string, page: number) => + pageRevisions.value.get(`${tab}:${page}`) ?? 0; + +// the work on each tab's fills, one after the other, so a fill never sends +// a set that misses one sent just before it +const queues = new Map>(); + +/** + * queued runs `task` once the fills of the tab `tab` sent before it are + * done + */ +const queued = (tab: string, task: () => Promise): Promise => { + const run = (queues.get(tab) ?? Promise.resolve()).then(task); + queues.set( + tab, + run.catch(() => {}), + ); + return run; +}; + +/** + * commitFill fills the field `field` of the PDF shown in with `value`, as + * one step of its history, and draws the pages it changed again; a value + * the field holds already is no step. A value the field's font can't show, + * or one it doesn't take, is refused: the field says so under it, and the + * last set stays. Fills run one after the other, each on the set the one + * before it left. + * @returns whether the field holds `value` now + */ +export const commitFill = ( + field: PdfField, + value: string | boolean, +): Promise => { + const tab = activeTabId.value; + const opened = shownPdf.value?.opened; + if (!tab || !opened) return Promise.resolve(false); + const refusal = fieldRefusal(field.name, shownContext()); + if (refusal) { + announce(refusal); + return Promise.resolve(false); + } + return queued(tab, async () => { + // the tab opened another PDF meanwhile + if (activeTabId.value !== tab || shownPdf.value?.opened !== opened) + return false; + const fills = fillsOf(tab); + if (fieldValue(field, fills) === value) { + clearProblem(tab, field.name); + return true; + } + const next = withFill(fills, { field: field.name, value }); + let pages: number[]; + try { + pages = await setFills(tab, [...next]); + } catch (error) { + const failure = failureOf(error); + if (failure === "missingGlyphs" || failure === "badRequest") { + // the field the helper names, else the one just filled in + const failed = failedField(error) ?? field.name; + fieldProblem.value = { + tab, + field: failed, + text: + failure === "missingGlyphs" + ? missingGlyphs(next.find((fill) => fill.field === failed)?.value) + : NOT_TAKEN, + }; + announce(fieldProblem.value.text); + } else if (failure !== "closed") { + logWarning("can't fill in a PDF's field", failure); + announce(FILL_FAILED); + } + return false; + } + sent.set(tab, { opened, fills: next }); + changeSheet(tab, { fills: next }, { sha256: opened.sha256 }); + clearProblem(tab, field.name); + redraw(tab, pages); + return true; + }); +}; + +const clearProblem = (tab: string, field: string) => { + const problem = fieldProblem.value; + if (problem?.tab === tab && problem.field === field) + fieldProblem.value = null; +}; + +/** + * syncFills sends the fills the history of the tab shown holds to its + * helper, when they aren't the ones it holds: after undo or redo, once they + * were restored, or to a helper opened afresh; then the pages they changed + * are drawn again. What the helper holds is known once it took them; a set + * it didn't take is said, and sent again with the next change. + */ +export const syncFills = async () => { + const tab = activeTabId.value; + const opened = shownPdf.value?.opened; + if (!tab || !opened) return; + await queued(tab, async () => { + if (activeTabId.value !== tab || shownPdf.value?.opened !== opened) return; + const fills = fillsOf(tab); + const was = sent.get(tab); + // a helper opened afresh holds none + const before = was?.opened === opened ? was.fills : []; + if (same(before, fills)) { + sent.set(tab, { opened, fills }); + return; + } + try { + const pages = await setFills(tab, [...fills]); + sent.set(tab, { opened, fills }); + redraw(tab, pages); + } catch (error) { + const failure = failureOf(error); + if (failure === "closed") return; + logWarning("can't fill in a PDF's form", failure); + announce(FORM_FAILED); + } + }); +}; + +/** + * forgetFills forgets what the helpers of the tabs not in `open` hold, and + * how often their pages were filled in anew + */ +export const forgetFills = (open: ReadonlySet) => { + for (const tab of sent.keys()) if (!open.has(tab)) sent.delete(tab); + for (const tab of queues.keys()) if (!open.has(tab)) queues.delete(tab); + const kept = [...pageRevisions.value].filter(([key]) => + open.has(key.slice(0, key.lastIndexOf(":"))), + ); + if (kept.length !== pageRevisions.value.size) + pageRevisions.value = new Map(kept); +}; + +/** + * resetFills forgets what every helper holds, for the tests + */ +export const resetFills = () => { + sent.clear(); + queues.clear(); + pageRevisions.value = new Map(); + fieldProblem.value = null; +}; diff --git a/src/pdf/ipc.test.ts b/src/pdf/ipc.test.ts new file mode 100644 index 00000000..a145a5d2 --- /dev/null +++ b/src/pdf/ipc.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { failedField, failureOf, readBitmap } from "./ipc"; + +describe("what the PDF tabs' native side sends", () => { + it("tells its failures apart by kind, and names a refused fill's field", () => { + expect(failureOf("changed")).toBe("changed"); + // pdf_set_fills fails with the field whose fill was refused + const refused = { kind: "missingGlyphs", field: "form.name" }; + expect(failureOf(refused)).toBe("missingGlyphs"); + expect(failedField(refused)).toBe("form.name"); + expect(failedField({ kind: "crashed", field: null })).toBeNull(); + // anything else is the webview's own + expect(failureOf(new Error("boom"))).toBe("engine"); + expect(failedField("badRequest")).toBeNull(); + }); + + it("reads a page's pixels, with its size at the end", () => { + const buffer = new ArrayBuffer(2 * 1 * 4 + 8); + new Uint8Array(buffer).set([1, 2, 3, 4, 5, 6, 7, 8]); + const sizes = new DataView(buffer, 8); + sizes.setUint32(0, 2, true); + sizes.setUint32(4, 1, true); + const bitmap = readBitmap(buffer); + expect(bitmap.width).toBe(2); + expect(bitmap.height).toBe(1); + expect([...bitmap.pixels]).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); +}); diff --git a/src/pdf/ipc.ts b/src/pdf/ipc.ts index 7be9c012..bf46b871 100644 --- a/src/pdf/ipc.ts +++ b/src/pdf/ipc.ts @@ -24,7 +24,9 @@ export interface OpenedPdf { encrypted: boolean; } -// why a call failed, as the native side says it (`PdfError::kind`), or +// why a call failed, as the native side says it (`PdfError::kind`: e.g. +// "missingGlyphs" for a fill with characters the field's font can't show, +// "badRequest" for one the field can't take), or // "unreadable" for a file that can't be read, "closed" for a tab whose PDF // was closed meanwhile, and for a file read again (a helper that stopped // opens it again) "missing" when it has gone, "changed" when it holds @@ -41,6 +43,9 @@ export type PdfFailure = | "tooComplex" | "cancelled" | "engine" + | "repaired" + | "encrypted" + | "missingGlyphs" | "timedOut" | "crashed" | "hung" @@ -48,12 +53,35 @@ export type PdfFailure = | "start" | "source"; +// how `pdf_set_fills` fails: the kind, and the field whose fill was refused +// (its full name), if it was one fill +export interface FillFailure { + kind: PdfFailure; + field: string | null; +} + +const isFillFailure = (error: unknown): error is FillFailure => + !!error && + typeof error === "object" && + typeof (error as { kind?: unknown }).kind === "string"; + /** * failureOf returns the kind of failure the native side sent, "engine" for * any other error, e.g. of the webview */ export const failureOf = (error: unknown): PdfFailure => - typeof error === "string" ? (error as PdfFailure) : "engine"; + typeof error === "string" + ? (error as PdfFailure) + : isFillFailure(error) + ? error.kind + : "engine"; + +/** + * failedField returns the field whose fill `pdf_set_fills` refused, if it + * named one + */ +export const failedField = (error: unknown) => + isFillFailure(error) ? error.field : null; /** * openPdf opens the PDF at `path` in the tab `tab`, with a helper of its @@ -114,3 +142,72 @@ export const closePdf = (tab: string) => invoke("pdf_close", { tab }); */ export const verifyPdf = (tab: string) => invoke("pdf_verify", { tab }); + +// a box on a page, [x, y, width, height] in display points +export type PdfRect = [number, number, number, number]; + +// a page's text: its words in reading order, and the thin horizontal lines +// drawn on it (at least 40 pt wide, at most 2 pt high), where people sign or +// write. Never logged: it is what the PDF says +export interface PageText { + words: { text: string; rect: PdfRect }[]; + rules: PdfRect[]; +} + +/** + * pageText reads the words and lines of the page `page` (from 0) of the tab + * `tab` + */ +export const pageText = (tab: string, page: number) => + invoke("pdf_text", { tab, page }); + +export type PdfFieldKind = + "text" | "checkbox" | "radio" | "combo" | "list" | "button" | "signature"; + +// a field of a PDF's form, as protocol.rs's Field +export interface PdfField { + // its full name, which a Fill names + name: string; + kind: PdfFieldKind; + // its /TU, what it is called for people + label: string | null; + // its value: a checkbox's or radio button group's state ("Off" for none) + value: string; + // a choice's options + options: string[]; + flags: { + readOnly: boolean; + required: boolean; + multiline: boolean; + comb: boolean; + maxLen: number | null; + }; + // a signature field that holds a signature + signed: boolean; + // where it shows; `onValue` is a checkbox's or radio button's state when on + widgets: { page: number; rect: PdfRect; onValue: string | null }[]; +} + +/** + * pdfFields reads the form's fields of the tab's PDF, with the values its + * fills gave them + */ +export const pdfFields = (tab: string) => + invoke("pdf_fields", { tab }); + +// what a field (by its full name) is filled in with: a checkbox's true or +// false; text for a text field, a choice (one of its options, any text in a +// combo box that may be edited) and a radio button group (the `onValue` of +// the button chosen, "Off" for none) +export interface Fill { + field: string; + value: string | boolean; +} + +/** + * setFills fills the form of the tab's PDF in with `fills`, always the whole + * set: the pages to draw again. Refused with "missingGlyphs" or + * "badRequest", the last set stays. Values are never logged + */ +export const setFills = (tab: string, fills: Fill[]) => + invoke("pdf_set_fills", { tab, fills }); diff --git a/src/pdf/placeActions.ts b/src/pdf/placeActions.ts new file mode 100644 index 00000000..4e6c86cd --- /dev/null +++ b/src/pdf/placeActions.ts @@ -0,0 +1,283 @@ +import type { Command } from "prosemirror-state"; +import { nextTick } from "vue"; + +import { config } from "../config"; +import { openSignatureDialog, todayText } from "../editor/commands/signing"; +import { logWarning } from "../log"; +import { + activeTab, + activeTabId, + announce, + isPdfTab, + lastPlace, + selectedStamp, + shownPdf, + signingCertificate, + signingPlace, + stampBooks, +} from "../state"; +import { PDF_VIEW_ID } from "./placeholder"; +import { + fitLines, + fitMark, + lettersOn, + MARK_ON_LINE, + nextMessage, + PLACE_DISMISSED, + placeElementId, +} from "./placeModel"; +import { + ALL_FILLED, + dismissPlace, + fillOf, + type Place, + type Prefill, +} from "./places"; +import { + shownBlocked, + shownContext, + shownNext, + shownPlaces, +} from "./placesRead"; +import { + focusStamp, + knownPlace, + newStamp, + placedMessage, +} from "./stampActions"; +import { + changeSheet, + changeStamps, + dismissedOf, + redoStamps, + stampsOf, + undoStamps, +} from "./stampBooks"; +import { STAMPS_NEED_LAYOUT } from "./stamps"; +import { + type Stamp, + type StampKind, + stepChange, + withStamp, +} from "./stampModel"; +import { placeRefusal } from "./toolRules"; + +// What the places of the PDF shown do when the user fills them: a stamp +// sized into a signature place or a found line, a text field prefilled, +// dismissing a found place, and Next. + +/** + * prefillText returns what a text field asking for `prefill` starts with: + * the user's name, today's date, where they sign, or both + */ +export const prefillText = (prefill: Prefill | undefined) => { + switch (prefill) { + case "name": + return config.value.signing.name || signingCertificate.value?.name || ""; + case "date": + return todayText(); + case "place": + return signingPlace.value.trim(); + case "placeAndDate": + return [signingPlace.value.trim(), todayText()] + .filter(Boolean) + .join(", "); + default: + return ""; + } +}; + +/** + * focusPlace gives the place `id` the focus once it shows, scrolled into + * the middle of the view + * @returns whether it shows + */ +export const focusPlace = async (id: string) => { + await nextTick(); + const element = document.getElementById(placeElementId(id)); + if (!element) return false; + element.focus({ preventScroll: true }); + element.scrollIntoView?.({ block: "center", inline: "nearest" }); + return true; +}; + +const focusPages = () => document.getElementById(PDF_VIEW_ID)?.focus(); + +/** + * stampFor makes the stamp that fills `place` with `kind`, sized into it; + * null when the user has no signature or initials yet, or cancelled the + * question where they sign + */ +const stampFor = async ( + place: Place, + stamp: "signature" | "initials" | "date" | "place" | "placeAndDate", + refocus: () => void, +): Promise => { + const kind: StampKind = stamp === "placeAndDate" ? "place" : stamp; + if (kind === "place" && !(await knownPlace(refocus))) return null; + const line = + stamp === "placeAndDate" + ? `${signingPlace.value.trim()}, ${todayText()}` + : undefined; + const mark = kind === "signature" || kind === "initials"; + // a date or place in the letters of the line's label + const made = await newStamp(kind, line, mark ? undefined : lettersOn(place)); + if (!made) return null; + const rect = mark + ? fitMark( + made.natural, + place.rect, + place.source === "found", + MARK_ON_LINE[kind], + ) + : fitLines(made.natural, place.rect); + return { + ...made, + // a signature or initials fitted in is its own size; a date or place + // keeps the size of its letters, so it shrinks only so far + natural: mark ? [rect[2], rect[3]] : made.natural, + page: place.page, + rect, + place: place.id, + }; +}; + +/** + * fillPlace fills the place `place` of the PDF shown with its stamp: + * the signature (made first if there's none yet) or initials, sized into + * it, or the date, the place or both on its line; the stamp is selected and + * takes the focus. `refocus` gives the focus back after a dialog. + * @returns the stamp placed, or null + */ +export const fillPlace = async ( + place: Place, + refocus: () => void, +): Promise => { + const fill = fillOf(place); + if (fill.action !== "stamp") { + await focusPlace(place.id); + return null; + } + const tab = activeTabId.value; + const opened = shownPdf.value?.opened; + if (!tab || !opened) return null; + const kind = fill.stamp === "placeAndDate" ? "place" : fill.stamp; + const refusal = placeRefusal(place, shownContext()); + if (refusal) { + announce(refusal); + return null; + } + let stamp; + try { + stamp = await stampFor(place, fill.stamp, refocus); + } catch (error) { + logWarning("failed to make a stamp", error); + announce(STAMPS_NEED_LAYOUT); + return null; + } + if (!stamp) { + if (kind === "signature" || kind === "initials") + // made first, then filled in + void openSignatureDialog(kind, refocus, () => { + if (activeTabId.value === tab) void fillPlace(place, refocus); + }); + return null; + } + // the tab changed while it was made, or the place was filled meanwhile + if ( + activeTabId.value !== tab || + shownPdf.value?.opened !== opened || + stampsOf(tab).some((one) => one.place === place.id) + ) + return null; + changeStamps(tab, withStamp(stampsOf(tab), stamp), { + sha256: opened.sha256, + }); + selectedStamp.value = stamp.id; + announce(placedMessage(stamp)); + await focusStamp(stamp.id); + return stamp; +}; + +/** + * dismissShownPlace dismisses the found place `place` of the PDF shown, as + * one step of its history: it no longer shows, nor counts in Next. Fields + * can't be dismissed. + * @returns whether it was dismissed + */ +export const dismissShownPlace = (place: Place) => { + const tab = activeTabId.value; + if (!tab || place.source !== "found") return false; + const before = dismissedOf(tab); + const dismissed = dismissPlace(new Set(before), place); + changeSheet( + tab, + { dismissed: [...dismissed] }, + { sha256: shownPdf.value?.opened?.sha256 }, + ); + // Blank signs the PDF meanwhile + if (dismissedOf(tab) === before) return false; + if (lastPlace.value === place.id) lastPlace.value = null; + announce(PLACE_DISMISSED); + void nextTick(focusPages); + return true; +}; + +/** + * goNext focuses the next place of the PDF shown that isn't filled, in + * reading order from the one focused last, and says what it is + * @returns whether there was one + */ +export const goNext = async () => { + if (shownBlocked.value) { + announce(shownBlocked.value); + return false; + } + const place = shownNext.value.place; + if (!place) { + announce( + shownPlaces.value.length + ? ALL_FILLED + : "This PDF has no places to fill in", + ); + return false; + } + lastPlace.value = place.id; + await focusPlace(place.id); + announce(nextMessage(place)); + return true; +}; + +/** + * stepStamps undoes (`undo`) or redoes the last step of the history of + * `tab`, says what that changed, and gives a found place that shows again, + * or a stamp that's back, the focus + * @returns whether there was a step + */ +export const stepStamps = (tab: string, undo: boolean) => { + const before = stampBooks.value.get(tab)?.book; + const stepped = (undo ? undoStamps : redoStamps)(tab); + const after = stampBooks.value.get(tab)?.book; + // none, or Blank signs the PDF meanwhile, which says so + if (!stepped || !before || !after || after === before) return stepped; + const change = stepChange(before, after, undo); + announce(change.message); + if (change.place) void focusPlace(change.place); + else if (change.stamp) void focusStamp(change.stamp); + return true; +}; + +export const NEXT_IN_PDF = + "Next goes to the places to fill in of a PDF you received. Open one first."; + +/** + * nextShownPlace is `pdf.next`: it goes to the next place of the PDF shown + * that isn't filled; asked whether it can, it can while there is one + */ +export const nextShownPlace = (): Command => (_state, dispatch) => { + const inPdf = isPdfTab(activeTab.value); + if (!dispatch) return inPdf && !shownNext.value.disabled; + if (!inPdf) announce(NEXT_IN_PDF); + else void goNext(); + return true; +}; diff --git a/src/pdf/placeModel.test.ts b/src/pdf/placeModel.test.ts new file mode 100644 index 00000000..d1f89259 --- /dev/null +++ b/src/pdf/placeModel.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; + +import type { PdfField, PdfRect as Rect } from "./ipc"; +import { + choiceText, + fieldValue, + filledOf, + fitLines, + fitMark, + LEAST_LETTERS, + lettersOn, + MARK_ON_LINE, + MISSING_GLYPHS, + missingGlyphs, + unshown, + isFilled, + nextMessage, + placeElementId, + placeLabel, + placeName, + staysOn, +} from "./placeModel"; +import { type LabelPlace, nextButton, placesOf } from "./places"; +import { type Stamp, stampSpec } from "./stampModel"; + +const field = ( + name: string, + rect: Rect, + more: Partial = {}, +): PdfField => ({ + name, + kind: "text", + label: null, + value: "", + options: [], + flags: { + readOnly: false, + required: false, + multiline: false, + comb: false, + maxLen: null, + }, + signed: false, + widgets: [{ page: 0, rect, onValue: null }], + ...more, +}); + +const places = placesOf({ + fields: [ + field("name", [100, 100, 200, 20], { label: "Your name" }), + field("agree", [100, 150, 15, 15], { kind: "checkbox", value: "Off" }), + field("signature_1", [100, 200, 200, 50], { kind: "signature" }), + field("witness", [350, 200, 200, 50], { + kind: "signature", + label: "Witness", + }), + ], + found: [ + { + page: 1, + kind: "signature", + rect: [100, 300, 200, 36], + label: "Signature · Tenant", + size: 10, + }, + { + page: 1, + kind: "placeAndDate", + rect: [100, 400, 80, 15], + label: "Ort, Datum", + size: 10, + }, + ], +}); +const byId = (id: string) => places.find((place) => place.id === id)!; + +const stamp = (place: string): Stamp => ({ + id: "s", + kind: "signature", + page: 0, + rect: [0, 0, 10, 10], + natural: [10, 10], + place, + spec: stampSpec("signature"), +}); + +describe("the places of a PDF on its pages", () => { + it("are named for people, with their page", () => { + expect(places.map(placeLabel)).toEqual([ + "Your name, page 1", + "Agree, page 1", + "Signature, page 1", + "Signature · Witness, page 1", + "Signature · Tenant, page 2", + "Place and date, page 2", + ]); + expect(placeName(byId("field:name"))).toBe("Your name"); + expect(placeElementId("found:1:100:300")).toBe( + "place-found%3A1%3A100%3A300", + ); + }); + + it("are filled by a stamp on them or a value in the field", () => { + const name = byId("field:name"); + const agree = byId("field:agree"); + const signature = byId("field:signature_1"); + expect(isFilled(name, [], [])).toBe(false); + expect(isFilled(name, [], [{ field: "name", value: " " }])).toBe(false); + expect(isFilled(name, [], [{ field: "name", value: "Ada" }])).toBe(true); + expect(isFilled(agree, [], [])).toBe(false); + expect(isFilled(agree, [], [{ field: "agree", value: true }])).toBe(true); + expect(isFilled(signature, [], [])).toBe(false); + expect(isFilled(signature, [stamp(signature.id)], [])).toBe(true); + expect([...filledOf(places, [stamp("found:1:100:300")], [])]).toEqual([ + "found:1:100:300", + ]); + }); + + it("hold what the PDF says until they're filled in", () => { + const agree = field("agree", [0, 0, 1, 1], { + kind: "checkbox", + value: "Yes", + }); + expect(fieldValue(agree, [])).toBe(true); + expect(fieldValue({ ...agree, value: "Off" }, [])).toBe(false); + const name = field("name", [0, 0, 1, 1], { value: "Ada" }); + expect(fieldValue(name, [{ field: "name", value: "Ben" }])).toBe("Ben"); + expect(choiceText("")).toBe("Choose…"); + expect(choiceText("Red")).toBe("Red"); + }); + + it("say where Next goes and how to fill it", () => { + const next = nextButton(places, new Set(["field:name"])); + expect(next.text).toBe("Next · 1 of 5"); + expect(next.place?.id).toBe("field:agree"); + expect(nextMessage(byId("field:signature_1"))).toBe( + "Signature on page 1. Press Enter to fill it in.", + ); + expect(nextMessage(byId("field:name"))).toBe( + "Your name on page 1. Type to fill it in.", + ); + expect(nextMessage(byId("field:agree"))).toBe( + "Agree on page 1. Press Space to check it.", + ); + expect( + nextButton(places, new Set(places.map((place) => place.id))), + ).toMatchObject({ text: "All places filled", disabled: true }); + }); + + it("fit a signature into its place, its shape kept", () => { + // a found line: standing on it + expect(fitMark([180, 60], [100, 300, 200, 36], true)).toEqual([ + 146, 300, 108, 36, + ]); + // a field: in its middle + expect(fitMark([180, 60], [100, 200, 200, 50], false)).toEqual([ + 125, 200, 150, 50, + ]); + // a date on a line, smaller only when it's wider than the line + expect(fitLines([60, 14], [100, 400, 80, 20])).toEqual([102, 406, 60, 14]); + const [, , w, h] = fitLines([156, 14], [100, 400, 80, 20]); + expect(w).toBeCloseTo(78); + expect(h).toBeCloseTo(7); + // taller than the line's place, it rises above it + expect(fitLines([60, 20], [100, 400, 80, 12])).toEqual([102, 392, 60, 20]); + }); + + it("stand a signature on a found line at least 36 pt tall, while it fits the line", () => { + // a line's place as tall as its label: the signature rises above it + expect( + fitMark([180, 60], [100, 300, 300, 16], true, MARK_ON_LINE.signature), + ).toEqual([196, 280, 108, 36]); + // a short line: as wide as it + expect( + fitMark([180, 60], [100, 300, 90, 16], true, MARK_ON_LINE.signature), + ).toEqual([100, 286, 90, 30]); + // it still fills its place, moved a little + expect(staysOn([197, 281, 108, 36], [100, 300, 300, 16])).toBe(true); + expect(staysOn([197, 200, 108, 36], [100, 300, 300, 16])).toBe(false); + }); + + it("write a date or place in the letters of the line's label, at least 9 pt", () => { + const place = (size?: number): LabelPlace => ({ + id: "found:0:100:300", + source: "found", + kind: "date", + page: 0, + rect: [100, 300, 200, 16], + label: "Date", + size, + }); + expect(lettersOn(place(12))).toBe(12); + expect(lettersOn(place(6))).toBe(LEAST_LETTERS); + expect(lettersOn(place())).toBeUndefined(); + }); + + it("name the first character a field's font can't show, as the helper checks", () => { + expect(unshown("Café – 5 € „ok“")).toBeUndefined(); + expect(unshown("two\nlines\tand a tab")).toBeUndefined(); + expect(unshown("Łukasz")).toBe("Ł"); + expect(unshown("Zoë 😀")).toBe("😀"); + expect(missingGlyphs("Ωμέγα")).toBe("This field's font can't show “Ω”."); + // a list box's options, or a value it can't tell + expect(missingGlyphs(undefined)).toBe(MISSING_GLYPHS); + expect(missingGlyphs("fine")).toBe(MISSING_GLYPHS); + }); +}); diff --git a/src/pdf/placeModel.ts b/src/pdf/placeModel.ts new file mode 100644 index 00000000..33ae951a --- /dev/null +++ b/src/pdf/placeModel.ts @@ -0,0 +1,231 @@ +import type { Fill, PdfField, PdfRect as Rect } from "./ipc"; +import { fillOf, type Place, type PlaceFill } from "./places"; +import type { Stamp } from "./stampModel"; + +// What the places of a PDF tab (src/pdf/places.ts) are on its pages: which +// of them are filled, what a field holds, where a stamp that fills a place +// goes, and what they're called for people. Pure; PdfView.vue shows them, +// placeActions.ts and fills.ts fill them in. + +export const PLACE_DISMISSED = "Place dismissed"; +export const MISSING_GLYPHS = + "This field's font can't show some of these characters."; + +// the characters of Windows-1252 outside Latin-1, which it has in place of +// the C1 controls +const WIN_ANSI_EXTRA = new Set("€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ"); + +/** + * unshown returns the first character of `text` a field's font can't show, + * as the helper checks it (`shows` in src-tauri/pdf/helper/shim): MuPDF + * draws a field's value in a base 14 font with Windows-1252, line breaks + * aside, and a tab as a space; undefined when it shows them all + */ +export const unshown = (text: string) => + [...text].find((char) => { + const code = char.codePointAt(0)!; + if (char === "\n" || char === "\r" || char === "\t") return false; + return !( + (code >= 1 && code <= 0x7f) || + (code >= 0xa0 && code <= 0xff) || + WIN_ANSI_EXTRA.has(char) + ); + }); + +/** + * missingGlyphs says that a field's font can't show `value`, naming the + * first character it can't show, e.g. "This field's font can't show “Ł”." + */ +export const missingGlyphs = (value: string | boolean | undefined) => { + const char = typeof value === "string" ? unshown(value) : undefined; + return char ? `This field's font can't show “${char}”.` : MISSING_GLYPHS; +}; +export const NOT_TAKEN = "This field doesn't take this value"; + +// what a place is called by its kind, where its label doesn't say it +const KIND_NAMES: Record = { + signature: "Signature", + initials: "Initials", + date: "Date", + place: "Place", + placeAndDate: "Place and date", +}; + +/** + * isTargetPlace tells whether `place` shows as a target to fill with a + * stamp (a found place or a signature field), rather than as a field of + * the form one types into or ticks + */ +export const isTargetPlace = (place: Place) => + place.source === "found" || place.kind === "signature"; + +/** + * placeName returns what `place` is called, e.g. "Signature · Tenant", + * "Place and date" or a field's label ("Your name") + */ +export const placeName = (place: Place) => { + const kind = KIND_NAMES[place.kind]; + if (place.source === "found") + return place.label.startsWith(`${kind} · `) ? place.label : kind; + if (place.kind !== "signature") return place.label; + return place.label.toLowerCase() === "signature" + ? kind + : `${kind} · ${place.label}`; +}; + +/** + * placeLabel names `place` for a screen reader, with its page, e.g. + * "Signature · Tenant, page 2" + */ +export const placeLabel = (place: Place) => + `${placeName(place)}, page ${place.page + 1}`; + +/** + * placeElementId returns the id of the element that shows `place` + */ +export const placeElementId = (id: string) => `place-${encodeURIComponent(id)}`; + +/** + * fieldValue returns what `field` holds: its fill, else what the PDF says + */ +export const fieldValue = ( + field: PdfField, + fills: readonly Fill[], +): string | boolean => { + const fill = fills.find((one) => one.field === field.name); + if (fill) return fill.value; + if (field.kind === "checkbox") return field.value !== "Off" && !!field.value; + return field.value; +}; + +/** + * isFilled tells whether `place` is filled: a stamp fills it, or the field + * holds a value (a checkbox ticked, a radio button chosen, some text) + */ +export const isFilled = ( + place: Place, + stamps: readonly Stamp[], + fills: readonly Fill[], +) => { + if (stamps.some((stamp) => stamp.place === place.id)) return true; + if (place.source === "found" || place.kind === "signature") return false; + const value = fieldValue(place.field, fills); + if (typeof value === "boolean") return value; + return value.trim() !== "" && value !== "Off"; +}; + +/** + * filledOf returns the ids of the places of `places` that are filled + */ +export const filledOf = ( + places: readonly Place[], + stamps: readonly Stamp[], + fills: readonly Fill[], +): ReadonlySet => + new Set( + places + .filter((place) => isFilled(place, stamps, fills)) + .map((place) => place.id), + ); + +// how to fill in a place, by what filling it does +const HOW: Record = { + stamp: "Press Enter to fill it in.", + text: "Type to fill it in.", + toggle: "Press Space to check it.", + choose: "Press Space to choose.", +}; + +/** + * nextMessage says where Next went and how to fill it in, e.g. + * "Signature on page 3. Press Enter to fill it in." + */ +export const nextMessage = (place: Place) => + `${placeName(place)} on page ${place.page + 1}. ${HOW[fillOf(place).action]}`; + +// the least height of a signature or initials standing on a found line, in +// points, so it isn't as small as the line's label: a line's place is about +// as tall as its label's letters +export const MARK_ON_LINE = { signature: 36, initials: 24 } as const; +// the smallest letters a date or place is written on a line with, in points +export const LEAST_LETTERS = 9; + +/** + * fitMark returns where a signature or initials `size` points large goes in + * the place `target`: as large as fits with its shape kept, centred across, + * and standing on the line of a found place (`onLine`), at least `least` + * points tall there while it fits the line's width (rising above the + * place), or centred in a field + */ +export const fitMark = ( + size: readonly [number, number], + target: Rect, + onLine: boolean, + least = 0, +): Rect => { + const [w, h] = size; + const [x, y, tw, th] = target; + const room = onLine ? Math.max(th, least) : th; + const scale = w > 0 && h > 0 ? Math.min(tw / w, room / h) : 1; + const width = w * scale; + const height = h * scale; + return [ + x + (tw - width) / 2, + onLine ? y + th - height : y + (th - height) / 2, + width, + height, + ]; +}; + +/** + * staysOn tells whether a stamp at `rect` still fills the place at `target` + * (both on one page): its middle is within it, or, for a stamp taller than + * it that stands on its line, within the room it rises to above it + */ +export const staysOn = (rect: Rect, target: Rect) => { + const x = rect[0] + rect[2] / 2; + const y = rect[1] + rect[3] / 2; + const top = Math.min(target[1], target[1] + target[3] - rect[3]); + return ( + x >= target[0] && + x <= target[0] + target[2] && + y >= top && + y <= target[1] + target[3] + ); +}; + +// the room left of a date or place written on a line, in points +const INSET = 2; + +/** + * lettersOn returns the size of the letters a date or place is written + * with on the found place `place`: its label's, at least LEAST_LETTERS; + * undefined for a place that doesn't say (the stamp's own size) + */ +export const lettersOn = (place: Place) => + place.source === "found" && place.size + ? Math.max(LEAST_LETTERS, place.size) + : undefined; + +/** + * fitLines returns where a date or place `size` points large goes on the + * found place `target`: at its left, standing on its line (rising above + * the place if its letters are taller), made smaller with its shape kept + * when it's wider than the line + */ +export const fitLines = ( + size: readonly [number, number], + target: Rect, +): Rect => { + const [w, h] = size; + const [x, y, tw, th] = target; + const scale = w > 0 && h > 0 ? Math.min(1, (tw - INSET) / w) : 1; + return [x + INSET, y + th - h * scale, w * scale, h * scale]; +}; + +/** + * choiceText returns what a combo or list box shows: the option it holds, + * or "Choose…" for none + */ +export const choiceText = (value: string | boolean) => + typeof value === "string" && value.trim() ? value : "Choose…"; diff --git a/src/pdf/places.test.ts b/src/pdf/places.test.ts new file mode 100644 index 00000000..0a9da4e3 --- /dev/null +++ b/src/pdf/places.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; + +import type { FoundPlace } from "../signing/labels"; +import type { PdfField, PdfRect as Rect } from "./ipc"; +import { + dismissPlace, + fieldLabel, + fillOf, + isOpen, + nextButton, + nextPlace, + type Place, + placesOf, + prefillOf, + readingOrder, +} from "./places"; + +const field = ( + name: string, + rect: Rect, + more: Partial = {}, + page = 0, +): PdfField => ({ + name, + kind: "text", + label: null, + value: "", + options: [], + flags: { + readOnly: false, + required: false, + multiline: false, + comb: false, + maxLen: null, + }, + signed: false, + widgets: [{ page, rect, onValue: null }], + ...more, +}); + +const found = (kind: FoundPlace["kind"], rect: Rect, page = 0): FoundPlace => ({ + page, + kind, + rect, + label: kind, + size: 10, +}); + +const ids = (places: Place[]) => places.map((place) => place.id); + +describe("the places of a PDF", () => { + it("takes the fields that can be filled or signed", () => { + const locked = new Set(["locked"]); + const fields = [ + field("name", [0, 0, 10, 10]), + field("id", [0, 0, 10, 10], { + flags: { ...field("", [0, 0, 0, 0]).flags, readOnly: true }, + }), + field("locked", [0, 0, 10, 10]), + field("send", [0, 0, 10, 10], { kind: "button" }), + field("signed", [0, 0, 10, 10], { kind: "signature", signed: true }), + field("signature", [0, 0, 10, 10], { kind: "signature" }), + field("hidden", [0, 0, 10, 10], { widgets: [] }), + ]; + const places = placesOf({ fields, found: [], locked }); + expect(ids(places).sort()).toEqual([ + "field:locked", + "field:name", + "field:signature", + ]); + // a field an earlier signer locked shows, but isn't one to fill in + const lockedOne = places.find((place) => place.id === "field:locked")!; + expect(lockedOne).toMatchObject({ source: "field", locked: true }); + expect(isOpen(lockedOne, new Set())).toBe(false); + expect(nextPlace(places, new Set(["field:name"]))?.id).toBe( + "field:signature", + ); + }); + + it("calls a field by its /TU, else by its name in words", () => { + expect( + fieldLabel(field("name", [0, 0, 1, 1], { label: "Your name" })), + ).toBe("Your name"); + expect(fieldLabel(field("form.signature_1", [0, 0, 1, 1]))).toBe( + "Signature", + ); + expect(fieldLabel(field("page1.firstName[0]", [0, 0, 1, 1]))).toBe( + "First name", + ); + expect(fieldLabel(field("Text-12", [0, 0, 1, 1]))).toBe("Text"); + expect(fieldLabel(field("7", [0, 0, 1, 1]))).toBe("7"); + }); + + it("orders places by page, line and from left to right", () => { + const places = placesOf({ + fields: [ + field("b", [300, 100, 100, 20]), + field("c", [100, 200, 100, 20]), + field("d", [50, 50, 100, 20], {}, 1), + // three points lower and further left: on the line of b + field("a", [100, 103, 50, 20]), + ], + found: [], + }); + expect(ids(places)).toEqual(["field:a", "field:b", "field:c", "field:d"]); + }); + + it("keeps the order of things on a line by their bottoms", () => { + // a tall signature place and a short date place standing on one line + const order = readingOrder([ + { page: 0, rect: [300, 482, 100, 18] as Rect, id: "date" }, + { page: 0, rect: [72, 464, 200, 36] as Rect, id: "signature" }, + ]); + expect(order.map((each) => each.id)).toEqual(["signature", "date"]); + }); + + it("puts a radio group at its buttons on the first page it shows on", () => { + const radio = field("pay", [100, 100, 10, 10], { + kind: "radio", + widgets: [ + { page: 0, rect: [100, 100, 10, 10], onValue: "card" }, + { page: 0, rect: [200, 100, 10, 10], onValue: "cash" }, + { page: 1, rect: [0, 0, 10, 10], onValue: "later" }, + ], + }); + expect(placesOf({ fields: [radio], found: [] })[0]).toMatchObject({ + page: 0, + rect: [100, 100, 110, 10], + kind: "radio", + }); + }); + + it("lets a field win over a found place at its spot", () => { + const places = placesOf({ + // form.pdf's signature field and the place found over its rule + fields: [field("signature", [100, 292, 200, 50], { kind: "signature" })], + found: [ + found("signature", [100, 315.75, 200, 36]), + found("date", [350, 330, 100, 18]), + ], + }); + expect(places.map(({ source, kind }) => ({ source, kind }))).toEqual([ + { source: "field", kind: "signature" }, + { source: "found", kind: "date" }, + ]); + }); + + it("keeps a found place a field barely touches, or one on another page", () => { + const places = placesOf({ + fields: [field("note", [100, 100, 200, 20])], + found: [ + found("date", [100, 115, 200, 18]), + found("date", [100, 100, 200, 20], 1), + ], + }); + expect(places).toHaveLength(3); + }); + + it("lets a signed signature's widget hide the place found under it", () => { + const places = placesOf({ + fields: [ + field("signed", [100, 300, 200, 50], { + kind: "signature", + signed: true, + }), + ], + found: [found("signature", [100, 310, 200, 36])], + }); + expect(places).toEqual([]); + }); + + it("makes found places within 4 pt one", () => { + const places = placesOf({ + fields: [], + found: [ + found("date", [100, 100, 100, 18]), + found("date", [103, 98, 98, 21]), + found("date", [100, 130, 100, 18]), + ], + }); + expect(ids(places)).toEqual(["found:0:100:100", "found:0:100:130"]); + }); + + it("dismisses found places, never fields, and undoes with the old set", () => { + const fields = [field("name", [0, 0, 10, 10])]; + const places = placesOf({ + fields, + found: [found("date", [0, 50, 50, 18])], + }); + const none = new Set(); + const dismissed = dismissPlace(none, places[1]); + expect(dismissPlace(dismissed, places[0])).toBe(dismissed); + expect( + ids( + placesOf({ + fields, + found: [found("date", [0, 50, 50, 18])], + dismissed, + }), + ), + ).toEqual(["field:name"]); + expect(none.size).toBe(0); + expect( + placesOf({ + fields, + found: [found("date", [0, 50, 50, 18])], + dismissed: none, + }), + ).toHaveLength(2); + }); +}); + +describe("Next", () => { + const places = placesOf({ + fields: [ + field("a", [0, 0, 10, 10]), + field("b", [0, 100, 10, 10]), + field("c", [0, 200, 10, 10]), + ], + found: [], + }); + + it("goes to the next place that isn't filled, coming round", () => { + const filled = new Set(["field:b"]); + expect(nextPlace(places, filled)?.id).toBe("field:a"); + expect(nextPlace(places, filled, "field:a")?.id).toBe("field:c"); + expect(nextPlace(places, filled, "field:c")?.id).toBe("field:a"); + expect(nextPlace(places, filled, "gone")?.id).toBe("field:a"); + expect(nextPlace([], filled)).toBeUndefined(); + }); + + it("counts the places left", () => { + expect(nextButton(places, new Set())).toMatchObject({ + text: "Next · 1 of 3", + disabled: false, + }); + expect(nextButton(places, new Set(["field:a"]), "field:a")).toMatchObject({ + text: "Next · 1 of 2", + place: places[1], + }); + expect(nextButton(places, new Set(["field:a"]), "field:b").text).toBe( + "Next · 2 of 2", + ); + }); + + it("says when every place is filled", () => { + expect(nextButton(places, new Set(ids(places)))).toEqual({ + text: "All places filled", + place: undefined, + disabled: true, + }); + expect(nextButton([], new Set()).disabled).toBe(true); + }); +}); + +describe("filling a place", () => { + const placeOf = (each: PdfField) => + placesOf({ fields: [each], found: [] })[0]; + + it("stamps a found place with what its label asks for", () => { + for (const kind of [ + "signature", + "initials", + "date", + "place", + "placeAndDate", + ] as const) { + const [place] = placesOf({ + fields: [], + found: [found(kind, [0, 0, 100, 18])], + }); + expect(fillOf(place)).toEqual({ action: "stamp", stamp: kind }); + } + }); + + it("signs a signature field, toggles a checkbox, lets the user choose", () => { + const rect: Rect = [0, 0, 10, 10]; + expect(fillOf(placeOf(field("s", rect, { kind: "signature" })))).toEqual({ + action: "stamp", + stamp: "signature", + }); + expect(fillOf(placeOf(field("c", rect, { kind: "checkbox" })))).toEqual({ + action: "toggle", + }); + for (const kind of ["radio", "combo", "list"] as const) + expect(fillOf(placeOf(field("x", rect, { kind })))).toEqual({ + action: "choose", + }); + expect(fillOf(placeOf(field("street", rect)))).toEqual({ action: "text" }); + expect(fillOf(placeOf(field("f", rect, { label: "Datum" })))).toEqual({ + action: "text", + prefill: "date", + }); + }); + + it("prefills text fields asking for the signer's name, the date or the place", () => { + const prefill = (name: string, label: string | null = null) => + prefillOf(field(name, [0, 0, 1, 1], { label })); + expect(prefill("name", "Your name")).toBe("name"); + expect(prefill("fullName")).toBe("name"); + expect(prefill("Text1", "Date")).toBe("date"); + expect(prefill("signing_date")).toBe("date"); + expect(prefill("ort")).toBe("place"); + expect(prefill("x", "Ort, Datum")).toBe("placeAndDate"); + expect(prefill("x", "Place and date")).toBe("placeAndDate"); + // other dates, places and names + expect(prefill("x", "Date of birth")).toBeUndefined(); + expect(prefill("Geburtsdatum")).toBeUndefined(); + expect(prefill("Wohnort")).toBeUndefined(); + expect(prefill("firstName")).toBeUndefined(); + expect(prefill("x", "Name, Vorname")).toBeUndefined(); + expect(prefill("x", "Name and date")).toBeUndefined(); + expect(prefill("street")).toBeUndefined(); + }); +}); diff --git a/src/pdf/places.ts b/src/pdf/places.ts new file mode 100644 index 00000000..9254efe6 --- /dev/null +++ b/src/pdf/places.ts @@ -0,0 +1,360 @@ +import { + type FoundPlace, + mergeNear, + overlap, + type PlaceKind, +} from "../signing/labels"; +import type { PdfField, PdfRect as Rect } from "./ipc"; + +// The places of a PDF tab: where the user signs, dates or fills it in. They +// are its form's fields that can take a value, and the places found by their +// labels (src/signing/labels.ts), which a field at the same spot wins over; +// in reading order, for Tab and "Next". + +export type FieldPlaceKind = + "text" | "checkbox" | "radio" | "combo" | "list" | "signature"; + +interface PlaceBase { + // stays the same while the PDF does: what filled and dismissed places are + // remembered by + id: string; + // from 0 + page: number; + rect: Rect; + // what it is called for people + label: string; +} + +// a field of the form +export interface FieldPlace extends PlaceBase { + source: "field"; + kind: FieldPlaceKind; + field: PdfField; + // an earlier signature locked it (FieldMDP): it shows, but can't change + locked?: true; +} + +// a place found by its label +export interface LabelPlace extends PlaceBase { + source: "found"; + kind: PlaceKind; + // the size of its label's letters, in points + size?: number; +} + +export type Place = FieldPlace | LabelPlace; + +// how far apart, in points, places count as on one line +const BAND = 4; +// how much a found place may share with a field and still count +const SHARED = 0.3; + +// the prefix of the id of a place that is a field of the form +const FIELD = "field:"; + +/** + * fieldPlaceId returns the id of the place of the field `name` (its full + * name) + */ +export const fieldPlaceId = (name: string) => `${FIELD}${name}`; + +/** + * fieldOfPlace returns the full name of the field the place `id` is, or + * null for a found place + */ +export const fieldOfPlace = (id: string | undefined) => + id?.startsWith(FIELD) ? id.slice(FIELD.length) : null; + +/** + * isFillable tells whether `field` is one a user fills in or signs: not a + * push button, a read-only field or a signature field that holds a + * signature, and it shows on a page + */ +const isFillable = (field: PdfField) => + field.kind !== "button" && + !field.flags.readOnly && + !(field.kind === "signature" && field.signed) && + field.widgets.length > 0; + +/** + * fieldLabel returns what a field is called: its `/TU`, else the last part of + * its full name in words, e.g. "Signature" for "form.signature_1" + */ +export const fieldLabel = (field: PdfField) => { + const told = field.label?.trim(); + if (told) return told; + const last = field.name.split(".").pop() ?? ""; + const words = last + .replace(/\[\d+\]/gu, "") + .replace(/(\p{Ll})(\p{Lu})/gu, "$1 $2") + .replace(/[_\-\s]+/gu, " ") + .replace(/(?:\s*\d+)+$/u, "") + .trim() + .toLowerCase(); + return words ? words[0].toUpperCase() + words.slice(1) : field.name; +}; + +const union = (rects: Rect[]): Rect => { + const left = Math.min(...rects.map((rect) => rect[0])); + const top = Math.min(...rects.map((rect) => rect[1])); + const right = Math.max(...rects.map((rect) => rect[0] + rect[2])); + const bottom = Math.max(...rects.map((rect) => rect[1] + rect[3])); + return [left, top, right - left, bottom - top]; +}; + +const iou = (a: Rect, b: Rect) => { + const shared = overlap(a, b); + return shared / (a[2] * a[3] + b[2] * b[3] - shared || 1); +}; + +/** + * foundId returns the id of a found place, from where it is + */ +const foundId = (place: FoundPlace) => + `found:${place.page}:${Math.round(place.rect[0])}:${Math.round(place.rect[1])}`; + +/** + * readingOrder sorts places by page, then by line, then from left to right: + * places whose bottoms (where text and lines stand) are within BAND points + * of the first of a line are on that line + */ +export const readingOrder = ( + places: readonly T[], +): T[] => { + const bottom = (place: T) => place.rect[1] + place.rect[3]; + const sorted = [...places].sort( + (a, b) => a.page - b.page || bottom(a) - bottom(b), + ); + const lines: T[][] = []; + for (const place of sorted) { + const line = lines[lines.length - 1]; + if ( + line && + line[0].page === place.page && + bottom(place) - bottom(line[0]) <= BAND + ) + line.push(place); + else lines.push([place]); + } + return lines.flatMap((line) => line.sort((a, b) => a.rect[0] - b.rect[0])); +}; + +/** + * placesOf returns the places of a PDF in reading order: the fields of + * `fields` that can be filled in (see isFillable; those an earlier signature + * locked, `locked`, marked so), and the places of `found` that + * no field covers (more than SHARED of both, as intersection over union) and + * that the user didn't dismiss; found places within 4 pt of each other are + * one + */ +export const placesOf = ({ + fields, + found, + locked = new Set(), + dismissed = new Set(), +}: { + fields: readonly PdfField[]; + found: readonly FoundPlace[]; + locked?: ReadonlySet; + dismissed?: ReadonlySet; +}): Place[] => { + const fieldPlaces = fields.filter(isFillable).map((field): FieldPlace => { + const page = field.widgets[0].page; + return { + ...(locked.has(field.name) ? { locked: true as const } : {}), + id: fieldPlaceId(field.name), + source: "field", + page, + rect: union( + field.widgets + .filter((widget) => widget.page === page) + .map((widget) => widget.rect), + ), + label: fieldLabel(field), + kind: field.kind as FieldPlaceKind, + field, + }; + }); + // every widget counts against found places, also those of fields that + // can't be filled: a signed signature isn't a line to sign on + const widgets = fields.flatMap((field) => field.widgets); + const labelPlaces = mergeNear([...found]) + .filter( + (place) => + !widgets.some( + (widget) => + widget.page === place.page && iou(widget.rect, place.rect) > SHARED, + ), + ) + .map((place): LabelPlace => ({ + ...place, + id: foundId(place), + source: "found", + })) + .filter((place) => !dismissed.has(place.id)); + return readingOrder([...fieldPlaces, ...labelPlaces]); +}; + +/** + * dismissPlace returns the dismissed places with `place` among them, if it is + * a found place; fields can't be dismissed. The caller keeps the set it had + * to undo it. + */ +export const dismissPlace = ( + dismissed: ReadonlySet, + place: Place, +): ReadonlySet => + place.source === "found" ? new Set([...dismissed, place.id]) : dismissed; + +/** + * isOpen tells whether `place` is still to be filled in: not filled, and + * not a field an earlier signature locked + */ +export const isOpen = (place: Place, filled: ReadonlySet) => + !filled.has(place.id) && !(place.source === "field" && place.locked); + +/** + * nextPlace returns the first place after the one of id `from` (else from + * the start) that is open (see isOpen), coming round to the start; none + * when every place is filled + */ +export const nextPlace = ( + places: readonly Place[], + filled: ReadonlySet, + from?: string, +): Place | undefined => { + const start = places.findIndex((place) => place.id === from) + 1; + for (let step = 0; step < places.length; step++) { + const place = places[(start + step) % places.length]; + if (isOpen(place, filled)) return place; + } + return undefined; +}; + +export const ALL_FILLED = "All places filled"; + +// what the Next button says and where it goes +export interface NextButton { + text: string; + place: Place | undefined; + disabled: boolean; +} + +/** + * nextButton returns the Next button for `places`: "Next · i of n", where n + * counts the places not filled yet and i is where among them the next one + * is, or "All places filled", disabled + */ +export const nextButton = ( + places: readonly Place[], + filled: ReadonlySet, + from?: string, +): NextButton => { + const place = nextPlace(places, filled, from); + if (!place) return { text: ALL_FILLED, place: undefined, disabled: true }; + const open = places.filter((each) => isOpen(each, filled)); + return { + text: `Next · ${open.indexOf(place) + 1} of ${open.length}`, + place, + disabled: false, + }; +}; + +// what a text field asks for, which it starts with when it is filled +export type Prefill = "name" | "date" | "place" | "placeAndDate"; + +// what filling a place does: +// - stamp: puts that stamp on it (the signature or initials, or the lines of +// the date, the place, or both); +// - text: focuses the field's input, with what `prefill` asks for in it, +// selected; +// - toggle: a checkbox switches; choose: picks one of a radio's buttons or a +// choice's options +export type PlaceFill = + | { + action: "stamp"; + stamp: "signature" | "initials" | "date" | "place" | "placeAndDate"; + } + | { action: "text"; prefill?: Prefill } + | { action: "toggle" } + | { action: "choose" }; + +// the words that make a text field's label one Blank fills, in English and +// German; whole words only, so "Geburtsdatum" or "Wohnort" are none +const DATE_WORDS = new Set(["date", "datum"]); +const PLACE_WORDS = new Set(["place", "ort"]); +const NAME_WORDS = new Set(["name"]); +// words that make it another date, place or name than the signer's +const OTHER_WORDS = new Set([ + "birth", + "expiry", + "expiration", + "due", + "start", + "end", + "issue", + "beginn", + "ende", + "geburt", + "first", + "last", + "middle", + "given", + "family", + "maiden", + "company", + "business", + "organisation", + "organization", + "user", + "file", + "firma", + "vorname", + "nachname", + "geburtsname", +]); + +/** + * prefillOf returns what a text field asks for, by the words of its `/TU` + * and its name: the signer's name, the date, the place, or the place and + * date; none for anything else or for a mix + */ +export const prefillOf = (field: PdfField): Prefill | undefined => { + const words = [field.label ?? "", field.name.split(".").pop() ?? ""] + .join(" ") + .replace(/(\p{Ll})(\p{Lu})/gu, "$1 $2") + .toLowerCase() + .split(/[^\p{L}]+/u) + .filter(Boolean); + if (words.some((word) => OTHER_WORDS.has(word))) return undefined; + const has = (set: Set) => words.some((word) => set.has(word)); + const date = has(DATE_WORDS); + const place = has(PLACE_WORDS); + const name = has(NAME_WORDS); + if (name) return date || place ? undefined : "name"; + if (date && place) return "placeAndDate"; + if (date) return "date"; + if (place) return "place"; + return undefined; +}; + +/** + * fillOf returns what filling `place` does (see PlaceFill) + */ +export const fillOf = (place: Place): PlaceFill => { + if (place.source === "found") return { action: "stamp", stamp: place.kind }; + switch (place.kind) { + case "signature": + return { action: "stamp", stamp: "signature" }; + case "checkbox": + return { action: "toggle" }; + case "radio": + case "combo": + case "list": + return { action: "choose" }; + case "text": { + const prefill = prefillOf(place.field); + return prefill ? { action: "text", prefill } : { action: "text" }; + } + } +}; diff --git a/src/pdf/placesRead.ts b/src/pdf/placesRead.ts new file mode 100644 index 00000000..59607474 --- /dev/null +++ b/src/pdf/placesRead.ts @@ -0,0 +1,158 @@ +import { computed } from "vue"; + +import { engineless } from "../engine/engine"; +import { logWarning } from "../log"; +import { findLabels, type FoundPlace } from "../signing/labels"; +import { + activeTabId, + engineMissing, + lastPlace, + pdfPlaces, + shownPdf, + shownPdfRules, + signingTabs, + stampBooks, + type TabPlaces, +} from "../state"; +import { failureOf, type OpenedPdf, pageText, pdfFields } from "./ipc"; +import { filledOf } from "./placeModel"; +import { nextButton, placesOf } from "./places"; +import { dismissedOf, fillsOf, stampsOf } from "./stampBooks"; +import { onPages } from "./stampModel"; +import { blocked, type ToolContext } from "./toolRules"; + +// The places of the PDF tabs: their forms' fields, read once a PDF shows, +// and the places found by their labels, page by page in the background +// (`pdfPlaces`); and those of the PDF shown in reading order, which are +// filled, and where Next goes; and what the signing tools, places and +// fields of the PDF shown may do (`shownContext`). + +/** + * shownStamps returns the stamps of the PDF shown on the pages it has: a + * file opened again from elsewhere may have fewer + */ +export const shownStamps = () => { + const tab = activeTabId.value; + const opened = shownPdf.value?.opened; + return tab && opened ? onPages(stampsOf(tab), opened.pages.length) : []; +}; + +/** + * shownContext returns what the signing tools of the PDF shown may do + */ +export const shownContext = (): ToolContext => { + const tab = activeTabId.value; + const opened = shownPdf.value?.opened ?? null; + const read = shownPdfRules.value; + return { + opened, + rules: opened && read?.sha256 === opened.sha256 ? read.rules : null, + engine: !engineMissing.value && !engineless(), + signing: tab !== null && signingTabs.value.has(tab), + signatures: shownStamps().filter((stamp) => stamp.kind === "signature") + .length, + }; +}; + +// the readings under way, by tab, so a tab is read once per PDF it opened +const reading = new Map(); + +/** + * readPlaces reads the fields of the PDF `opened` in the tab `tab`, then + * the words and lines of its pages one after another for the places found + * by their labels, publishing what it has after each page. It stops once + * the tab closes or opens another PDF. Words and values are never logged. + */ +export const readPlaces = async (tab: string, opened: OpenedPdf) => { + const known = pdfPlaces.value.get(tab); + if (reading.get(tab) === opened || known?.sha256 === opened.sha256) return; + reading.set(tab, opened); + const current = () => reading.get(tab) === opened; + const publish = (places: TabPlaces) => { + if (!current()) return; + pdfPlaces.value = new Map(pdfPlaces.value).set(tab, places); + }; + let fields: TabPlaces["fields"] = []; + try { + fields = await pdfFields(tab); + } catch (error) { + if (!current()) return; + logWarning("can't read a PDF's fields", failureOf(error)); + } + const found: FoundPlace[] = []; + publish({ sha256: opened.sha256, fields, found: [] }); + for (let page = 0; page < opened.pages.length && current(); page++) { + try { + found.push(...findLabels(await pageText(tab, page), page)); + } catch (error) { + const failure = failureOf(error); + if (failure === "closed" || !current()) break; + logWarning("can't read a PDF page's text", failure); + continue; + } + publish({ sha256: opened.sha256, fields, found: [...found] }); + } + if (current()) reading.delete(tab); +}; + +/** + * forgetPlaces drops what was read of the tabs that aren't in `open` any + * more + */ +export const forgetPlaces = (open: ReadonlySet) => { + for (const tab of reading.keys()) if (!open.has(tab)) reading.delete(tab); + const kept = [...pdfPlaces.value].filter(([tab]) => open.has(tab)); + if (kept.length !== pdfPlaces.value.size) pdfPlaces.value = new Map(kept); +}; + +/** + * resetPlaces forgets every tab's places, for the tests + */ +export const resetPlaces = () => { + reading.clear(); + pdfPlaces.value = new Map(); + lastPlace.value = null; +}; + +// the places of the PDF shown, in reading order: its fields that can be +// filled (those an earlier signature locked marked so) and the places found +// that the user didn't dismiss, on the pages it has +export const shownPlaces = computed(() => { + void stampBooks.value; + const tab = activeTabId.value; + const opened = shownPdf.value?.opened; + const read = tab ? pdfPlaces.value.get(tab) : undefined; + if (!tab || !opened || read?.sha256 !== opened.sha256) return []; + const rules = shownPdfRules.value; + const locked = + rules?.sha256 === opened.sha256 ? rules.rules.locked : ([] as string[]); + return placesOf({ + fields: read.fields, + found: read.found, + locked: new Set(locked), + dismissed: new Set(dismissedOf(tab)), + }).filter((place) => place.page < opened.pages.length); +}); + +// the ids of the places of the PDF shown that are filled +export const shownFilled = computed(() => { + void stampBooks.value; + const tab = activeTabId.value; + return tab + ? filledOf(shownPlaces.value, stampsOf(tab), fillsOf(tab)) + : new Set(); +}); + +// why nothing can be filled in or signed on the PDF shown (not open, +// encrypted, damaged, certified without changes), or null: its places then +// show no targets, and Next none of them +export const shownBlocked = computed(() => blocked(shownContext())); + +// where Next goes in the PDF shown, and what it says +export const shownNext = computed(() => + nextButton( + shownBlocked.value ? [] : shownPlaces.value, + shownFilled.value, + lastPlace.value ?? undefined, + ), +); diff --git a/src/pdf/receivedPlan.test.ts b/src/pdf/receivedPlan.test.ts new file mode 100644 index 00000000..85c1a831 --- /dev/null +++ b/src/pdf/receivedPlan.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { receivedPlan } from "./receivedPlan"; +import { type Stamp, stampSpec } from "./stampModel"; + +// What signing a received PDF asks the signer for: the stamps as parts of +// the body, and where the signature goes. + +const stamp = (over: Partial = {}): Stamp => ({ + id: "s", + kind: "signature", + page: 0, + rect: [100, 300, 180, 60], + natural: [180, 60], + spec: stampSpec("signature"), + ...over, +}); + +const signing = { + digital: true, + place: "Berlin", + reason: "", + timestampServer: "https://tsa.example", +}; + +describe("signing a PDF received", () => { + it("shows the signature in the signature field it fills", () => { + const date = stamp({ id: "d", kind: "date", rect: [100, 380, 80, 14] }); + const signature = stamp({ place: "field:form.signature" }); + const { plan, parts } = receivedPlan({ + ...signing, + stamps: [date, signature], + }); + expect(parts).toEqual([signature, date]); + expect(plan.signature).toEqual({ + kind: "existing", + field: "form.signature", + stamp: 0, + }); + expect(plan.stamps).toEqual([ + { part: 1, page: 1, rect: [100, 380, 80, 14], repeat: false }, + ]); + expect(plan).not.toHaveProperty("name"); + expect(plan.location).toBe("Berlin"); + expect(plan.reason).toBeNull(); + expect(plan.timestampServer).toBe("https://tsa.example"); + expect(plan.others).toEqual([]); + }); + + it("puts a signature on a found line in a new field there", () => { + const signature = stamp({ page: 1, place: "found:1:100:316" }); + const { plan } = receivedPlan({ ...signing, stamps: [signature] }); + expect(plan.signature).toEqual({ + kind: "new", + page: 2, + rect: [100, 300, 180, 60], + stamp: 0, + }); + expect(plan.stamps).toEqual([]); + }); + + it("prefers the signature on a field when there are several", () => { + const free = stamp({ id: "free", rect: [10, 10, 180, 60] }); + const onField = stamp({ id: "on", place: "field:sig" }); + const { plan, parts } = receivedPlan({ + ...signing, + stamps: [free, onField], + }); + expect(plan.signature).toMatchObject({ kind: "existing", field: "sig" }); + expect(plan.stamps).toEqual([ + { part: parts.indexOf(free), page: 1, rect: free.rect, repeat: false }, + ]); + }); + + it("draws every stamp into the pages without a digital signature", () => { + const initials = stamp({ + id: "i", + kind: "initials", + rect: [500, 780, 56, 28], + repeat: true, + }); + const signature = stamp({ place: "field:sig" }); + const { plan, parts } = receivedPlan({ + ...signing, + digital: false, + stamps: [initials, signature], + }); + expect(plan.signature).toEqual({ kind: "invisible" }); + expect(plan.stamps).toEqual([ + { part: 0, page: 1, rect: signature.rect, repeat: false }, + { part: 1, page: 1, rect: initials.rect, repeat: true }, + ]); + expect(parts).toEqual([signature, initials]); + expect(plan.timestampServer).toBeNull(); + }); + + it("signs invisibly without a signature on the pages", () => { + const { plan, parts } = receivedPlan({ ...signing, stamps: [] }); + expect(plan.signature).toEqual({ kind: "invisible" }); + expect(parts).toEqual([]); + }); +}); diff --git a/src/pdf/receivedPlan.ts b/src/pdf/receivedPlan.ts new file mode 100644 index 00000000..25ed1752 --- /dev/null +++ b/src/pdf/receivedPlan.ts @@ -0,0 +1,70 @@ +import type { SignPlan, StampPlan } from "../signing/ipc"; +import { fieldOfPlace, readingOrder } from "./places"; +import type { Stamp } from "./stampModel"; + +// What signing a PDF the user received adds to it (`signing_sign_pdf` with +// the tab as its source): every stamp as a one-page PDF of its own, one part +// of the body each, drawn into the pages' content; and, signed digitally, +// the signature, which shows one signature stamp as the appearance of the +// signature field it fills, or of a new field where it stands. Pure. + +export interface ReceivedSigning { + stamps: readonly Stamp[]; + // whether the user's key signs too, else no digital signature + digital: boolean; + // the native side names the certificate's holder as who signed it + place: string; + reason: string; + timestampServer: string; +} + +/** + * receivedPlan returns the plan, and the stamps whose PDFs are the body's + * parts in that order: the stamps in reading order; signed digitally, the + * signature stamp on a signature field (else the first one) is the + * signature's appearance, there or in a new field at its spot; without a + * signature stamp the signature is invisible + */ +export const receivedPlan = ({ + stamps, + digital, + place, + reason, + timestampServer, +}: ReceivedSigning): { plan: SignPlan; parts: Stamp[] } => { + const parts = [...readingOrder(stamps)]; + const signatures = parts.filter((stamp) => stamp.kind === "signature"); + const shown = digital + ? (signatures.find((stamp) => fieldOfPlace(stamp.place) !== null) ?? + signatures[0]) + : undefined; + const field = fieldOfPlace(shown?.place); + const pageStamps: StampPlan[] = parts.flatMap((stamp, part) => + stamp === shown + ? [] + : [ + { + part, + page: stamp.page + 1, + rect: stamp.rect, + repeat: !!stamp.repeat, + }, + ], + ); + const at = shown ? parts.indexOf(shown) : -1; + return { + parts, + plan: { + stamps: pageStamps, + signature: !shown + ? { kind: "invisible" } + : field !== null + ? { kind: "existing", field, stamp: at } + : { kind: "new", page: shown.page + 1, rect: shown.rect, stamp: at }, + others: [], + location: place || null, + reason: reason || null, + timestampServer: (digital && timestampServer) || null, + }, + }; +}; diff --git a/src/pdf/sign.test.ts b/src/pdf/sign.test.ts new file mode 100644 index 00000000..5454d2a2 --- /dev/null +++ b/src/pdf/sign.test.ts @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeFile } from "@tauri-apps/plugin-fs"; +import { sendNotification } from "@tauri-apps/plugin-notification"; + +import { loadMarks } from "../signing/marks"; +import { pdfRules, type SignRequest, signPdf } from "../signing/ipc"; +import { + activeTabId, + announcement, + type SignChoices, + signDialog, + type SignDialogRequest, + signingInitials, + signingTabs, + shownPdfRules, + tabs, +} from "../state"; +import { flushPromises } from "../test/async"; +import { createState, doc, p } from "../test/editor"; +import { + pdfDocument, + pdfRulesOf, + pdfTab, + resetPdfState, + showPdf, + testStamp, +} from "../test/pdf"; +import { readShownRules, savedMessage, signShownPdf } from "./sign"; +import { changeStamps, stampsOf } from "./stampBooks"; +import { stampPdf } from "./stampEngine"; +import { CHECKING, RESTRICTED, SIGNING, VISIBLE_ON_SIGNED } from "./toolRules"; + +// Signing a PDF received (sign.ts): what refuses it, what the Sign dialog +// is asked, what it signs, and that a failure keeps what was placed. + +vi.mock("../signing/ipc", async (original) => ({ + ...(await original()), + pdfRules: vi.fn(), + signPdf: vi.fn(), + checkPassphrase: vi.fn(), +})); + +vi.mock("../signing/marks", async (original) => ({ + ...(await original()), + loadMarks: vi.fn(async () => {}), +})); + +vi.mock("../signing/certificate", async (original) => ({ + ...(await original()), + loadCertificate: vi.fn(async () => {}), +})); + +vi.mock("../editor/tabs", () => ({ openPaths: vi.fn(async () => {}) })); + +vi.mock("./stampEngine", () => ({ + naturalSize: vi.fn(async () => ({ width: 90, height: 14 })), + releaseStamp: vi.fn(async () => {}), + stampPdf: vi.fn(async () => new Uint8Array([1])), +})); + +vi.mock("../engine/engine", async (original) => ({ + ...(await original()), + engineless: () => false, +})); + +const pdf = pdfDocument(2); +const state = createState(doc(p())); +const run = () => signShownPdf()(state, () => {}); +const canSign = () => signShownPdf()(state); + +/** + * ask opens the Sign dialog and returns its request + */ +const ask = async (): Promise => { + run(); + await flushPromises(); + return signDialog.value!; +}; + +const CHOICES: SignChoices = { + pos: null, + place: "Berlin", + reason: "", + passphrase: "secret", + initials: { on: false, side: "right" }, + open: false, +}; + +/** + * sign signs as `choices` say, from the Sign dialog + */ +const sign = async (choices: Partial = {}) => { + const request = await ask(); + signDialog.value = null; + request.submit({ ...CHOICES, ...choices }); + await flushPromises(); +}; + +describe("signing a PDF received", () => { + beforeEach(() => { + showPdf(pdf); + vi.mocked(save).mockResolvedValue("/docs/contract signed.pdf"); + vi.mocked(signPdf).mockResolvedValue(new Uint8Array([37, 80, 68, 70])); + changeStamps("t", [testStamp()]); + }); + + afterEach(() => { + resetPdfState(); + }); + + it("refuses a PDF it can't sign yet or at all, and says why", async () => { + shownPdfRules.value = null; + expect(canSign()).toBe(false); + run(); + expect(announcement.value?.text).toBe(CHECKING); + shownPdfRules.value = { + sha256: "abc", + rules: pdfRulesOf({ encrypted: true }), + }; + run(); + await flushPromises(); + expect(announcement.value?.text).toBe(RESTRICTED); + expect(signDialog.value).toBeNull(); + }); + + it("signs only the stamps on the pages the PDF has", async () => { + changeStamps("t", [testStamp(), testStamp({ id: "far", page: 5 })]); + await sign(); + const [request, parts] = vi.mocked(signPdf).mock.lastCall!; + expect(parts).toHaveLength(1); + expect((request as SignRequest).plan.stamps).toEqual([]); + expect(stampsOf("t")).toEqual([]); + }); + + it("lets nothing change while it signs, and only then is done with the stamps", async () => { + let answer: (bytes: Uint8Array) => void = () => {}; + vi.mocked(signPdf).mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)), + ); + await sign(); + expect(signingTabs.value.has("t")).toBe(true); + expect(canSign()).toBe(false); + changeStamps("t", []); + expect(announcement.value?.text).toBe(SIGNING); + expect(stampsOf("t")).toHaveLength(1); + answer(new Uint8Array([37])); + await flushPromises(); + expect(signingTabs.value.size).toBe(0); + expect(stampsOf("t")).toEqual([]); + expect(announcement.value?.text).toBe( + "Signed and saved as contract signed.pdf", + ); + }); + + it("says the signature is only a picture without a digital signature", async () => { + await sign({ passphrase: null }); + expect(announcement.value?.text).toBe( + "Saved as contract signed.pdf, your signature shows as a picture", + ); + expect(savedMessage("a.pdf", false, false)).toBe("Saved as a.pdf"); + }); + + it("keeps what was placed when the signed PDF can't be written", async () => { + vi.mocked(writeFile).mockRejectedValueOnce(new Error("disk full")); + await sign(); + expect(stampsOf("t")).toHaveLength(1); + expect(signingTabs.value.size).toBe(0); + expect(vi.mocked(sendNotification).mock.lastCall?.[0]).toMatchObject({ + title: "Signed PDF", + body: expect.stringContaining("Nothing was saved."), + }); + }); + + it("signs nothing when a stamp's PDF can't be made", async () => { + vi.mocked(stampPdf).mockRejectedValueOnce(new Error("no layout")); + await sign(); + expect(signPdf).not.toHaveBeenCalled(); + expect(writeFile).not.toHaveBeenCalled(); + expect(stampsOf("t")).toHaveLength(1); + }); + + it("asks nothing when the signature can't be read", async () => { + vi.mocked(loadMarks).mockRejectedValueOnce(new Error("unreadable")); + run(); + await flushPromises(); + expect(signDialog.value).toBeNull(); + expect(sendNotification).toHaveBeenCalled(); + }); + + it("signs nothing once another tab shows", async () => { + const request = await ask(); + tabs.value = [...tabs.value, pdfTab("other")]; + activeTabId.value = "other"; + signDialog.value = null; + request.submit(CHOICES); + await flushPromises(); + expect(save).not.toHaveBeenCalled(); + expect(stampsOf("t")).toHaveLength(1); + }); + + it("signs a PDF others signed only digitally", async () => { + shownPdfRules.value = { + sha256: "abc", + rules: pdfRulesOf({ signed: true }), + }; + expect((await ask()).visibleRefusal).toBe(VISIBLE_ON_SIGNED); + shownPdfRules.value = { sha256: "abc", rules: pdfRulesOf() }; + signDialog.value = null; + expect((await ask()).visibleRefusal).toBeUndefined(); + }); + + it("takes back the initials the dialog put on when it's cancelled", async () => { + const request = await ask(); + request.setInitials!({ on: true, side: "left" }); + await flushPromises(); + expect(stampsOf("t").some((stamp) => stamp.kind === "initials")).toBe(true); + signDialog.value = null; + request.cancel(); + await flushPromises(); + expect(stampsOf("t")).toEqual([testStamp()]); + }); + + it("takes back initials whose stamp came after the dialog was cancelled", async () => { + const request = await ask(); + request.setInitials!({ on: true, side: "left" }); + signDialog.value = null; + request.cancel(); + await flushPromises(); + expect(stampsOf("t")).toEqual([testStamp()]); + }); + + it("remembers the initials chosen once it signed", async () => { + await sign({ initials: { on: true, side: "left" } }); + expect(signingInitials.value).toEqual({ on: true, side: "left" }); + }); + + it("lets the tools place anything when the PDF's rules can't be read", async () => { + shownPdfRules.value = null; + vi.mocked(pdfRules).mockRejectedValueOnce({ kind: "failed", message: "" }); + await readShownRules(); + expect(shownPdfRules.value).toEqual({ + sha256: "abc", + rules: pdfRulesOf(), + }); + }); +}); diff --git a/src/pdf/sign.ts b/src/pdf/sign.ts new file mode 100644 index 00000000..3bf92b5f --- /dev/null +++ b/src/pdf/sign.ts @@ -0,0 +1,422 @@ +import type { Command } from "prosemirror-state"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeFile } from "@tauri-apps/plugin-fs"; +import { sendNotification } from "@tauri-apps/plugin-notification"; + +import { config } from "../config"; +import { + SIGNED_PDF_TITLE, + signDialogBase, + signingFailure, +} from "../editor/commands/signing"; +import { showSettings } from "../editor/commands/settings"; +import suggestPath from "../editor/commands/suggestPath"; +import { PDF_FILTER } from "../formats"; +import { logError, logWarning } from "../log"; +import { basename } from "../paths"; +import { + type InitialsChoice, + type InitialsSide, + initialsRect, +} from "../signing/initials"; +import { forTheLog, pdfRules, signingKind, signPdf } from "../signing/ipc"; +import { loadCertificate, reportSigningFailure } from "../signing/certificate"; +import { loadMarks } from "../signing/marks"; +import { + activeTab, + activeTabId, + announce, + isPdfTab, + shownPdf, + shownPdfRules, + signDialog, + type SignChoices, + signingInitials, + signingPlace, + signingTabs, + stampBooks, +} from "../state"; +import type { PdfTabDocument } from "./document"; +import type { OpenedPdf } from "./ipc"; +import { PDF_VIEW_ID } from "./placeholder"; +import { isOpen } from "./places"; +import { + shownContext, + shownFilled, + shownPlaces, + shownStamps, +} from "./placesRead"; +import { receivedPlan } from "./receivedPlan"; +import { newStamp } from "./stampActions"; +import { + changeStamps, + clearSheet, + fillsOf, + returnTo, + stampsOf, +} from "./stampBooks"; +import { stampPdf } from "./stampEngine"; +import { + onPages, + scaledSpec, + type Stamp, + toggleRepeat, + withoutStamp, + withStamp, +} from "./stampModel"; +import { signRefusal, toolRefusal, visibleRefusal } from "./toolRules"; + +// Signing a PDF the user received, `pdf.sign` (Sign… on the signing +// toolbar, Save's key in a PDF tab, and Sign… when a tab with stamps +// closes): the Sign dialog, then the save dialog, then the stamps' PDFs and +// the signer, which writes the fills, the stamps and the signature as +// updates of the file the tab opened (see .claude/rules/signing.md). What +// the PDF allows is read once it shows (`readShownRules`). While it signs, +// the tab's stamps, fills and places wait (`signingTabs`). + +// what a PDF allows when it can't be read +const OPEN_RULES = { + signed: false, + docmdp: null, + encrypted: false, + damaged: false, + locked: [], +}; + +export const SIGN_IN_PDF = + "Sign… signs PDFs you received. Open one to sign it."; +const TITLE = SIGNED_PDF_TITLE; + +declare global { + interface Window { + // where E2E has a signed PDF saved, since the save dialog can't be + // automated: a debug build saves there once a test sets it + blankTestSavePath?: string; + } +} + +/** + * chooseDestination asks where the signed PDF goes, suggesting + * `suggested`; null when the user cancelled + */ +const chooseDestination = (suggested: string | undefined) => + __TEST_HOOKS__ && window.blankTestSavePath + ? Promise.resolve(window.blankTestSavePath) + : save({ filters: [PDF_FILTER], defaultPath: suggested }); + +/** + * sideOf returns the corner initials at `stamp` are nearest to + */ +const sideOf = (stamp: Stamp, width: number): InitialsSide => + stamp.rect[0] + stamp.rect[2] / 2 < width / 2 ? "left" : "right"; + +/** + * initialsSetter returns what the Sign dialog's Initials row does in the + * tab `tab`: on, the initials repeat on every page (those placed, else new + * ones in the corner chosen of the first page); off, they don't (and those + * the row added go again); another corner moves them there + */ +const initialsSetter = (tab: string) => { + let added: string | null = null; + return async ({ on, side }: InitialsChoice) => { + const opened = shownPdf.value?.opened; + if (activeTabId.value !== tab || !opened) return; + const stamps = onPages(stampsOf(tab), opened.pages.length); + const repeated = stamps.find( + (stamp) => stamp.kind === "initials" && stamp.repeat, + ); + if (!on) { + if (!repeated) return; + changeStamps( + tab, + repeated.id === added + ? withoutStamp(stampsOf(tab), repeated.id) + : toggleRepeat(stampsOf(tab), repeated.id), + ); + if (repeated.id === added) added = null; + return; + } + if (repeated) { + const page = opened.pages[repeated.page]; + if (sideOf(repeated, page.width) !== side) + changeStamps( + tab, + withStamp(stampsOf(tab), { + ...repeated, + rect: initialsRect(page, side, [ + repeated.rect[2], + repeated.rect[3], + ]), + }), + ); + return; + } + const placed = stamps.find((stamp) => stamp.kind === "initials"); + if (placed) { + changeStamps(tab, toggleRepeat(stampsOf(tab), placed.id)); + return; + } + const made = await newStamp("initials"); + if (!made || activeTabId.value !== tab) return; + const stamp: Stamp = { + ...made, + page: 0, + rect: initialsRect(opened.pages[0], side, made.natural), + repeat: true, + }; + added = stamp.id; + changeStamps(tab, withStamp(stampsOf(tab), stamp), { + sha256: opened.sha256, + }); + }; +}; + +/** + * signReceived signs the PDF shown with what was placed on it: the Sign + * dialog, then where to save it, then the signer. Without a signature + * placed, the dialog offers an invisible digital signature, or asks for + * one on the page first. + */ +export const signReceived = () => { + const refusal = signRefusal(shownContext()); + if (refusal) { + announce(refusal); + return; + } + const tab = activeTabId.value; + const doc = shownPdf.value; + const opened = doc?.opened; + if (!tab || !doc || !opened) return; + const pages = () => document.getElementById(PDF_VIEW_ID); + const refocus = () => pages()?.focus(); + const shown = () => activeTabId.value === tab && shownPdf.value === doc; + const ask = () => { + if (!shown()) return; + const stamps = shownStamps(); + const repeated = stamps.find( + (stamp) => stamp.kind === "initials" && stamp.repeat, + ); + const context = shownContext(); + const initialsReason = toolRefusal("initials", context); + const visibleReason = visibleRefusal(context); + const setInitials = initialsSetter(tab); + // what the tab held before the Initials row changed it, which a cancel + // brings back + const before = stampBooks.value.get(tab); + let changed = false; + // the Initials row's changes, one after the other: new initials wait + // for their stamp, which signing and a cancel wait for in turn + let initialsDone: Promise = Promise.resolve(); + signDialog.value = { + ...signDialogBase(refocus), + mode: "pdf", + title: basename(doc.path).replace(/\.pdf$/i, ""), + targets: [], + chosen: 0, + warnings: [], + confirmPages: false, + initials: { + on: !!repeated, + side: repeated + ? sideOf(repeated, opened.pages[repeated.page].width) + : signingInitials.value.side, + ...(initialsReason ? { reason: initialsReason } : {}), + }, + setInitials: (choice) => { + changed = true; + initialsDone = initialsDone + .then(() => setInitials(choice)) + // the dialog goes on without them; the next change tries again + .catch((error: unknown) => + logWarning( + "failed to place the initials on every page", + forTheLog(error), + ), + ); + }, + stamped: stamps.some((stamp) => stamp.kind === "signature"), + ...(visibleReason ? { visibleRefusal: visibleReason } : {}), + emptyFields: shownPlaces.value.filter((place) => + isOpen(place, shownFilled.value), + ).length, + manage: () => { + refocus(); + showSettings("signing"); + }, + submit: (choices) => { + refocus(); + void initialsDone.then(() => { + if (shown()) void signNow(tab, choices); + }); + }, + cancel: () => { + if (changed) void initialsDone.then(() => returnTo(tab, before)); + refocus(); + }, + }; + }; + void Promise.all([loadMarks(), loadCertificate()]) + .then(ask) + .catch((error: unknown) => + reportSigningFailure( + "failed to read the signature", + error, + "Blank couldn't read your signature", + TITLE, + ), + ); +}; + +/** + * setSigning marks the tab `tab` as being signed, or not any more + */ +const setSigning = (tab: string, on: boolean) => { + const next = new Set(signingTabs.value); + if (on) next.add(tab); + else next.delete(tab); + signingTabs.value = next; +}; + +/** + * writeSigned writes the PDF of the tab `tab` signed as chosen: where the + * user says, then the stamps' PDFs (those on the pages it has), then the + * signer + * @returns where it went, null when the user didn't choose a place + */ +const writeSigned = async ( + tab: string, + doc: PdfTabDocument, + opened: OpenedPdf, + choices: SignChoices, +) => { + const dest = await chooseDestination( + suggestPath("pdf", { path: doc.path, importedFrom: null }, " signed"), + ); + if (dest === null) return null; + const { plan, parts } = receivedPlan({ + stamps: onPages(stampsOf(tab), opened.pages.length), + digital: choices.passphrase !== null, + place: choices.place, + reason: choices.reason, + timestampServer: config.value.signing.timestampServer, + }); + await loadMarks(); + const pdfs: Uint8Array[] = []; + for (const stamp of parts) + pdfs.push(await stampPdf(scaledSpec(stamp), stamp.rect[2], stamp.rect[3])); + const bytes = await signPdf( + { + passphrase: choices.passphrase, + source: { + kind: "tab", + tab, + path: doc.path, + sha256: opened.sha256, + fills: [...fillsOf(tab)], + }, + plan, + }, + pdfs, + ); + await writeFile(dest, bytes); + return dest; +}; + +/** + * savedMessage says that the PDF was saved as `file`: signed, when it was + * signed digitally; else saved, with the user's signature as a picture + * when one was placed, since the PDF's signatures don't count it + */ +export const savedMessage = ( + file: string, + digital: boolean, + pictured: boolean, +) => + digital + ? `Signed and saved as ${file}` + : pictured + ? `Saved as ${file}, your signature shows as a picture` + : `Saved as ${file}`; + +/** + * signNow saves the PDF of the tab `tab` signed as chosen (writeSigned); + * meanwhile its stamps, fills and places wait. Once it's written, the + * tab's stamps and fills are done with, if they're still those it signed, + * and the signed PDF opens in a tab of its own if asked. Nothing is written + * when anything fails. + */ +const signNow = async (tab: string, choices: SignChoices) => { + const doc = shownPdf.value; + const opened = doc?.opened; + if (!doc || !opened || signingTabs.value.has(tab)) return; + // what is signed + const book = stampBooks.value.get(tab)?.book; + setSigning(tab, true); + let dest: string | null; + try { + dest = await writeSigned(tab, doc, opened, choices); + } catch (error) { + logError("failed to sign a PDF", forTheLog(error)); + const body = signingFailure(error); + sendNotification({ title: TITLE, body }); + announce(body); + return; + } finally { + setSigning(tab, false); + } + if (dest === null) return; + if (choices.place) signingPlace.value = choices.place; + signingInitials.value = choices.initials; + const pictured = (book?.stamps ?? []).some( + (stamp) => stamp.kind === "signature", + ); + if (stampBooks.value.get(tab)?.book === book) clearSheet(tab); + const saved = savedMessage( + basename(dest), + choices.passphrase !== null, + pictured, + ); + announce(saved); + sendNotification({ title: TITLE, body: saved }); + if (choices.open) { + // the tabs ask this module to sign when a tab closes, so they're loaded + // only here + const { openPaths } = await import("../editor/tabs"); + await openPaths([dest]); + } +}; + +/** + * signShownPdf is `pdf.sign`: it signs the PDF shown; asked whether it can, + * it can in a PDF tab whose PDF allows it + */ +export const signShownPdf = (): Command => (_state, dispatch) => { + const inPdf = isPdfTab(activeTab.value); + if (!dispatch) return inPdf && signRefusal(shownContext()) === null; + if (!inPdf) announce(SIGN_IN_PDF); + else signReceived(); + return true; +}; + +/** + * readShownRules reads what the PDF shown allows, once per file it opened; + * one that can't be read allows anything, and the signer refuses what it + * doesn't; that's logged by kind + */ +export const readShownRules = async () => { + const doc = shownPdf.value; + const sha256 = doc?.opened?.sha256; + if (!doc || !sha256 || shownPdfRules.value?.sha256 === sha256) return; + try { + const rules = await pdfRules(doc.path, sha256); + // still the PDF shown + if (shownPdf.value?.opened?.sha256 === sha256) + shownPdfRules.value = { sha256, rules }; + } catch (error) { + // the tools don't wait for ever: the native side refuses what the PDF + // doesn't allow when it signs + logWarning("can't read what a PDF allows", signingKind(error)); + if (shownPdf.value?.opened?.sha256 === sha256) + shownPdfRules.value = { sha256, rules: OPEN_RULES }; + } +}; diff --git a/src/pdf/stampActions.ts b/src/pdf/stampActions.ts new file mode 100644 index 00000000..cc7ec5a4 --- /dev/null +++ b/src/pdf/stampActions.ts @@ -0,0 +1,308 @@ +import { nextTick } from "vue"; + +import { openSignatureDialog, todayText } from "../editor/commands/signing"; +import { logWarning } from "../log"; +import { loadMarks } from "../signing/marks"; +import { + activeTabId, + announce, + pdfDeskLayout, + pdfPageInView, + pdfViewport, + placeDialog, + placingStamp, + savedMarks, + selectedStamp, + shownPdf, + signingPlace, +} from "../state"; +import { PDF_VIEW_ID } from "./placeholder"; +import { placeElementId, staysOn } from "./placeModel"; +import { shownPlaces } from "./placesRead"; +import { changeStamps, stampsOf } from "./stampBooks"; +import { naturalSize } from "./stampEngine"; +import { STAMPS_NEED_LAYOUT } from "./stamps"; +import { + defaultSpot, + markSize, + type Shown, + type Stamp, + STAMP_NAMES, + type StampKind, + stampSpec, + spotAt, + toggleRepeat, + withoutStamp, + withStamp, +} from "./stampModel"; + +// What the signing tools and the keys do with the stamps of the PDF shown: +// make a stamp of a kind (the user's signature first, if they have none), +// place it at its default spot or where the page was clicked, select it, +// remove it, and say so. The shown tab's stamps go through stampBooks.ts. + +export const PLACING_CANCELED = "Placing canceled"; + +/** + * placedMessage says that `stamp` was placed, and how to move it: "Place + * added", since "Place placed" reads as a stutter + */ +export const placedMessage = (stamp: Stamp) => + `${STAMP_NAMES[stamp.kind]} ${stamp.kind === "place" ? "added" : "placed"} on page ${stamp.page + 1}, use the arrow keys to move it`; + +/** + * removedMessage says that `stamp` was removed + */ +export const removedMessage = (stamp: Stamp) => + `${STAMP_NAMES[stamp.kind]} removed`; + +/** + * lineOf returns the line a date or place stamp shows: today's date as the + * settings write it, in the language, or where the user signs + */ +const lineOf = (kind: "date" | "place") => + kind === "date" ? todayText() : signingPlace.value.trim(); + +/** + * askPlace asks where the user signs, and remembers it (`signingPlace`); + * `refocus` gives the focus back once the question closes + * @returns the place, or null when the user cancelled + */ +export const askPlace = (refocus: () => void) => + new Promise((resolve) => { + placeDialog.value = { + place: signingPlace.value, + submit: (place) => { + signingPlace.value = place; + refocus(); + resolve(place); + }, + cancel: () => { + refocus(); + resolve(null); + }, + }; + }); + +/** + * knownPlace returns where the user signs, asking first when Blank doesn't + * know it yet + * @returns the place, or null when the user cancelled + */ +export const knownPlace = async (refocus: () => void) => + signingPlace.value.trim() || (await askPlace(refocus)); + +/** + * newStamp makes a stamp of `kind`, not yet placed: its look and its + * natural size; null for a signature or initials the user has none of yet + * @param line what a date or place stamp shows, if not the date or place + * @param letters the size of a date or place stamp's letters, if not its own + * @throws when the layout can't size its lines + */ +export const newStamp = async ( + kind: StampKind, + line?: string, + letters?: number, +): Promise | null> => { + const id = crypto.randomUUID(); + if (kind === "signature" || kind === "initials") { + await loadMarks(); + const mark = savedMarks.value?.[kind]; + if (!mark) return null; + return { + id, + kind, + spec: stampSpec(kind), + natural: markSize(kind, mark.width, mark.height), + }; + } + const own = stampSpec(kind, line ?? lineOf(kind)); + const spec = letters ? { ...own, size: letters } : own; + const { width, height } = await naturalSize(spec); + return { id, kind, spec, natural: [width, height] }; +}; + +/** + * shownPart returns the part of the page `page` of the PDF shown that shows + * in the view, in points from its top + */ +const shownPart = (page: number): Shown => { + const layout = pdfDeskLayout.value; + const frame = layout?.frames[page]; + const viewport = pdfViewport.value; + const height = shownPdf.value?.opened?.pages[page]?.height ?? 0; + if (!layout || !frame || !viewport) return { top: 0, height }; + const top = Math.max(0, (viewport.scrollTop - frame.top) / layout.scale); + const bottom = Math.min( + height, + (viewport.scrollTop + viewport.height - frame.top) / layout.scale, + ); + return bottom > top ? { top, height: bottom - top } : { top: 0, height }; +}; + +/** + * focusStamp gives the stamp `id` the focus once it shows, so the arrows + * move it, and scrolls it into view: at a zoom wider than the view, its spot + * may be beside what shows + */ +export const focusStamp = async (id: string) => { + await nextTick(); + const element = document.getElementById(`stamp-${id}`); + if (!element) return; + element.focus({ preventScroll: true }); + element.scrollIntoView?.({ block: "nearest", inline: "nearest" }); +}; + +// where a stamp goes: its default spot on the page in view, or centred on a +// point of a page (display points) +export type Where = { page: number; x: number; y: number } | null; + +/** + * placeStamp places a new stamp of `kind` on the PDF shown, at `where`, + * selects it, gives it the focus and says so. The user's signature or initials are made first + * if there are none yet (`refocus` gives the focus back once the dialog + * closes). + * @returns the stamp placed, or null + */ +export const placeStamp = async ( + kind: StampKind, + where: Where, + refocus: () => void, +): Promise => { + const tab = activeTabId.value; + const opened = shownPdf.value?.opened; + if (!tab || !opened) return null; + // where the user signs, asked for the first time + if (kind === "place" && !(await knownPlace(refocus))) return null; + let made; + try { + made = await newStamp(kind); + } catch (error) { + logWarning("failed to make a stamp", error); + announce(STAMPS_NEED_LAYOUT); + return null; + } + if (!made) { + const mark = kind as "signature" | "initials"; + // made first, then placed + void openSignatureDialog(mark, refocus, () => { + if (activeTabId.value === tab) void placeStamp(kind, where, refocus); + }); + return null; + } + // the tab changed while it was made + if (activeTabId.value !== tab || shownPdf.value?.opened !== opened) + return null; + const sizes = opened.pages; + const at = where + ? { + page: where.page, + rect: spotAt(made.natural, where.x, where.y, sizes[where.page]), + } + : defaultSpot( + kind, + made.natural, + Math.min(pdfPageInView.value ?? 0, sizes.length - 1), + sizes, + shownPart(pdfPageInView.value ?? 0), + stampsOf(tab), + ); + const stamp: Stamp = { ...made, ...at }; + changeStamps(tab, withStamp(stampsOf(tab), stamp), { + sha256: opened.sha256, + }); + selectedStamp.value = stamp.id; + announce(placedMessage(stamp)); + // also after a dialog that made the signature first, whose `refocus` + // gave the focus back to the tool + void focusStamp(stamp.id); + return stamp; +}; + +/** + * focusFreed gives the focus, once the DOM shows a stamp gone, to the place + * `place` it filled, which shows again, else to the pages + */ +export const focusFreed = (place: string | undefined) => + void nextTick(() => + ( + (place && document.getElementById(placeElementId(place))) || + document.getElementById(PDF_VIEW_ID) + )?.focus(), + ); + +/** + * removeStamp removes the stamp `id` of the PDF shown and says so; the + * place it filled, if it filled one, shows again + * @returns the stamp removed, or null when there was none to remove + */ +export const removeStamp = (id: string): Stamp | null => { + const tab = activeTabId.value; + if (!tab) return null; + const stamps = stampsOf(tab); + const stamp = stamps.find((one) => one.id === id); + if (!stamp) return null; + changeStamps(tab, withoutStamp(stamps, id)); + // Blank signs the PDF meanwhile + if (stampsOf(tab) === stamps) return null; + if (selectedStamp.value === id) selectedStamp.value = null; + announce(removedMessage(stamp)); + return stamp; +}; + +/** + * repeatStamp puts the initials `id` of the PDF shown on every page, or + * back on their own page only, and says so + */ +export const repeatStamp = (id: string) => { + const tab = activeTabId.value; + if (!tab) return; + const stamps = toggleRepeat(stampsOf(tab), id); + const stamp = stamps.find((one) => one.id === id); + if (stamp?.kind !== "initials") return; + changeStamps(tab, stamps); + announce( + stamp.repeat + ? "Initials on every page" + : `Initials only on page ${stamp.page + 1}`, + ); +}; + +/** + * offItsPlace returns `stamp` without the place it filled once it was moved + * or resized off it, so that place shows again; else as it is + */ +const offItsPlace = (stamp: Stamp): Stamp => { + if (!stamp.place) return stamp; + const place = shownPlaces.value.find((one) => one.id === stamp.place); + if (!place || (place.page === stamp.page && staysOn(stamp.rect, place.rect))) + return stamp; + const { id, kind, page, rect, natural, repeat, spec } = stamp; + return { id, kind, page, rect, natural, spec, ...(repeat ? { repeat } : {}) }; +}; + +/** + * updateStamp puts `stamp` in place of the one of its id on the PDF shown; + * one moved off the place it filled leaves it + * @param nudge when a nudge moved it, which joins the nudges of the last + * moment in one step + */ +export const updateStamp = (stamp: Stamp, nudge?: number) => { + const tab = activeTabId.value; + if (!tab) return; + changeStamps( + tab, + withStamp(stampsOf(tab), offItsPlace(stamp)), + nudge === undefined ? {} : { nudge: { id: stamp.id, at: nudge } }, + ); +}; + +/** + * cancelPlacing stops placing a stamp with the mouse, and says so + */ +export const cancelPlacing = () => { + if (placingStamp.value === null) return false; + placingStamp.value = null; + announce(PLACING_CANCELED); + return true; +}; diff --git a/src/pdf/stampBooks.test.ts b/src/pdf/stampBooks.test.ts new file mode 100644 index 00000000..8bf19871 --- /dev/null +++ b/src/pdf/stampBooks.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { announcement, stampBooks } from "../state"; +import type { PdfTabDocument } from "./document"; +import { + canRedoStamps, + canUndoStamps, + changeStamps, + forgetStamps, + redoStamps, + resetStampBooks, + restoreStamps, + stampsOf, + undoStamps, +} from "./stampBooks"; +import { releaseStamp } from "./stampEngine"; +import { type Stamp, stampSpec } from "./stampModel"; + +vi.mock("./stampEngine", () => ({ + releaseStamp: vi.fn(async () => {}), +})); + +const a4 = { width: 595, height: 842, rotation: 0 }; + +const stamp = (over: Partial = {}): Stamp => ({ + id: "s1", + kind: "signature", + page: 0, + rect: [100, 100, 180, 60], + natural: [180, 60], + spec: stampSpec("signature"), + ...over, +}); + +const doc = (sha256: string, pages: number): PdfTabDocument => ({ + path: "/docs/contract.pdf", + opened: { + sha256, + pages: Array.from({ length: pages }, () => a4), + repaired: false, + encrypted: false, + }, + problem: null, + failedPages: [], + signatures: null, +}); + +afterEach(() => { + resetStampBooks(); + announcement.value = null; +}); + +describe("a tab's stamps", () => { + it("change in steps that undo and redo", () => { + changeStamps("t", [stamp()], { sha256: "abc" }); + changeStamps("t", [stamp({ rect: [0, 0, 180, 60] })]); + expect(stampBooks.value.get("t")?.sha256).toBe("abc"); + expect(canUndoStamps("t")).toBe(true); + expect(undoStamps("t")).toBe(true); + expect(stampsOf("t")[0].rect[0]).toBe(100); + expect(canRedoStamps("t")).toBe(true); + expect(redoStamps("t")).toBe(true); + expect(stampsOf("t")[0].rect[0]).toBe(0); + expect(redoStamps("t")).toBe(false); + }); + + it("keep their history once all are undone, and none of another tab's", () => { + changeStamps("t", [stamp()]); + undoStamps("t"); + expect(stampsOf("t")).toEqual([]); + expect(canRedoStamps("t")).toBe(true); + expect(canUndoStamps("other")).toBe(false); + expect(undoStamps("other")).toBe(false); + expect(canUndoStamps(null)).toBe(false); + }); + + it("free the engines of looks no stamp has any more", () => { + changeStamps("t", [stamp()]); + forgetStamps("t"); + expect(stampBooks.value.has("t")).toBe(false); + expect(releaseStamp).toHaveBeenCalledWith(stampSpec("signature")); + }); +}); + +describe("restoring a tab's stamps", () => { + const stored = (sha256: string) => ({ + sha256, + stamps: [stamp(), stamp({ id: "far", page: 4 })], + fills: [] as { field: string; value: string | boolean }[], + dismissed: [] as string[], + }); + + it("brings them back as they were on the same file", () => { + restoreStamps("t", doc("abc", 5), stored("abc")); + expect(stampsOf("t").map((s) => s.id)).toEqual(["s1", "far"]); + // with no history + expect(canUndoStamps("t")).toBe(false); + expect(announcement.value).toBeNull(); + }); + + it("drops those on pages a changed file no longer has, and says so", () => { + restoreStamps("t", doc("new", 2), stored("abc")); + expect(stampsOf("t").map((s) => s.id)).toEqual(["s1"]); + expect(stampBooks.value.get("t")?.sha256).toBe("new"); + expect(announcement.value?.text).toBe( + "contract.pdf changed since you last had it open; 1 stamp was on pages it no longer has", + ); + }); + + it("moves those beside a smaller page of a changed file onto it", () => { + const a5 = { width: 420, height: 595, rotation: 0 }; + const smaller = doc("new", 1); + smaller.opened!.pages = [a5]; + restoreStamps("t", smaller, { + ...stored("abc"), + stamps: [ + stamp({ rect: [400, 700, 180, 60] }), + stamp({ id: "wide", rect: [0, 0, 600, 200], natural: [600, 200] }), + ], + }); + const [moved, wide] = stampsOf("t"); + expect(moved.rect).toEqual([240, 535, 180, 60]); + // made smaller, its shape and letters kept + expect(wide.rect).toEqual([0, 0, 420, 140]); + expect(wide.natural).toEqual([420, 140]); + expect(wide.spec.size).toBeCloseTo(stampSpec("signature").size * 0.7); + }); + + it("brings back the fills and dismissed places on the same file only", () => { + const kept = { + ...stored("abc"), + fills: [{ field: "name", value: "Ada" }], + dismissed: ["found:0:100:300"], + }; + restoreStamps("t", doc("abc", 5), kept); + expect(stampBooks.value.get("t")?.book.fills).toEqual(kept.fills); + expect(stampBooks.value.get("t")?.book.dismissed).toEqual(kept.dismissed); + restoreStamps("u", doc("new", 5), kept); + expect(stampBooks.value.get("u")?.book.fills).toEqual([]); + expect(stampBooks.value.get("u")?.book.dismissed).toEqual([]); + expect(announcement.value?.text).toBe( + "contract.pdf changed since you last had it open; its form is empty again", + ); + }); + + it("keeps them when the file went", () => { + restoreStamps( + "t", + { ...doc("abc", 1), opened: null, problem: "missing" }, + stored("abc"), + ); + expect(stampsOf("t")).toHaveLength(2); + }); + + it("fits them to a file found again after it went", () => { + const gone = { + ...doc("abc", 1), + opened: null, + problem: "missing" as const, + }; + restoreStamps("t", gone, stored("abc")); + expect(stampsOf("t")).toHaveLength(2); + // Find it… opens another file + restoreStamps("t", doc("new", 2), null); + expect(stampsOf("t").map((s) => s.id)).toEqual(["s1"]); + expect(stampBooks.value.get("t")?.sha256).toBe("new"); + }); + + it("fits what was placed to a file that changed when it opens again", () => { + changeStamps("t", [stamp(), stamp({ id: "far", page: 4 })], { + sha256: "abc", + }); + restoreStamps("t", doc("abc", 5), null); + expect(stampsOf("t")).toHaveLength(2); + // Open again, on a file that changed meanwhile + restoreStamps("t", doc("new", 2), null); + expect(stampsOf("t").map((s) => s.id)).toEqual(["s1"]); + expect(canUndoStamps("t")).toBe(false); + }); + + it("drops dismissed places alone on a changed file, saying nothing", () => { + restoreStamps("t", doc("new", 5), { + ...stored("abc"), + stamps: [], + dismissed: ["found:0:100:300"], + }); + expect(stampBooks.value.has("t")).toBe(false); + expect(announcement.value).toBeNull(); + }); + + it("restores only once, and never over stamps placed meanwhile", () => { + restoreStamps("t", doc("abc", 5), null); + restoreStamps("t", doc("abc", 5), stored("abc")); + expect(stampsOf("t")).toEqual([]); + changeStamps("u", [stamp({ id: "new" })]); + restoreStamps("u", doc("abc", 5), stored("abc")); + expect(stampsOf("u").map((s) => s.id)).toEqual(["new"]); + }); +}); diff --git a/src/pdf/stampBooks.ts b/src/pdf/stampBooks.ts new file mode 100644 index 00000000..8a10133d --- /dev/null +++ b/src/pdf/stampBooks.ts @@ -0,0 +1,302 @@ +import { basename } from "../paths"; +import { announce, signingTabs, stampBooks, type TabStamps } from "../state"; +import type { PdfTabDocument } from "./document"; +import { releaseStamp } from "./stampEngine"; +import type { Fill } from "./ipc"; +import { + bookOf, + changed, + changedSheet, + EMPTY_BOOK, + fitOnPage, + onPages, + redone, + type Sheet, + type Stamp, + type StampBook, + undone, +} from "./stampModel"; +import { stampKey, type StampSpec } from "./stamps"; +import { SIGNING } from "./toolRules"; + +// The stamps of each PDF tab, the fills of its form and the found places +// dismissed, with their one history (`stampBooks`), changed only here: +// placing, moving, removing, filling in, dismissing, undo and redo, +// forgetting a closed tab's, and restoring what storage kept of a tab's. +// The engines of stamps no tab shows any more are freed. + +/** + * stampsOf returns the stamps of the tab `tab` + */ +export const stampsOf = (tab: string): readonly Stamp[] => + stampBooks.value.get(tab)?.book.stamps ?? []; + +/** + * fillsOf returns the fills of the form of the tab `tab` + */ +export const fillsOf = (tab: string): readonly Fill[] => + stampBooks.value.get(tab)?.book.fills ?? []; + +/** + * dismissedOf returns the found places of the tab `tab` the user dismissed + */ +export const dismissedOf = (tab: string): readonly string[] => + stampBooks.value.get(tab)?.book.dismissed ?? []; + +/** + * hasChanges tells whether the tab `tab` has stamps or fills, which signing + * keeps: what makes it unsaved + */ +export const hasChanges = (tab: string) => { + const book = stampBooks.value.get(tab)?.book; + return !!book && (book.stamps.length > 0 || book.fills.length > 0); +}; + +/** + * holdsAnything tells whether `book` holds anything to keep: stamps, + * fills, dismissed places or a history + */ +const holdsAnything = (book: StampBook) => + book.stamps.length > 0 || + book.fills.length > 0 || + book.dismissed.length > 0 || + book.past.length > 0 || + book.future.length > 0; + +/** + * keysOf returns what tells apart the looks of the stamps of `books` + */ +const keysOf = (books: ReadonlyMap) => + new Set( + [...books.values()].flatMap(({ book }) => + // what undo and redo may bring back keeps its engine too + [book, ...book.past, ...book.future].flatMap((sheet: Sheet) => + sheet.stamps.map((stamp) => stampKey(stamp.spec)), + ), + ), + ); + +/** + * isHeld tells whether a stamp of a tab, or one its history may bring back, + * looks like `spec`, and so keeps its engine + */ +export const isHeld = (spec: StampSpec) => + keysOf(stampBooks.value).has(stampKey(spec)); + +/** + * busy tells whether Blank is signing the PDF of `tab`, which its stamps, + * fills and places wait for, and says so + */ +const busy = (tab: string) => { + if (!signingTabs.value.has(tab)) return false; + announce(SIGNING); + return true; +}; + +/** + * write makes `next` the stamps of `tab` (none for null), and frees the + * engines of the looks no stamp has any more + */ +const write = (tab: string, next: TabStamps | null) => { + const before = stampBooks.value; + const books = new Map(before); + if (next && holdsAnything(next.book)) books.set(tab, next); + else books.delete(tab); + stampBooks.value = books; + const kept = keysOf(books); + const gone = new Map(); + for (const { book } of before.values()) + for (const sheet of [book, ...book.past, ...book.future]) + for (const stamp of sheet.stamps) + if (!kept.has(stampKey(stamp.spec))) + gone.set(stampKey(stamp.spec), stamp.spec); + for (const spec of gone.values()) void releaseStamp(spec).catch(() => {}); +}; + +/** + * changeStamps makes `stamps` the stamps of `tab`, as one step of its + * history, or part of the last one for a nudge right after one of the same + * stamp + * @param sha256 the PDF's they're on, if it's known + */ +export const changeStamps = ( + tab: string, + stamps: readonly Stamp[], + { + nudge, + sha256, + }: { nudge?: { id: string; at: number }; sha256?: string | null } = {}, +) => { + if (busy(tab)) return; + const now = stampBooks.value.get(tab); + const book = changed(now?.book ?? EMPTY_BOOK, stamps, nudge); + if (book === now?.book) return; + write(tab, { book, sha256: sha256 ?? now?.sha256 ?? null }); +}; + +/** + * changeSheet makes `part` (the fills, the dismissed places, the stamps) + * that of `tab`, as one step of its history + */ +export const changeSheet = ( + tab: string, + part: Partial, + { sha256 }: { sha256?: string | null } = {}, +) => { + if (busy(tab)) return; + const now = stampBooks.value.get(tab); + const book = changedSheet(now?.book ?? EMPTY_BOOK, part); + if (book === now?.book) return; + write(tab, { book, sha256: sha256 ?? now?.sha256 ?? null }); +}; + +/** + * clearSheet drops the stamps, fills and dismissed places of `tab` with + * their history, e.g. once it was signed: nothing is unsaved then + */ +export const clearSheet = (tab: string) => { + if (stampBooks.value.has(tab)) write(tab, null); +}; + +/** + * returnTo makes `kept` what `tab` holds again, as it was, with its history, + * e.g. when the Sign dialog that changed it was cancelled + */ +export const returnTo = (tab: string, kept: TabStamps | undefined) => { + if (stampBooks.value.get(tab) !== kept) write(tab, kept ?? null); +}; + +/** + * canUndoStamps tells whether `tab` has a step to undo + */ +export const canUndoStamps = (tab: string | null) => + tab !== null && (stampBooks.value.get(tab)?.book.past.length ?? 0) > 0; + +/** + * canRedoStamps tells whether `tab` has a step to redo + */ +export const canRedoStamps = (tab: string | null) => + tab !== null && (stampBooks.value.get(tab)?.book.future.length ?? 0) > 0; + +/** + * undoStamps takes the last step of `tab` back; while Blank signs its PDF, + * it says so + * @returns whether there was one + */ +export const undoStamps = (tab: string) => { + const now = stampBooks.value.get(tab); + if (!now || !now.book.past.length) return false; + if (busy(tab)) return true; + write(tab, { ...now, book: undone(now.book) }); + return true; +}; + +/** + * redoStamps does the step of `tab` undone last again; while Blank signs + * its PDF, it says so + * @returns whether there was one + */ +export const redoStamps = (tab: string) => { + const now = stampBooks.value.get(tab); + if (!now || !now.book.future.length) return false; + if (busy(tab)) return true; + write(tab, { ...now, book: redone(now.book) }); + return true; +}; + +/** + * forgetStamps drops the stamps of `tab`, e.g. once it closed + */ +export const forgetStamps = (tab: string) => { + restored.add(tab); + if (stampBooks.value.has(tab)) write(tab, null); +}; + +// what storage kept of a tab's stamps, fills and dismissed places +// (`stamps:`) +export interface StoredStamps { + sha256: string | null; + stamps: Stamp[]; + fills: Fill[]; + dismissed: string[]; +} + +// the tabs whose stamps were restored, or which had none to restore, this +// session +const restored = new Set(); + +/** + * fitted returns what `kept` becomes on the PDF `doc` opened, and what it + * lost: on the file they were placed on (or one not known yet), as they + * are; on a file that changed, without the stamps on pages it no longer has, + * the others moved (and made smaller) onto their pages where those are + * smaller now (fitOnPage), and without the fills, the dismissed places and the history, whose fields + * and lines may be others now; on a file that went or can't be opened, as + * they are + */ +const fitted = ( + doc: PdfTabDocument, + kept: TabStamps, +): { kept: TabStamps; lost: string[] } => { + const opened = doc.opened; + if (!opened || kept.sha256 === opened.sha256) return { kept, lost: [] }; + if (kept.sha256 === null) + return { kept: { ...kept, sha256: opened.sha256 }, lost: [] }; + const { stamps, fills } = kept.book; + const left = onPages(stamps, opened.pages.length).map((stamp) => + fitOnPage(stamp, opened.pages[stamp.page]), + ); + const dropped = stamps.length - left.length; + return { + kept: { book: bookOf(left), sha256: opened.sha256 }, + lost: [ + dropped > 0 + ? `${dropped === 1 ? "1 stamp was" : `${dropped} stamps were`} on pages it no longer has` + : "", + fills.length > 0 ? "its form is empty again" : "", + ].filter(Boolean), + }; +}; + +/** + * restoreStamps runs on every open of the PDF `doc` of `tab`: the first + * time, it brings back the stamps, fills and dismissed places `stored` of + * it, unless some were placed meanwhile, which count more; then it makes + * what the tab holds fit the file opened (see fitted), also on Open again + * or Find it…, which may open another file, and says what that lost + */ +export const restoreStamps = ( + tab: string, + doc: PdfTabDocument, + stored: StoredStamps | null, +) => { + const first = !restored.has(tab); + restored.add(tab); + const now = stampBooks.value.get(tab); + const kept = + now ?? + (first && + stored && + (stored.stamps.length || stored.fills.length || stored.dismissed.length) + ? { + book: bookOf(stored.stamps, stored.fills, stored.dismissed), + sha256: stored.sha256, + } + : null); + if (!kept) return; + const fit = fitted(doc, kept); + if (fit.kept !== now) write(tab, fit.kept); + if (fit.lost.length) + announce( + `${basename(doc.path)} changed since you last had it open; ${fit.lost.join(", and ")}`, + ); +}; + +/** + * resetStampBooks forgets every tab's stamps and what was restored, for the + * tests + */ +export const resetStampBooks = () => { + restored.clear(); + stampBooks.value = new Map(); +}; diff --git a/src/pdf/stampEngine.test.ts b/src/pdf/stampEngine.test.ts new file mode 100644 index 00000000..e578b67b --- /dev/null +++ b/src/pdf/stampEngine.test.ts @@ -0,0 +1,186 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { forgetEngineFailure, useFallbackEditor } from "../engine/engine"; +import { savedMarks } from "../state"; +import { IMAGES } from "../test/images"; +import { paintCalls } from "../test/canvas"; +import { testEngine } from "../test/engine"; +import { naturalSize, paintStamp, releaseStamp, stampPdf } from "./stampEngine"; +import { type StampSpec, STAMPS_NEED_LAYOUT } from "./stamps"; + +// The stamps as Blank's engine lays them out: their own size, what is +// painted of them, and their one-page PDFs. + +const has = (tool: string) => { + try { + execFileSync(tool, ["-v"], { stdio: "ignore" }); + return true; + } catch { + if (process.env.CI) + throw new Error( + `${tool} is missing: install poppler, CI doesn't skip this test`, + ); + return false; + } +}; + +const dir = mkdtempSync(join(tmpdir(), "blank-stamps-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const pdfInfo = (contents: Uint8Array, name: string) => { + const file = join(dir, name); + writeFileSync(file, contents); + return { + info: execFileSync("pdfinfo", [file], { encoding: "utf8" }), + text: execFileSync("pdftotext", [file, "-"], { encoding: "utf8" }), + }; +}; + +const date: StampSpec = { + lines: ["12 October 2026"], + size: 11, + align: "left", +}; + +const initials: StampSpec = { + image: "initials", + lines: [], + size: 11, + align: "center", +}; + +beforeEach(() => { + testEngine(); + savedMarks.value = { + signature: null, + initials: { + source: { kind: "image" }, + png: `data:image/png;base64,${IMAGES.png}`, + width: 60, + height: 30, + }, + }; +}); + +afterEach(async () => { + // every look the tests lay out + for (const spec of [ + date, + initials, + { ...date, lines: ["12 October 2026", "in Berlin"] }, + ]) + await releaseStamp(spec); + savedMarks.value = undefined; + forgetEngineFailure(); +}); + +describe("a stamp's natural size", () => { + it("is as wide as its widest line and as high as its lines", async () => { + const one = await naturalSize(date); + expect(one.width).toBeGreaterThan(40); + expect(one.width).toBeLessThan(150); + expect(one.height).toBeCloseTo(11 * 1.3 * 1.12, 1); + const two = await naturalSize({ + ...date, + lines: ["12 October 2026", "in Berlin"], + }); + expect(two.width).toBe(one.width); + expect(two.height).toBeCloseTo(2 * one.height, 1); + // larger letters, a larger stamp + const larger = await naturalSize({ ...date, size: 22 }); + expect(larger.width).toBeCloseTo(2 * one.width, 0); + }); + + it("is its image's as large as fits on the page it's laid out on", async () => { + expect(await naturalSize(initials)).toEqual({ width: 400, height: 200 }); + }); +}); + +describe("a stamp's PDF", () => { + it("is one page of the size asked, holding its lines", async () => { + if (!has("pdfinfo")) return; + const { width, height } = await naturalSize(date); + const pdf = await stampPdf(date, width, height); + const { info, text } = pdfInfo(pdf, "date.pdf"); + expect(info).toMatch(/^Pages:\s+1$/m); + const [, w, h] = /^Page size:\s+([\d.]+) x ([\d.]+) pts/m.exec(info)!; + expect(Number(w)).toBeCloseTo(width, 1); + expect(Number(h)).toBeCloseTo(height, 1); + // the line unbroken in a box of its natural width + expect(text.trim()).toBe("12 October 2026"); + }); + + it("holds the initials' image", async () => { + if (!has("pdfimages")) return; + const pdf = await stampPdf(initials, 56, 28); + const file = join(dir, "initials.pdf"); + writeFileSync(file, pdf); + const images = execFileSync("pdfimages", ["-list", file], { + encoding: "utf8", + }); + expect(images.split("\n").slice(2).filter(Boolean)).toHaveLength(1); + expect(pdfInfo(pdf, "initials.pdf").info).toMatch( + /^Page size:\s+56 x 28 pts/m, + ); + }); + + it("isn't written without the initials it stamps", async () => { + savedMarks.value = { signature: null, initials: null }; + await expect(stampPdf(initials, 56, 28)).rejects.toThrow(/initials/); + }); + + it("follows the stamp's size, on the one engine of what it shows", async () => { + if (!has("pdfinfo")) return; + await stampPdf(date, 100, 20); + const pdf = await stampPdf({ ...date, size: 5.5 }, 50, 10); + expect(pdfInfo(pdf, "small.pdf").info).toMatch( + /^Page size:\s+50 x 10 pts/m, + ); + }); +}); + +describe("painting a stamp", () => { + it("paints its lines in the ink of paper, at the canvas' scale", async () => { + const canvas = document.createElement("canvas"); + const painted = await paintStamp( + date, + canvas, + { width: 100, height: 20 }, + 2, + ); + expect(painted).toBe(true); + expect(canvas.width).toBe(200 * (window.devicePixelRatio || 1)); + const calls = paintCalls(canvas); + const glyphs = calls.filter((call) => call[0] === "fill"); + // a glyph for each character, the spaces' too + expect(glyphs).toHaveLength("12 October 2026".length); + expect(calls).toContainEqual(["style", "#000000", 1]); + }); + + it("says so while its image hasn't loaded", async () => { + const canvas = document.createElement("canvas"); + // jsdom never loads images + expect( + await paintStamp(initials, canvas, { width: 56, height: 28 }, 1), + ).toBe(false); + }); + + it("lays it out again on a new engine once its engine was released", async () => { + await naturalSize(date); + await releaseStamp(date); + expect((await naturalSize(date)).width).toBeGreaterThan(40); + }); +}); + +describe("stamps without the page layout", () => { + it("can't be laid out", async () => { + useFallbackEditor("unavailable"); + await expect(naturalSize(date)).rejects.toThrow(STAMPS_NEED_LAYOUT); + await expect(stampPdf(date, 10, 10)).rejects.toThrow(STAMPS_NEED_LAYOUT); + }); +}); diff --git a/src/pdf/stampEngine.ts b/src/pdf/stampEngine.ts new file mode 100644 index 00000000..f62c3a00 --- /dev/null +++ b/src/pdf/stampEngine.ts @@ -0,0 +1,214 @@ +import { engineless, newEngine, type PageEngine } from "../engine/engine"; +import { fallbackFonts, findFonts } from "../engine/fallback"; +import { loadedImage } from "../engine/images"; +import { pdfDate } from "../engine/pdf"; +import { imageKind } from "../engine/pdfJob"; +import { fromDataUrl } from "../images/dataUrl"; +import type { Mark } from "../signing/profile"; +import { language, savedMarks } from "../state"; +import { sizeCanvas } from "../ui/pageLayer"; +import { paintDisplay, PAPER_COLORS } from "../ui/painter/canvas2d"; +import { + MARK_SRC, + stampItem, + stampKey, + stampSettings, + type StampSpec, + STAMPS_NEED_LAYOUT, +} from "./stamps"; + +// The stamps' engines: one for each stamp that looks different, sharing the +// page view's fonts (`newEngine`), with the user's signature and initials +// added as images. Each lays its stamp out on a page that is the stamp's +// box, which is painted on screen from its display list and written as the +// stamp's one-page PDF. + +// the page a stamp is laid out on to find its own size, in points +export const NATURAL_PAGE = { width: 600, height: 200 }; + +interface StampEngine { + engine: PageEngine; + // the PNG of each mark added to it, as its data URL + marks: Map; + // the settings and items it laid out last, as JSON + content: string; + // the work on it, one task after the other, since a task may wait for + // fonts between laying out and reading what it laid out + queue: Promise; +} + +// by stampKey +const engines = new Map>(); + +/** + * engineOf returns the engine of the stamp `spec` is, made if there's none + */ +const engineOf = (spec: StampSpec) => { + if (engineless()) throw new Error(STAMPS_NEED_LAYOUT); + const key = stampKey(spec); + let made = engines.get(key); + if (!made) { + made = newEngine(true).then((engine) => ({ + engine, + marks: new Map(), + content: "", + queue: Promise.resolve(), + })); + engines.set(key, made); + // the next stamp tries again + made.catch(() => { + if (engines.get(key) === made) engines.delete(key); + }); + } + return made; +}; + +/** + * onStamp runs `task` on the engine of `spec`, once it laid the stamp out + * in a box of `width` × `height` points; an engine that failed is given up, + * so the next stamp gets a new one + */ +const onStamp = async ( + spec: StampSpec, + width: number, + height: number, + task: (engine: PageEngine) => T, +): Promise => { + const made = engineOf(spec); + const stamp = await made; + const run = stamp.queue.then(async () => { + const { engine } = stamp; + if (engine.broken) throw new Error("the page layout failed before"); + const mark = spec.image ? (savedMarks.value?.[spec.image] ?? null) : null; + if (spec.image && mark && stamp.marks.get(spec.image) !== mark.png) { + const decoded = fromDataUrl(mark.png); + if (decoded) { + // the user's marks are PNGs (src/signing/profile.ts) + engine.addImage( + MARK_SRC[spec.image], + decoded.bytes, + imageKind("image/png"), + ); + stamp.marks.set(spec.image, mark.png); + } + } + const settings = stampSettings(width, height); + const items = [stampItem(spec, mark)]; + const content = JSON.stringify([settings, items]); + if (content !== stamp.content) { + engine.setContent(settings, items); + stamp.content = content; + // fonts for what Blank's fonts lack, e.g. a place in Chinese + const missing = engine.missing(); + if (missing) { + await findFonts(missing, language.value); + engine.addFonts(fallbackFonts.value); + } + } + return task(engine); + }); + stamp.queue = run.catch(() => undefined); + try { + return await run; + } catch (error) { + if (stamp.engine.broken && engines.get(stampKey(spec)) === made) { + engines.delete(stampKey(spec)); + stamp.engine.free(); + } + throw error; + } +}; + +/** + * naturalSize returns the size of a stamp as it lays itself out, in points: + * its lines as wide as the widest, or its image as large as fits on + * NATURAL_PAGE + */ +export const naturalSize = (spec: StampSpec) => + onStamp(spec, NATURAL_PAGE.width, NATURAL_PAGE.height, (engine) => { + const box = engine.boxes(0, 1)[0]; + // up to a hundredth of a point, so a box of that size never breaks its + // widest line + const up = (value: number) => Math.ceil(value * 100) / 100; + return box + ? { width: up(box.width), height: up(box.height) } + : { width: 0, height: 0 }; + }); + +/** + * paintStamp paints a stamp of `box` (its size in points) into `canvas`, at + * `scale` CSS pixels per point and the screen's pixel ratio, in the colours + * of paper, since a PDF's page is white, over nothing + * @returns whether all of it is painted: false while its image loads, so + * paint again once it did (`imagesLoaded`) + */ +export const paintStamp = ( + spec: StampSpec, + canvas: HTMLCanvasElement, + box: { width: number; height: number }, + scale: number, +) => + onStamp(spec, box.width, box.height, (engine) => { + const ratio = window.devicePixelRatio || 1; + sizeCanvas( + canvas, + Math.max(1, Math.round(box.width * scale * ratio)), + Math.max(1, Math.round(box.height * scale * ratio)), + ratio, + ); + const context = canvas.getContext("2d"); + if (!context) return false; + let complete = true; + paintDisplay( + context, + engine.printDisplay(0), + { scale, ratio, x: 0, y: 0, colors: PAPER_COLORS }, + engine, + (src) => { + const mark = (Object.keys(MARK_SRC) as Mark[]).find( + (name) => MARK_SRC[name] === src, + ); + const png = mark && savedMarks.value?.[mark]?.png; + const loaded = png ? loadedImage(png, null) : null; + if (!loaded) complete = false; + return loaded?.image ?? null; + }, + ); + return complete; + }); + +/** + * stampPdf writes a stamp as a PDF of one page, `width` × `height` points, + * a PDF/A-2u with subsets of its fonts, which the signer copies into the + * PDF it signs; it throws when the stamp's signature or initials aren't + * saved, or the layout fails + */ +export const stampPdf = async ( + spec: StampSpec, + width: number, + height: number, +): Promise => { + if (spec.image && !savedMarks.value?.[spec.image]) + throw new Error(`Your ${spec.image} isn't saved, so it can't be stamped`); + return onStamp(spec, width, height, (engine) => { + const pdf = engine.pdf("", "", undefined, pdfDate()); + if (pdf.length === 0) throw new Error("the page layout wrote no PDF"); + return pdf; + }); +}; + +/** + * releaseStamp frees the engine of a stamp once it's gone, e.g. removed or + * its tab closed; a stamp that looks the same gets a new one when it's + * painted again + */ +export const releaseStamp = async (spec: StampSpec) => { + const key = stampKey(spec); + const made = engines.get(key); + if (!made) return; + engines.delete(key); + const stamp = await made.catch(() => null); + if (!stamp) return; + await stamp.queue; + stamp.engine.free(); +}; diff --git a/src/pdf/stampModel.test.ts b/src/pdf/stampModel.test.ts new file mode 100644 index 00000000..ecf1301f --- /dev/null +++ b/src/pdf/stampModel.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from "vitest"; + +import { + bookOf, + changed, + clampRect, + defaultSpot, + EMPTY_BOOK, + fitInto, + ghostsOf, + HISTORY, + isStamp, + markSize, + moveTo, + nudge, + onPages, + redone, + resizeBy, + resizeTo, + scaledSpec, + spotAt, + type Stamp, + stepChange, + stampLabel, + stampSpec, + toggleRepeat, + undone, + withoutStamp, + withStamp, +} from "./stampModel"; +import { readingOrder } from "./places"; + +const A4 = { width: 595, height: 842 }; +const A5 = { width: 420, height: 595 }; + +const stamp = (over: Partial = {}): Stamp => ({ + id: "s1", + kind: "signature", + page: 0, + rect: [100, 100, 180, 60], + natural: [180, 60], + spec: stampSpec("signature"), + ...over, +}); + +describe("placing and moving", () => { + it("keeps at least 10 pt of a stamp on its page", () => { + expect(clampRect([-500, 900, 180, 60], A4)).toEqual([-170, 832, 180, 60]); + expect(clampRect([590, -100, 180, 60], A4)).toEqual([585, -50, 180, 60]); + expect(clampRect([100, 100, 180, 60], A4)).toEqual([100, 100, 180, 60]); + }); + + it("nudges by the points asked, kept on the page", () => { + expect(nudge(stamp(), 1, 0, A4).rect).toEqual([101, 100, 180, 60]); + expect(nudge(stamp(), 0, -10, A4).rect).toEqual([100, 90, 180, 60]); + expect(nudge(stamp(), -1000, 0, A4).rect[0]).toBe(-170); + }); + + it("moves a stamp's top left to a point", () => { + expect(moveTo(stamp(), 20, 30, A4).rect).toEqual([20, 30, 180, 60]); + }); + + it("centres a stamp placed by a click on the click", () => { + expect(spotAt([180, 60], 300, 400, A4)).toEqual([210, 370, 180, 60]); + }); + + it("adds, replaces and removes stamps by id", () => { + const one = stamp(); + const two = stamp({ id: "s2" }); + expect(withStamp([one], two)).toEqual([one, two]); + const moved = { ...one, rect: [0, 0, 180, 60] as Stamp["rect"] }; + expect(withStamp([one, two], moved)).toEqual([moved, two]); + expect(withoutStamp([one, two], "s1")).toEqual([two]); + }); + + it("puts stamps in reading order", () => { + const low = stamp({ id: "low", rect: [10, 500, 10, 10] }); + const right = stamp({ id: "right", rect: [300, 100, 10, 10] }); + const left = stamp({ id: "left", rect: [10, 100, 10, 10] }); + const next = stamp({ id: "next", page: 1, rect: [0, 0, 10, 10] }); + expect(readingOrder([next, low, right, left]).map((s) => s.id)).toEqual([ + "left", + "right", + "low", + "next", + ]); + }); +}); + +describe("resizing", () => { + it("keeps the aspect and the top left", () => { + expect(resizeBy(stamp(), 10, A4).rect).toEqual([100, 100, 190, 190 / 3]); + expect(resizeBy(stamp(), -1, A4).rect).toEqual([100, 100, 179, 179 / 3]); + }); + + it("stays within 50 % and 300 % of the natural size", () => { + expect(resizeBy(stamp(), -1000, A4).rect).toEqual([100, 100, 90, 30]); + expect(resizeBy(stamp(), 1000, A4).rect).toEqual([100, 100, 540, 180]); + }); + + it("keeps the opposite corner of a handle where it was", () => { + // dragging the top left corner leaves the bottom right + expect(resizeTo(stamp(), 360, A4, "bottom-right").rect).toEqual([ + -80, 40, 360, 120, + ]); + expect(resizeTo(stamp(), 90, A4, "bottom-left").rect).toEqual([ + 100, 130, 90, 30, + ]); + expect(resizeTo(stamp(), 90, A4, "top-right").rect).toEqual([ + 190, 100, 90, 30, + ]); + }); + + it("scales the lines' letters with the stamp", () => { + const date = stamp({ + kind: "date", + spec: stampSpec("date", "12 October 2026"), + natural: [100, 14], + rect: [0, 0, 200, 28], + }); + expect(scaledSpec(date).size).toBe(22); + expect(scaledSpec(date).lines).toEqual(["12 October 2026"]); + }); + + it("contains a signature's picture in its box", () => { + expect(markSize("signature", 600, 100)).toEqual([180, 30]); + expect(markSize("initials", 100, 100)).toEqual([28, 28]); + expect(markSize("signature", 0, 0)).toEqual([180, 60]); + }); +}); + +describe("repeated initials", () => { + const initials = stamp({ + id: "i", + kind: "initials", + rect: [500, 780, 56, 28], + natural: [56, 28], + }); + + it("repeat only initials", () => { + expect(toggleRepeat([initials], "i")[0].repeat).toBe(true); + expect(toggleRepeat(toggleRepeat([initials], "i"), "i")[0].repeat).toBe( + false, + ); + expect(toggleRepeat([stamp()], "s1")[0].repeat).toBeUndefined(); + }); + + it("show on the other pages, as far from the nearer edges", () => { + const ghosts = ghostsOf([{ ...initials, repeat: true }], [A4, A4, A5]); + expect(ghosts.map((ghost) => ghost.page)).toEqual([1, 2]); + expect(ghosts[0].rect).toEqual([500, 780, 56, 28]); + // 39 pt from the right edge and 34 pt from the bottom, as on A4 + expect(ghosts[1].rect).toEqual([325, 533, 56, 28]); + expect(ghostsOf([initials], [A4, A4])).toEqual([]); + }); + + it("fit made smaller into a page smaller than them", () => { + expect( + fitInto( + [0, 0, 200, 100], + { width: 300, height: 300 }, + { width: 100, height: 100 }, + ), + ).toEqual([0, 0, 100, 50]); + }); +}); + +describe("default spots", () => { + const shown = { top: 0, height: 842 }; + + it("puts a signature in the lower third of what shows, centred", () => { + expect(defaultSpot("signature", [180, 60], 0, [A4], shown, [])).toEqual({ + page: 0, + rect: [207.5, 2 * (842 / 3) + (842 / 3 - 60) / 2, 180, 60], + }); + const part = { top: 400, height: 300 }; + expect(defaultSpot("signature", [180, 60], 0, [A4], part, []).rect[1]).toBe( + 400 + 200 + 20, + ); + }); + + it("puts initials at the bottom right", () => { + expect(defaultSpot("initials", [56, 28], 0, [A4], shown, [])).toEqual({ + page: 0, + rect: [595 - 56 - 36, 842 - 28 - 36, 56, 28], + }); + }); + + it("puts a date and a place below the last signature", () => { + const signature = stamp({ page: 1, rect: [50, 600, 180, 60] }); + const date = defaultSpot("date", [90, 14], 0, [A4, A4], shown, [ + stamp({ id: "first" }), + signature, + ]); + expect(date).toEqual({ page: 1, rect: [50, 666, 90, 14] }); + const placed = stamp({ id: "d", kind: "date", ...date }); + const place = defaultSpot("place", [60, 14], 0, [A4, A4], shown, [ + signature, + placed, + ]); + expect(place).toEqual({ page: 1, rect: [50, 686, 60, 14] }); + }); + + it("puts a date like a signature when there's none", () => { + expect(defaultSpot("date", [90, 14], 0, [A4], shown, []).rect[0]).toBe( + (595 - 90) / 2, + ); + }); +}); + +describe("the history", () => { + const one = [stamp()]; + const two = [stamp({ id: "s2" })]; + + it("undoes and redoes whole steps", () => { + const book = changed(changed(EMPTY_BOOK, one), two); + expect(undone(book).stamps).toBe(one); + expect(undone(undone(book)).stamps).toEqual([]); + expect(undone(undone(undone(book))).stamps).toEqual([]); + expect(redone(undone(book)).stamps).toBe(two); + expect(redone(book)).toBe(book); + }); + + it("forgets what was undone once something else changes", () => { + const book = changed(undone(changed(changed(EMPTY_BOOK, one), two)), two); + expect(book.future).toEqual([]); + }); + + it("makes nudges of one stamp in quick succession one step", () => { + let book = changed(EMPTY_BOOK, one); + book = changed(book, [stamp({ rect: [101, 100, 180, 60] })], { + id: "s1", + at: 1000, + }); + book = changed(book, [stamp({ rect: [102, 100, 180, 60] })], { + id: "s1", + at: 1400, + }); + expect(undone(book).stamps).toBe(one); + // a pause starts a new step + book = changed(book, [stamp({ rect: [103, 100, 180, 60] })], { + id: "s1", + at: 2000, + }); + expect(undone(book).stamps[0].rect[0]).toBe(102); + }); + + it("keeps at most a hundred steps", () => { + let book = EMPTY_BOOK; + for (let i = 0; i < HISTORY + 20; i++) book = changed(book, [stamp()]); + expect(book.past.length).toBe(HISTORY); + }); + + it("starts restored stamps with no history", () => { + expect(bookOf(one)).toEqual({ ...EMPTY_BOOK, stamps: one }); + }); +}); + +describe("what a step of the history changed", () => { + const sheet = (stamps: Stamp[], dismissed: string[] = [], fills = []) => ({ + stamps, + dismissed, + fills, + }); + + it("names the stamp that came back, went, moved or changed size", () => { + expect(stepChange(sheet([]), sheet([stamp()]), false)).toEqual({ + message: "Signature back on page 1", + stamp: "s1", + }); + expect(stepChange(sheet([stamp()]), sheet([]), true)).toEqual({ + message: "Signature removed", + }); + const moved = stamp({ rect: [120, 100, 180, 60] }); + expect(stepChange(sheet([moved]), sheet([stamp()]), true).message).toBe( + "Signature moved", + ); + const bigger = stamp({ rect: [100, 100, 200, 66] }); + expect(stepChange(sheet([bigger]), sheet([stamp()]), true).message).toBe( + "Signature resized", + ); + const initials = stamp({ id: "i", kind: "initials" }); + expect( + stepChange( + sheet([initials]), + sheet([{ ...initials, repeat: true }]), + false, + ).message, + ).toBe("Initials on every page"); + }); + + it("gives a found place that shows again the focus", () => { + expect(stepChange(sheet([], ["found:0:1:2"]), sheet([]), true)).toEqual({ + message: "Place back", + place: "found:0:1:2", + }); + expect( + stepChange(sheet([]), sheet([], ["found:0:1:2"]), false).message, + ).toBe("Place dismissed"); + }); + + it("says a form entry was undone or redone, and what else", () => { + const filled = { ...sheet([]), fills: [{ field: "name", value: "Ada" }] }; + expect(stepChange(filled, sheet([]), true).message).toBe( + "Form entry undone", + ); + expect(stepChange(sheet([]), filled, false).message).toBe( + "Form entry redone", + ); + expect(stepChange(sheet([]), sheet([]), true).message).toBe("Undone"); + }); +}); + +describe("what is stored", () => { + it("keeps the stamps on the pages a PDF has", () => { + expect(onPages([stamp(), stamp({ id: "far", page: 3 })], 2)).toEqual([ + stamp(), + ]); + }); + + it("tells a stamp from what isn't one", () => { + expect(isStamp(JSON.parse(JSON.stringify(stamp())))).toBe(true); + expect(isStamp({ ...stamp(), kind: "seal" })).toBe(false); + expect(isStamp({ ...stamp(), rect: [1, 2, 3] })).toBe(false); + expect(isStamp(null)).toBe(false); + }); + + it("names a stamp for screen readers", () => { + expect(stampLabel(stamp({ page: 1 }), true)).toBe( + "Signature on page 2, selected", + ); + expect(stampLabel(stamp({ kind: "initials", repeat: true }), false)).toBe( + "Initials on page 1 and every other page", + ); + }); +}); diff --git a/src/pdf/stampModel.ts b/src/pdf/stampModel.ts new file mode 100644 index 00000000..3eee60be --- /dev/null +++ b/src/pdf/stampModel.ts @@ -0,0 +1,609 @@ +import type { PageSize } from "../engine/frames"; +import { + INITIALS_BOX, + INITIALS_STAMP, + initialsRect, +} from "../signing/initials"; +import type { Mark } from "../signing/profile"; +import type { Fill, PdfRect as Rect } from "./ipc"; +import { readingOrder } from "./places"; +import { LINE_SIZE, type StampSpec } from "./stamps"; + +// The stamps placed on a PDF's pages, and what the keys and the mouse do to +// them (see .claude/rules/pdf.md, "Stamps"): place, move, nudge, resize with +// the aspect kept, remove, repeat initials on every page, and an undo +// history of whole snapshots. Pure: the shown tab's stamps are kept by +// stampBooks.ts, painted by StampOverlay.vue. Rects are display points, `[x, +// y, width, height]` from the top left of the page as it shows. + +export type StampKind = "signature" | "initials" | "date" | "place"; + +export const STAMP_KINDS: readonly StampKind[] = [ + "signature", + "initials", + "date", + "place", +]; + +export interface Stamp { + readonly id: string; + readonly kind: StampKind; + // the page, from 0 + readonly page: number; + readonly rect: Rect; + // the size it was placed at, which a resize keeps 50–300 % of and whose + // shape it keeps + readonly natural: readonly [number, number]; + // initials on every page, at the same spot (clamped into smaller pages) + readonly repeat?: boolean; + // the found place or field it fills, if it was placed on one + readonly place?: string; + // what it shows, at its natural size (`scaledSpec` for its size now) + readonly spec: StampSpec; +} + +// how much of a stamp stays on its page, at least, in points +export const MIN_ON_PAGE = 10; +// a resize's bounds, as shares of the natural size +export const MIN_SCALE = 0.5; +export const MAX_SCALE = 3; +// how far the keys move or resize, in points, and with Shift +export const STEP = 1; +export const BIG_STEP = 10; +// the room between a signature and the date or place below it, in points +export const GAP = 6; +// the box a signature or initials is contained in when placed freely +export const MARK_BOX: Record = { + signature: [180, 60], + initials: [INITIALS_BOX.width, INITIALS_BOX.height], +}; + +// what a stamp is called, for the screen reader and the announcements +export const STAMP_NAMES: Record = { + signature: "Signature", + initials: "Initials", + date: "Date", + place: "Place", +}; + +/** + * stampSpec returns what a stamp of `kind` shows: the user's signature or + * initials, or one line of text (the date, the place) + */ +export const stampSpec = (kind: StampKind, line = ""): StampSpec => + kind === "initials" + ? { ...INITIALS_STAMP } + : kind === "signature" + ? { image: kind, lines: [], size: LINE_SIZE, align: "center" } + : { lines: [line], size: LINE_SIZE, align: "left" }; + +/** + * markSize returns the size of a signature or initials placed freely: its + * picture's shape (`width` × `height` pixels) contained in its MARK_BOX + */ +export const markSize = ( + mark: Mark, + width: number, + height: number, +): [number, number] => { + const [boxWidth, boxHeight] = MARK_BOX[mark]; + if (!(width > 0 && height > 0)) return [boxWidth, boxHeight]; + const scale = Math.min(boxWidth / width, boxHeight / height); + const round = (value: number) => Math.round(value * 100) / 100; + return [round(width * scale), round(height * scale)]; +}; + +/** + * scaleOf returns how large `stamp` is now, as a share of its natural size + */ +export const scaleOf = (stamp: Stamp) => + stamp.natural[0] > 0 ? stamp.rect[2] / stamp.natural[0] : 1; + +/** + * scaledSpec returns what `stamp` shows at its size now: its letters as + * much larger as it is + */ +export const scaledSpec = (stamp: Stamp): StampSpec => ({ + ...stamp.spec, + size: stamp.spec.size * scaleOf(stamp), +}); + +/** + * clampRect moves `rect` so at least MIN_ON_PAGE points of it stay on a + * page of `page`, both ways + */ +export const clampRect = (rect: Rect, page: PageSize): Rect => { + const [x, y, w, h] = rect; + const keep = (at: number, size: number, room: number) => { + const least = Math.min(MIN_ON_PAGE, size, room); + return Math.min(Math.max(at, least - size), room - least); + }; + return [keep(x, w, page.width), keep(y, h, page.height), w, h]; +}; + +/** + * fitInto returns where `rect`, placed on a page of `from`, goes on a page of + * `to`, as a repeated stamp is drawn there (`repeat_into` in + * src-tauri/src/signing/pdf/stamp.rs): made smaller, its shape kept, if it + * doesn't fit; as far from the nearer of the left and right edges, and of + * the top and bottom ones, as on its own page (a tie goes left and up); and + * never off the page + */ +export const fitInto = (rect: Rect, from: PageSize, to: PageSize): Rect => { + const [x, y, w, h] = rect; + const scale = Math.min(to.width / w, to.height / h, 1); + const [fittedW, fittedH] = [w * scale, h * scale]; + // along one axis: its start and size on its own page, into the other + const along = ( + start: number, + size: number, + page: number, + into: number, + fitted: number, + ) => { + const far = page - start - size; + const at = start <= far ? start : into - far - fitted; + return Math.max(0, Math.min(at, into - fitted)); + }; + return [ + along(x, w, from.width, to.width, fittedW), + along(y, h, from.height, to.height, fittedH), + fittedW, + fittedH, + ]; +}; + +/** + * moveTo returns `stamp` with its top left at `x`, `y`, kept on its page + */ +export const moveTo = ( + stamp: Stamp, + x: number, + y: number, + page: PageSize, +): Stamp => ({ + ...stamp, + rect: clampRect([x, y, stamp.rect[2], stamp.rect[3]], page), +}); + +/** + * nudge returns `stamp` moved `dx`, `dy` points, kept on its page + */ +export const nudge = ( + stamp: Stamp, + dx: number, + dy: number, + page: PageSize, +): Stamp => moveTo(stamp, stamp.rect[0] + dx, stamp.rect[1] + dy, page); + +// a corner of a stamp: the one a resize leaves where it is is the opposite +export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; + +/** + * resizeTo returns `stamp` `width` points wide, its shape kept, within 50 % + * and 300 % of its natural size, with the corner `fixed` where it was + */ +export const resizeTo = ( + stamp: Stamp, + width: number, + page: PageSize, + fixed: Corner = "top-left", +): Stamp => { + const [naturalWidth, naturalHeight] = stamp.natural; + const next = Math.min( + naturalWidth * MAX_SCALE, + Math.max(naturalWidth * MIN_SCALE, width), + ); + const height = naturalWidth > 0 ? (next * naturalHeight) / naturalWidth : 0; + const [x, y, w, h] = stamp.rect; + const left = fixed.endsWith("left") ? x : x + w - next; + const top = fixed.startsWith("top") ? y : y + h - height; + return { ...stamp, rect: clampRect([left, top, next, height], page) }; +}; + +/** + * resizeBy returns `stamp` `by` points wider (narrower for a negative + * `by`), its top left where it was + */ +export const resizeBy = (stamp: Stamp, by: number, page: PageSize): Stamp => + resizeTo(stamp, stamp.rect[2] + by, page); + +/** + * withStamp returns `stamps` with `stamp` in place of the one of its id, or + * added at the end + */ +export const withStamp = ( + stamps: readonly Stamp[], + stamp: Stamp, +): readonly Stamp[] => + stamps.some((one) => one.id === stamp.id) + ? stamps.map((one) => (one.id === stamp.id ? stamp : one)) + : [...stamps, stamp]; + +/** + * withoutStamp returns `stamps` without the one `id` + */ +export const withoutStamp = (stamps: readonly Stamp[], id: string) => + stamps.filter((stamp) => stamp.id !== id); + +/** + * toggleRepeat returns `stamps` with the initials `id` on every page, or + * only on their own again; other stamps don't repeat + */ +export const toggleRepeat = ( + stamps: readonly Stamp[], + id: string, +): readonly Stamp[] => + stamps.map((stamp) => + stamp.id === id && stamp.kind === "initials" + ? { ...stamp, repeat: !stamp.repeat } + : stamp, + ); + +// a repeated stamp as it shows on a page other than its own +export interface Ghost { + stamp: Stamp; + page: number; + rect: Rect; +} + +/** + * ghostsOf returns the repeated initials of `stamps` on the pages of + * `sizes` other than their own, fitted into smaller pages + */ +export const ghostsOf = ( + stamps: readonly Stamp[], + sizes: readonly PageSize[], +): Ghost[] => + stamps + .filter((stamp) => stamp.repeat && stamp.kind === "initials") + .flatMap((stamp) => + sizes.flatMap((size, page) => + page === stamp.page + ? [] + : [ + { + stamp, + page, + rect: fitInto(stamp.rect, sizes[stamp.page] ?? size, size), + }, + ], + ), + ); + +// the part of a page that shows, in points from its top: where a stamp +// placed from the keyboard goes +export interface Shown { + top: number; + height: number; +} + +/** + * defaultSpot returns where a stamp of `kind`, `size` points large, goes on + * the page `page` of `sizes` when it's placed from the keyboard: a + * signature centred in the lower third of what shows of the page; initials + * in its bottom right corner, as on every page of a signed export; a date or place below the last signature + * stamp (and what was placed below it already), else as a signature + */ +export const defaultSpot = ( + kind: StampKind, + size: readonly [number, number], + page: number, + sizes: readonly PageSize[], + shown: Shown, + stamps: readonly Stamp[], +): { page: number; rect: Rect } => { + const [w, h] = size; + const on = (at: number, rect: Rect) => ({ + page: at, + rect: clampRect(rect, sizes[at]), + }); + const pageSize = sizes[page]; + if (kind === "initials") + return on(page, initialsRect(pageSize, "right", [w, h])); + const signature = [...stamps] + .reverse() + .find((stamp) => stamp.kind === "signature"); + if (kind !== "signature" && signature) { + // what stands below it already: dates and places under its left edge + let bottom = signature.rect[1] + signature.rect[3]; + for (const stamp of readingOrder(stamps)) { + if ( + stamp.page === signature.page && + (stamp.kind === "date" || stamp.kind === "place") && + Math.abs(stamp.rect[0] - signature.rect[0]) < 1 && + stamp.rect[1] >= bottom - 1 && + stamp.rect[1] <= bottom + GAP + 1 + ) + bottom = stamp.rect[1] + stamp.rect[3]; + } + return on(signature.page, [signature.rect[0], bottom + GAP, w, h]); + } + const third = shown.height / 3; + return on(page, [ + (pageSize.width - w) / 2, + shown.top + 2 * third + (third - h) / 2, + w, + h, + ]); +}; + +/** + * spotAt returns where a stamp `size` points large goes when it's placed + * with a click at `x`, `y` on a page of `page`: centred on the click + */ +export const spotAt = ( + size: readonly [number, number], + x: number, + y: number, + page: PageSize, +): Rect => + clampRect([x - size[0] / 2, y - size[1] / 2, size[0], size[1]], page); + +// The undo history of a tab's stamps, the fills of its form and the found +// places the user dismissed: snapshots of all three, at most HISTORY deep, +// so one undo steps back through whichever changed + +export const HISTORY = 100; +// nudges of one stamp this close together are one step, in ms +export const NUDGE_RUN = 500; + +// what the user did to a PDF tab at one step +export interface Sheet { + readonly stamps: readonly Stamp[]; + // the form's fields filled in, one fill per field (by its full name) + readonly fills: readonly Fill[]; + // the ids of the found places the user dismissed + readonly dismissed: readonly string[]; +} + +export interface StampBook extends Sheet { + readonly past: readonly Sheet[]; + readonly future: readonly Sheet[]; + // the last nudge, which the next one of the same stamp may join + readonly nudged: { readonly id: string; readonly at: number } | null; +} + +export const EMPTY_BOOK: StampBook = { + stamps: [], + fills: [], + dismissed: [], + past: [], + future: [], + nudged: null, +}; + +/** + * sheetOf returns what `book` holds now, without its history + */ +const sheetOf = ({ stamps, fills, dismissed }: Sheet): Sheet => ({ + stamps, + fills, + dismissed, +}); + +// what a step of the history changed, for the screen reader, and what +// takes the focus: a found place that shows again, or a stamp that's back +export interface StepChange { + message: string; + place?: string; + stamp?: string; +} + +/** + * stepChange says what undoing (`undo`) or redoing a step changed from + * `before` to `after`, e.g. "Signature removed", "Place back"; the stamps + * first, then the found places, then the form + */ +export const stepChange = ( + before: Sheet, + after: Sheet, + undo: boolean, +): StepChange => { + const name = (stamp: Stamp) => STAMP_NAMES[stamp.kind]; + const was = new Map(before.stamps.map((stamp) => [stamp.id, stamp])); + const now = new Map(after.stamps.map((stamp) => [stamp.id, stamp])); + const back = after.stamps.find((stamp) => !was.has(stamp.id)); + if (back) + return { + message: `${name(back)} back on page ${back.page + 1}`, + stamp: back.id, + }; + const gone = before.stamps.find((stamp) => !now.has(stamp.id)); + if (gone) return { message: `${name(gone)} removed` }; + for (const stamp of after.stamps) { + const old = was.get(stamp.id)!; + if (!!old.repeat !== !!stamp.repeat) + return { + message: stamp.repeat + ? `${name(stamp)} on every page` + : `${name(stamp)} only on page ${stamp.page + 1}`, + stamp: stamp.id, + }; + const [x, y, w, h] = stamp.rect; + const [ox, oy, ow, oh] = old.rect; + if (w !== ow || h !== oh) + return { message: `${name(stamp)} resized`, stamp: stamp.id }; + if (x !== ox || y !== oy || stamp.page !== old.page) + return { message: `${name(stamp)} moved`, stamp: stamp.id }; + } + const shows = before.dismissed.find((id) => !after.dismissed.includes(id)); + if (shows) return { message: "Place back", place: shows }; + if (after.dismissed.some((id) => !before.dismissed.includes(id))) + return { message: "Place dismissed" }; + if (JSON.stringify(before.fills) !== JSON.stringify(after.fills)) + return { message: undo ? "Form entry undone" : "Form entry redone" }; + return { message: undo ? "Undone" : "Redone" }; +}; + +/** + * bookOf returns a book of `stamps`, `fills` and `dismissed`, with no + * history, e.g. restored ones + */ +export const bookOf = ( + stamps: readonly Stamp[], + fills: readonly Fill[] = [], + dismissed: readonly string[] = [], +): StampBook => ({ ...EMPTY_BOOK, stamps, fills, dismissed }); + +/** + * changedSheet returns `book` with `part` (the stamps, the fills or the + * dismissed places) as one more step, or, for a nudge of the stamp the last + * step nudged less than NUDGE_RUN before, in that step + * @param nudge the stamp a nudge moved and when, for a nudge + */ +export const changedSheet = ( + book: StampBook, + part: Partial, + nudge?: { id: string; at: number }, +): StampBook => { + const next = { ...sheetOf(book), ...part }; + if ( + next.stamps === book.stamps && + next.fills === book.fills && + next.dismissed === book.dismissed + ) + return book; + const joins = + nudge !== undefined && + book.nudged?.id === nudge.id && + nudge.at - book.nudged.at < NUDGE_RUN && + book.past.length > 0; + return { + ...next, + past: joins ? book.past : [...book.past, sheetOf(book)].slice(-HISTORY), + future: [], + nudged: nudge ?? null, + }; +}; + +/** + * changed returns `book` with `stamps` as one more step (see changedSheet) + */ +export const changed = ( + book: StampBook, + stamps: readonly Stamp[], + nudge?: { id: string; at: number }, +): StampBook => changedSheet(book, { stamps }, nudge); + +/** + * undone returns `book` a step back, or `book` with nothing to undo + */ +export const undone = (book: StampBook): StampBook => { + const previous = book.past[book.past.length - 1]; + if (!previous) return book; + return { + ...sheetOf(previous), + past: book.past.slice(0, -1), + future: [sheetOf(book), ...book.future], + nudged: null, + }; +}; + +/** + * redone returns `book` a step forward again, or `book` with nothing to + * redo + */ +export const redone = (book: StampBook): StampBook => { + const next = book.future[0]; + if (!next) return book; + return { + ...sheetOf(next), + past: [...book.past, sheetOf(book)].slice(-HISTORY), + future: book.future.slice(1), + nudged: null, + }; +}; + +/** + * withFill returns `fills` with `fill` in place of the one of its field, + * or added at the end + */ +export const withFill = ( + fills: readonly Fill[], + fill: Fill, +): readonly Fill[] => + fills.some((one) => one.field === fill.field) + ? fills.map((one) => (one.field === fill.field ? fill : one)) + : [...fills, fill]; + +/** + * isFill tells whether `value` is a fill as stored (`stamps:`) + */ +export const isFill = (value: unknown): value is Fill => { + const fill = value as Fill | null; + return ( + typeof fill?.field === "string" && + (typeof fill.value === "string" || typeof fill.value === "boolean") + ); +}; + +/** + * onPages returns the stamps of `stamps` on the pages a PDF of `count` + * pages has, e.g. once it changed on the disk + */ +export const onPages = (stamps: readonly Stamp[], count: number) => + stamps.filter((stamp) => stamp.page < count); + +/** + * fitOnPage returns `stamp` wholly on its page of `page`, e.g. once the file + * changed to a smaller paper: moved in, and made smaller with its shape kept + * where it doesn't fit (its natural size and letters too, so it resizes from + * there); as it is when it's on the page already + */ +export const fitOnPage = (stamp: Stamp, page: PageSize): Stamp => { + const [x, y, w, h] = stamp.rect; + if (x >= 0 && y >= 0 && x + w <= page.width && y + h <= page.height) + return stamp; + const scale = Math.min(page.width / w, page.height / h, 1); + const [width, height] = [w * scale, h * scale]; + const within = (at: number, size: number, room: number) => + Math.max(0, Math.min(at, room - size)); + const rect: Rect = [ + within(x, width, page.width), + within(y, height, page.height), + width, + height, + ]; + if (scale === 1) return { ...stamp, rect }; + const [naturalW, naturalH] = stamp.natural; + return { + ...stamp, + rect, + natural: [naturalW * scale, naturalH * scale], + spec: { ...stamp.spec, size: stamp.spec.size * scale }, + }; +}; + +/** + * stampLabel names `stamp` for a screen reader, e.g. "Signature on page 2, + * selected" + */ +export const stampLabel = (stamp: Stamp, selected: boolean) => + `${STAMP_NAMES[stamp.kind]} on page ${stamp.page + 1}${ + stamp.repeat ? " and every other page" : "" + }${selected ? ", selected" : ""}`; + +/** + * isStamp tells whether `value` is a stamp as stored (`stamps:`) + */ +export const isStamp = (value: unknown): value is Stamp => { + const stamp = value as Stamp | null; + const numbers = (list: unknown, length: number) => + Array.isArray(list) && + list.length === length && + list.every((n) => typeof n === "number" && Number.isFinite(n)); + return ( + typeof stamp?.id === "string" && + STAMP_KINDS.includes(stamp.kind) && + Number.isInteger(stamp.page) && + stamp.page >= 0 && + numbers(stamp.rect, 4) && + numbers(stamp.natural, 2) && + typeof stamp.spec === "object" && + stamp.spec !== null && + Array.isArray(stamp.spec.lines) && + stamp.spec.lines.every((line) => typeof line === "string") && + typeof stamp.spec.size === "number" + ); +}; diff --git a/src/pdf/stamps.ts b/src/pdf/stamps.ts new file mode 100644 index 00000000..a6d19e1e --- /dev/null +++ b/src/pdf/stamps.ts @@ -0,0 +1,82 @@ +import type { Mark, SavedMark } from "../signing/profile"; +import { SIGNATURE_IMAGE } from "../engine/flatten"; + +// A stamp placed on a PDF's page: the user's signature or initials, lines +// of text (a date, a place), or both. Blank's layout engine lays each out +// on a page of its own that is the stamp's box (`Content::Stamp` and +// `Settings::stamp` in src-tauri/layout/src/model.rs): stampEngine.ts paints +// it on screen and writes its one-page PDF, which the signer copies into +// the PDF as a Form XObject. + +export interface StampSpec { + // the user's signature or initials, contained in the box + image?: Mark; + // the lines under the image, in the body font + lines: string[]; + // the size of the lines' letters, in points + size: number; + align: "left" | "center"; +} + +// the size of the lines of a date or place, in points +export const LINE_SIZE = 11; + +// what the user reads when a stamp can't be laid out: the signing tools' +// tooltips and note, and a stamp that failed to be made +export const STAMPS_NEED_LAYOUT = + "Stamps need the page layout, which isn't running. Restart Blank to place them."; + +// the src the engine knows a mark's PNG by: the signature by the name a +// signed export's block shows it by (SIGNATURE_IMAGE) +export const MARK_SRC: Record = { + signature: SIGNATURE_IMAGE, + initials: "blank:initials", +}; + +// what a screen reader would call the image, were a stamp's text read +const MARK_ALT: Record = { + signature: "Signature", + initials: "Initials", +}; + +/** + * stampItem returns the engine's item for a stamp: its image by its mark's + * src, with the mark's size for its shape (none while the user has none) + */ +export const stampItem = (spec: StampSpec, mark?: SavedMark | null) => { + const image = + spec.image && mark && mark.width > 0 && mark.height > 0 + ? { + src: MARK_SRC[spec.image], + width: mark.width, + height: mark.height, + alt: MARK_ALT[spec.image], + } + : { src: "", width: 0, height: 0, alt: "" }; + return { + kind: "stamp", + pos: 0, + ...image, + lines: spec.lines, + size: spec.size, + align: spec.align, + }; +}; + +/** + * stampSettings returns the engine's settings for a stamp's page: its box, + * `width` × `height` points, without margins or headers and footers + */ +export const stampSettings = (width: number, height: number) => ({ + width, + height, + margins: { top: 0, right: 0, bottom: 0, left: 0 }, + stamp: true, +}); + +/** + * stampKey returns what tells stamps apart: what they show, but not its + * size, which a stamp's resize changes and its engine lays out again + */ +export const stampKey = (spec: StampSpec) => + JSON.stringify([spec.image ?? null, spec.lines, spec.align]); diff --git a/src/pdf/toolRules.test.ts b/src/pdf/toolRules.test.ts new file mode 100644 index 00000000..e4ca4789 --- /dev/null +++ b/src/pdf/toolRules.test.ts @@ -0,0 +1,158 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import type { PdfRules } from "../signing/ipc"; +import { + CHECKING, + DAMAGED, + NO_CHANGES, + NOT_OPEN, + RESTRICTED, + fieldRefusal, + FIELD_LOCKED, + ONE_SIGNATURE, + signRefusal, + SIGNING, + type ToolContext, + toolbarNote, + toolRefusal, +} from "./toolRules"; +import { STAMPS_NEED_LAYOUT } from "./stamps"; + +const rules = (over: Partial = {}): PdfRules => ({ + signed: false, + docmdp: null, + encrypted: false, + damaged: false, + locked: [], + ...over, +}); + +const context = (over: Partial = {}): ToolContext => ({ + opened: { + sha256: "abc", + pages: [{ width: 595, height: 842, rotation: 0 }], + repaired: false, + encrypted: false, + }, + rules: rules(), + engine: true, + signing: false, + signatures: 0, + ...over, +}); + +describe("what the signing tools may place", () => { + it("place everything on a PDF nobody signed", () => { + for (const kind of ["signature", "initials", "date", "place"] as const) + expect(toolRefusal(kind, context())).toBeNull(); + expect(signRefusal(context())).toBeNull(); + expect(toolbarNote(context())).toBeNull(); + }); + + it("keep to the signature on a signed or certified PDF, saying why", () => { + for (const signed of [ + context({ rules: rules({ signed: true }) }), + context({ rules: rules({ docmdp: 2 }) }), + ]) { + expect(toolRefusal("signature", signed)).toBeNull(); + expect(toolRefusal("initials", signed)).toBe( + "Initials would change pages the earlier signers signed.", + ); + expect(toolRefusal("date", signed)).toBe( + "A date would change pages the earlier signers signed.", + ); + expect(toolRefusal("place", signed)).toBe( + "A place would change pages the earlier signers signed.", + ); + expect(signRefusal(signed)).toBeNull(); + } + }); + + it("place nothing on a PDF that allows no changes, nor sign it", () => { + const p1 = context({ rules: rules({ docmdp: 1, signed: true }) }); + expect(toolRefusal("signature", p1)).toBe(NO_CHANGES); + expect(signRefusal(p1)).toBe(NO_CHANGES); + expect(toolbarNote(p1)).toBe(NO_CHANGES); + }); + + it("place nothing on an encrypted or damaged PDF", () => { + const encrypted = context({ rules: rules({ encrypted: true }) }); + expect(toolRefusal("date", encrypted)).toBe(RESTRICTED); + expect(signRefusal(encrypted)).toBe(RESTRICTED); + const repaired = context({ + opened: { ...context().opened!, repaired: true }, + }); + expect(toolRefusal("signature", repaired)).toBe(DAMAGED); + expect(toolbarNote(repaired)).toBe(DAMAGED); + }); + + it("wait for the PDF to open and its rules to be read", () => { + expect(toolRefusal("signature", context({ opened: null }))).toBe(NOT_OPEN); + expect(toolbarNote(context({ opened: null }))).toBeNull(); + expect(toolRefusal("signature", context({ rules: null }))).toBe(CHECKING); + expect(signRefusal(context({ rules: null }))).toBe(CHECKING); + }); + + it("need the layout", () => { + const without = context({ engine: false }); + expect(toolRefusal("signature", without)).toBe(STAMPS_NEED_LAYOUT); + expect(toolbarNote(without)).toBe(STAMPS_NEED_LAYOUT); + // signing itself needs no stamps + expect(signRefusal(without)).toBeNull(); + // a place Blank doesn't know yet is asked for when it's placed + expect(toolRefusal("place", context())).toBeNull(); + }); +}); + +describe("what else waits or is refused", () => { + it("take one more signature only on a signed PDF", () => { + const signed = context({ rules: rules({ signed: true }), signatures: 1 }); + expect(toolRefusal("signature", signed)).toBe(ONE_SIGNATURE); + expect(toolRefusal("signature", context({ signatures: 1 }))).toBeNull(); + expect( + toolRefusal("signature", context({ rules: rules({ docmdp: 2 }) })), + ).toBeNull(); + }); + + it("wait while Blank signs the PDF", () => { + const signing = context({ signing: true }); + expect(toolRefusal("date", signing)).toBe(SIGNING); + expect(signRefusal(signing)).toBe(SIGNING); + expect(fieldRefusal("name", signing)).toBe(SIGNING); + }); + + it("keep fields an earlier signer locked as they are", () => { + const locked = context({ rules: rules({ locked: ["name"] }) }); + expect(fieldRefusal("name", locked)).toBe(FIELD_LOCKED); + expect(fieldRefusal("other", locked)).toBeNull(); + expect(fieldRefusal("name", context({ rules: null }))).toBe(CHECKING); + }); +}); + +describe("what the signer refuses", () => { + // the sentences of `Refusal` (src-tauri/src/signing/mod.rs), which the + // tools say before the signer would + const source = readFileSync( + resolve(import.meta.dirname, "../../src-tauri/src/signing/mod.rs"), + "utf8", + ); + const refusal = (name: string) => { + const match = new RegExp(`Refusal::${name} =>\\s*\\{?\\s*"([^"]*)"`).exec( + source, + ); + expect(match, name).not.toBeNull(); + return match![1]; + }; + + it("is said in the same words", () => { + expect(refusal("Restricted")).toBe(RESTRICTED); + expect(refusal("Certified")).toBe(NO_CHANGES); + }); + + it("never says it can't yet", () => { + expect(RESTRICTED).not.toMatch(/\byet\b/); + expect(refusal("Restricted")).not.toMatch(/\byet\b/); + }); +}); diff --git a/src/pdf/toolRules.ts b/src/pdf/toolRules.ts new file mode 100644 index 00000000..b1baf6c4 --- /dev/null +++ b/src/pdf/toolRules.ts @@ -0,0 +1,154 @@ +import type { PdfRules } from "../signing/ipc"; +import type { OpenedPdf } from "./ipc"; +import { fillOf, type Place } from "./places"; +import type { StampKind } from "./stampModel"; +import { STAMPS_NEED_LAYOUT } from "./stamps"; + +// What the signing tools of a PDF tab may do (see .claude/rules/pdf.md, +// "Stamps"), and why not when they may not: one sentence per reason, the +// tools' tooltips and the toolbar's note. Pure; signingToolbarModel.ts +// builds the toolbar on it, the places and fields ask it, and pdf.sign asks +// it before it runs. The sentences of what the signer refuses itself are +// those of `Refusal` in src-tauri/src/signing/mod.rs (toolRules.test.ts +// compares them). + +export interface ToolContext { + // the PDF as it opened, null while it opens or can't be + opened: OpenedPdf | null; + // what it lets the tools place, null while it's being read + rules: PdfRules | null; + // whether Blank's layout engine runs, which lays out the stamps + engine: boolean; + // whether Blank is signing the PDF right now + signing: boolean; + // how many signature stamps are on its pages + signatures: number; +} + +export const RESTRICTED = + "This PDF has restrictions set by its author. Blank can't sign it."; +export const DAMAGED = "This PDF is damaged, so Blank can't add to it"; +export const NO_CHANGES = + "This PDF doesn't allow any changes, not even signatures"; +export const CHECKING = "Blank is checking what this PDF allows"; +export const NOT_OPEN = "The PDF isn't open"; +export const SIGNING = "Blank is signing this PDF"; +export const FIELD_LOCKED = + "An earlier signer locked this field, so it can't change any more"; +export const ONE_SIGNATURE = + "This PDF is signed already, so it takes one more signature only"; +export const VISIBLE_ON_SIGNED = + "Without a digital signature, your signature would change pages the earlier signers signed. Sign with your certificate."; + +// what a stamp on a signed PDF would do, by tool (signatures go into a +// signature field's appearance there, so they stay) +const SIGNED: Partial> = { + initials: "Initials would change pages the earlier signers signed.", + date: "A date would change pages the earlier signers signed.", + place: "A place would change pages the earlier signers signed.", +}; + +/** + * isSigned tells whether earlier signatures protect the PDF's pages: it is + * signed or certified + */ +const isSigned = (rules: PdfRules) => rules.signed || rules.docmdp !== null; + +/** + * blocked says why nothing can be added to the PDF, neither stamps nor a + * signature, or null when something can + */ +export const blocked = ({ opened, rules }: ToolContext): string | null => { + if (!opened) return NOT_OPEN; + if (opened.encrypted || rules?.encrypted) return RESTRICTED; + if (opened.repaired || rules?.damaged) return DAMAGED; + if (rules?.docmdp === 1) return NO_CHANGES; + return null; +}; + +/** + * toolbarNote returns what the signing toolbar says next to its tools when + * none of them works, or null + */ +export const toolbarNote = (context: ToolContext): string | null => { + const reason = blocked(context); + if (reason && reason !== NOT_OPEN) return reason; + if (!reason && !context.engine) return STAMPS_NEED_LAYOUT; + return null; +}; + +/** + * toolRefusal says why the tool `kind` can't place its stamp on the PDF, + * or null when it can + */ +export const toolRefusal = ( + kind: StampKind, + context: ToolContext, +): string | null => { + const reason = blocked(context); + if (reason) return reason; + if (context.signing) return SIGNING; + if (!context.engine) return STAMPS_NEED_LAYOUT; + if (!context.rules) return CHECKING; + if (isSigned(context.rules)) { + if (SIGNED[kind]) return SIGNED[kind]!; + // only one of them becomes the signature's appearance + if (kind === "signature" && context.signatures > 0) return ONE_SIGNATURE; + } + return null; +}; + +/** + * fieldRefusal says why the form's field `name` can't be filled in, or null + * when it can + */ +export const fieldRefusal = ( + name: string, + context: ToolContext, +): string | null => { + const reason = blocked(context); + if (reason) return reason; + if (context.signing) return SIGNING; + if (!context.rules) return CHECKING; + if (context.rules.locked.includes(name)) return FIELD_LOCKED; + return null; +}; + +/** + * placeRefusal says why `place` can't be filled in or signed, or null when + * it can: as its stamp's tool for a stamp, else as its field + */ +export const placeRefusal = ( + place: Place, + context: ToolContext, +): string | null => { + const fill = fillOf(place); + if (fill.action !== "stamp") + return place.source === "field" + ? fieldRefusal(place.field.name, context) + : null; + const kind = fill.stamp === "placeAndDate" ? "place" : fill.stamp; + return ( + toolRefusal(kind, context) ?? + (place.source === "field" ? fieldRefusal(place.field.name, context) : null) + ); +}; + +/** + * signRefusal says why the PDF can't be signed, or null when it can + */ +export const signRefusal = (context: ToolContext): string | null => { + const reason = blocked(context); + if (reason) return reason; + if (context.signing) return SIGNING; + if (!context.rules) return CHECKING; + return null; +}; + +/** + * visibleRefusal says why the PDF can't be signed without a digital + * signature, or null when it can: its earlier signatures protect its pages, + * which a signature drawn into them would change + */ +export const visibleRefusal = (context: ToolContext): string | null => + context.rules && isSigned(context.rules) ? VISIBLE_ON_SIGNED : null; diff --git a/src/scss/_formatToolbar.scss b/src/scss/_formatToolbar.scss index e32291fc..01617a7c 100644 --- a/src/scss/_formatToolbar.scss +++ b/src/scss/_formatToolbar.scss @@ -68,3 +68,43 @@ font-family: monospace; } } + +// the signing toolbar of a PDF tab (src/ui/SigningToolbar.vue), in the +// formatting toolbar's row: the tools with their names, Sign… in a box, and +// a note when no tool works +#signing-toolbar { + justify-content: center; + gap: 2px; + padding: 0 var(--s2); + overflow: hidden; + white-space: nowrap; + + .separator { + @include separator; + align-self: center; + height: 16px; + margin: 0 var(--s1); + } +} + +.signing-tool { + @include icon-button; + @include icon-text-button; + align-items: center; +} + +.signing-sign { + @include text-button; +} + +.signing-note { + min-width: 0; + margin: 0 0 0 var(--s3); + font-size: var(--ui-small); + line-height: 16px; + color: var(--muted); + // hidden for Safari 15 (macOS 12), which has no clip + overflow: hidden; + overflow: clip; + text-overflow: ellipsis; +} diff --git a/src/scss/_signing.scss b/src/scss/_signing.scss index 3c8ecae3..c84a9e4f 100644 --- a/src/scss/_signing.scss +++ b/src/scss/_signing.scss @@ -267,10 +267,9 @@ margin-left: var(--s1); } - .note { - margin: var(--s3) 0 0; - color: var(--muted); - font-size: var(--ui-small); + // a dialog's note (main.scss), further from what it notes + p.note { + margin-top: var(--s3); } } @@ -295,3 +294,15 @@ flex-basis: 100%; } } + +// the Sign dialog's switches and the button that makes the initials +.sign-dialog { + .dialog-body > .switch-row { + margin-bottom: var(--s2); + } + + .make-initials { + @include text-button; + margin-left: var(--s1); + } +} diff --git a/src/scss/main.scss b/src/scss/main.scss index 38dd213a..e06dd9db 100644 --- a/src/scss/main.scss +++ b/src/scss/main.scss @@ -1864,3 +1864,155 @@ body.without-engine.blocks-docked { @include text-button; } } + +// a PDF tab's pages while a tool waits for the press that places its stamp +#pdf-view.pdf-placing .page-desk { + cursor: crosshair; +} + +// a stamp placed on a PDF's page (StampOverlay.vue): painted by its stamp +// engine on the white page; selected, the accent ring with a corner at each +// end to resize it, the mouse moving it +.pdf-stamp { + position: absolute; + box-sizing: border-box; + cursor: move; + // the pointer drags it, never scrolls the view + touch-action: none; + + canvas { + display: block; + width: 100%; + height: 100%; + } + + &.selected { + outline: 2px solid var(--accent); + outline-offset: 3px; + } + + // a repeated stamp on the other pages + &.ghost { + opacity: 0.6; + pointer-events: none; + } +} + +// a place to sign or date on a PDF's page (PdfTarget.vue), and a field of +// its form (PdfField.vue): a dashed box in the accent over what the page +// shows there. A text field shows its input only while it has the focus; +// otherwise the page shows what the field holds. +.pdf-target, +.pdf-field, +.pdf-choice-slot .pdf-choice { + position: absolute; + box-sizing: border-box; + margin: 0; + padding: 0; + border: 1px dashed var(--accent); + border-radius: 2px; + font: inherit; + color: transparent; + background: transparent; +} + +.pdf-target { + background: var(--accent-soft); +} + +.pdf-field { + padding: 0 2px; + font-size: var(--ui); + resize: none; + user-select: text; + + &.editing { + border-style: solid; + color: var(--color); + background: var(--background-color); + } +} + +// a radio group's buttons, within its box +.pdf-radios { + position: absolute; + + .pdf-field { + position: absolute; + } +} + +// a combo or list box: its select shows its value while it has the focus +.pdf-choice-slot { + position: absolute; + + .pdf-choice { + position: static; + width: 100%; + height: 100%; + min-height: 0; + + &:focus-visible { + border-style: solid; + color: var(--color); + background: var(--background-color); + } + } +} + +// why a field didn't take what was typed, under it +// above its field, its bottom at the top given: below it, it would cover +// the next field +.pdf-field-problem { + position: absolute; + transform: translateY(-100%); + margin: 0; + padding: 2px var(--s1); + font-size: var(--ui-small); + color: var(--spelling-color); + @include ui-text; + @include popover; +} + +// a corner of the stamp selected, on its ring +.stamp-handle { + position: absolute; + width: 8px; + height: 8px; + box-sizing: border-box; + border: 1.5px solid var(--accent); + border-radius: 2px; + background-color: var(--background-color); + + &[data-corner="top-left"] { + top: -8px; + left: -8px; + cursor: nwse-resize; + } + &[data-corner="top-right"] { + top: -8px; + right: -8px; + cursor: nesw-resize; + } + &[data-corner="bottom-left"] { + bottom: -8px; + left: -8px; + cursor: nesw-resize; + } + &[data-corner="bottom-right"] { + right: -8px; + bottom: -8px; + cursor: nwse-resize; + } +} + +// the toolbar of the stamp selected (StampToolbar.vue), above it on the desk +.stamp-toolbar { + position: absolute; + display: flex; + gap: 2px; + box-sizing: border-box; + padding: 3px; + @include ui-text; + @include popover; +} diff --git a/src/signing/__fixtures__/labels/de-captions-under-rules.json b/src/signing/__fixtures__/labels/de-captions-under-rules.json new file mode 100644 index 00000000..f9bb54d5 --- /dev/null +++ b/src/signing/__fixtures__/labels/de-captions-under-rules.json @@ -0,0 +1,73 @@ +{ + "about": "German captions just below drawn rules: Ort, Datum and Unterschrift", + "text": { + "words": [ + { + "text": "Berlin,", + "rect": [72, 592, 33.98, 10.5] + }, + { + "text": "den", + "rect": [108.76, 592, 15.6, 10.5] + }, + { + "text": "1.", + "rect": [127.14, 592, 7.98, 10.5] + }, + { + "text": "Oktober", + "rect": [137.9, 592, 36.4, 10.5] + }, + { + "text": "2026.", + "rect": [177.08, 592, 23.58, 10.5] + }, + { + "text": "Beide", + "rect": [203.44, 592, 26, 10.5] + }, + { + "text": "Seiten", + "rect": [232.22, 592, 31.2, 10.5] + }, + { + "text": "erhalten", + "rect": [266.2, 592, 41.6, 10.5] + }, + { + "text": "eine", + "rect": [310.58, 592, 20.8, 10.5] + }, + { + "text": "Ausfertigung.", + "rect": [334.16, 592, 65.18, 10.5] + }, + { + "text": "Ort,", + "rect": [72, 702.43, 18.38, 10.5] + }, + { + "text": "Datum", + "rect": [93.16, 702.43, 26, 10.5] + }, + { + "text": "Unterschrift", + "rect": [320, 702.43, 62.4, 10.5] + } + ], + "rules": [ + [72, 699.75, 180, 0.5], + [320, 699.75, 200, 0.5] + ] + }, + "expected": [ + { + "kind": "placeAndDate", + "rect": [72, 681.75, 180, 18] + }, + { + "kind": "signature", + "rect": [320, 663.75, 200, 36] + } + ] +} diff --git a/src/signing/__fixtures__/labels/de-dots-one-line.json b/src/signing/__fixtures__/labels/de-dots-one-line.json new file mode 100644 index 00000000..8bc4d88d --- /dev/null +++ b/src/signing/__fixtures__/labels/de-dots-one-line.json @@ -0,0 +1,38 @@ +{ + "about": "German labels left of dot runs, both on one line", + "text": { + "words": [ + { + "text": "Ort,", + "rect": [72, 712, 18.38, 10.5] + }, + { + "text": "Datum", + "rect": [93.16, 712, 26, 10.5] + }, + { + "text": "......................................", + "rect": [121.94, 712, 105.64, 10.5] + }, + { + "text": "Unterschrift", + "rect": [235.92, 712, 62.4, 10.5] + }, + { + "text": "..........................................", + "rect": [301.1, 712, 116.76, 10.5] + } + ], + "rules": [] + }, + "expected": [ + { + "kind": "placeAndDate", + "rect": [121.94, 705.7, 105.64, 16.8] + }, + { + "kind": "signature", + "rect": [301.1, 705.7, 116.76, 16.8] + } + ] +} diff --git a/src/signing/__fixtures__/labels/de-prose-traps.json b/src/signing/__fixtures__/labels/de-prose-traps.json new file mode 100644 index 00000000..fd1b4163 --- /dev/null +++ b/src/signing/__fixtures__/labels/de-prose-traps.json @@ -0,0 +1,160 @@ +{ + "about": "German running text about signatures, under and over drawn rules: no places", + "text": { + "words": [ + { + "text": "§", + "rect": [72, 88.8, 7.28, 14.7] + }, + { + "text": "12", + "rect": [83.17, 88.8, 14.56, 14.7] + }, + { + "text": "Schlussbestimmungen", + "rect": [101.62, 88.8, 138.32, 14.7] + }, + { + "text": "Die", + "rect": [72, 123.2, 14.04, 9.45] + }, + { + "text": "Unterschrift", + "rect": [88.54, 123.2, 56.16, 9.45] + }, + { + "text": "des", + "rect": [147.2, 123.2, 14.04, 9.45] + }, + { + "text": "Mieters", + "rect": [163.75, 123.2, 32.76, 9.45] + }, + { + "text": "ist", + "rect": [199.01, 123.2, 14.04, 9.45] + }, + { + "text": "erforderlich.", + "rect": [215.55, 123.2, 58.66, 9.45] + }, + { + "text": "Mit", + "rect": [72, 152, 15.6, 10.5] + }, + { + "text": "der", + "rect": [90.38, 152, 15.6, 10.5] + }, + { + "text": "Unterschrift", + "rect": [108.76, 152, 62.4, 10.5] + }, + { + "text": "erkennt", + "rect": [173.94, 152, 36.4, 10.5] + }, + { + "text": "der", + "rect": [213.12, 152, 15.6, 10.5] + }, + { + "text": "Mieter", + "rect": [231.5, 152, 31.2, 10.5] + }, + { + "text": "die", + "rect": [265.48, 152, 15.6, 10.5] + }, + { + "text": "Hausordnung", + "rect": [283.86, 152, 57.2, 10.5] + }, + { + "text": "an.", + "rect": [343.84, 152, 13.18, 10.5] + }, + { + "text": "Ort", + "rect": [359.8, 152, 15.6, 10.5] + }, + { + "text": "und", + "rect": [378.18, 152, 15.6, 10.5] + }, + { + "text": "Datum", + "rect": [72, 164, 26, 10.5] + }, + { + "text": "der", + "rect": [100.78, 164, 15.6, 10.5] + }, + { + "text": "Übergabe", + "rect": [119.16, 164, 41.6, 10.5] + }, + { + "text": "stehen", + "rect": [163.54, 164, 31.2, 10.5] + }, + { + "text": "im", + "rect": [197.52, 164, 10.4, 10.5] + }, + { + "text": "Protokoll.", + "rect": [210.7, 164, 49.58, 10.5] + }, + { + "text": "Unterschriften", + "rect": [263.06, 164, 72.8, 10.5] + }, + { + "text": "Dritter", + "rect": [338.64, 164, 36.4, 10.5] + }, + { + "text": "sind", + "rect": [377.82, 164, 20.8, 10.5] + }, + { + "text": "nicht", + "rect": [72, 176, 26, 10.5] + }, + { + "text": "nötig;", + "rect": [100.78, 176, 31.2, 10.5] + }, + { + "text": "ein", + "rect": [134.76, 176, 15.6, 10.5] + }, + { + "text": "Stempel", + "rect": [153.14, 176, 36.4, 10.5] + }, + { + "text": "und", + "rect": [192.32, 176, 15.6, 10.5] + }, + { + "text": "eine", + "rect": [210.7, 176, 20.8, 10.5] + }, + { + "text": "Unterschrift", + "rect": [234.28, 176, 62.4, 10.5] + }, + { + "text": "genügen.", + "rect": [299.46, 176, 39.18, 10.5] + } + ], + "rules": [ + [72, 119.75, 450, 0.5], + [72, 229.75, 450, 0.5] + ] + }, + "expected": [] +} diff --git a/src/signing/__fixtures__/labels/de-roles.json b/src/signing/__fixtures__/labels/de-roles.json new file mode 100644 index 00000000..5b610414 --- /dev/null +++ b/src/signing/__fixtures__/labels/de-roles.json @@ -0,0 +1,112 @@ +{ + "about": "German signature captions with the signer's role before or after them", + "text": { + "words": [ + { + "text": "Unterschrift", + "rect": [72, 653.2, 56.16, 9.45] + }, + { + "text": "Mieter", + "rect": [130.66, 653.2, 28.08, 9.45] + }, + { + "text": "Unterschrift", + "rect": [260, 653.2, 56.16, 9.45] + }, + { + "text": "Vermieter/in", + "rect": [318.66, 653.2, 56.16, 9.45] + }, + { + "text": "Mieter/in", + "rect": [440, 653.2, 42.12, 9.45] + }, + { + "text": "(Unterschrift)", + "rect": [484.62, 653.2, 62.15, 9.45] + }, + { + "text": "Erziehungsberechtigte(r)", + "rect": [72, 723.2, 108.95, 9.45] + }, + { + "text": "(Unterschrift)", + "rect": [183.46, 723.2, 62.15, 9.45] + }, + { + "text": "Unterschrift", + "rect": [72, 762, 62.4, 10.5] + }, + { + "text": "Arbeitgeber:", + "rect": [137.18, 762, 59.98, 10.5] + }, + { + "text": "____________________", + "rect": [199.94, 762, 111.2, 10.5] + }, + { + "text": "Arbeitnehmer", + "rect": [320, 762, 62.4, 10.5] + }, + { + "text": "(Unterschrift)", + "rect": [385.18, 762, 69.06, 10.5] + }, + { + "text": "_______________", + "rect": [457.02, 762, 83.4, 10.5] + }, + { + "text": "Unterschriften", + "rect": [72, 813.2, 65.52, 9.45] + }, + { + "text": "Auftraggeber,", + "rect": [140.02, 813.2, 58.66, 9.45] + }, + { + "text": "Auftragnehmer", + "rect": [201.19, 813.2, 60.84, 9.45] + } + ], + "rules": [ + [72, 649.75, 160, 0.5], + [260, 649.75, 160, 0.5], + [440, 649.75, 160, 0.5], + [72, 719.75, 220, 0.5], + [72, 809.75, 200, 0.5] + ] + }, + "expected": [ + { + "kind": "signature", + "rect": [72, 613.75, 160, 36] + }, + { + "kind": "signature", + "rect": [260, 613.75, 160, 36] + }, + { + "kind": "signature", + "rect": [440, 613.75, 160, 36] + }, + { + "kind": "signature", + "rect": [72, 683.75, 220, 36] + }, + { + "kind": "signature", + "rect": [199.94, 755.7, 111.2, 16.8] + }, + { + "kind": "signature", + "rect": [457.02, 755.7, 83.4, 16.8] + }, + { + "kind": "signature", + "rect": [72, 773.75, 200, 36] + } + ] +} diff --git a/src/signing/__fixtures__/labels/en-colon-underscores.json b/src/signing/__fixtures__/labels/en-colon-underscores.json new file mode 100644 index 00000000..53b6c844 --- /dev/null +++ b/src/signing/__fixtures__/labels/en-colon-underscores.json @@ -0,0 +1,62 @@ +{ + "about": "English labels with a colon, left of underscores, in single-spaced lines; Name gets no place", + "text": { + "words": [ + { + "text": "Application", + "rect": [72, 88.8, 80.08, 14.7] + }, + { + "text": "for", + "rect": [155.97, 88.8, 21.84, 14.7] + }, + { + "text": "a", + "rect": [181.7, 88.8, 7.28, 14.7] + }, + { + "text": "parking", + "rect": [192.88, 88.8, 50.96, 14.7] + }, + { + "text": "permit", + "rect": [247.73, 88.8, 43.68, 14.7] + }, + { + "text": "Name:", + "rect": [72, 592, 23.58, 10.5] + }, + { + "text": "____________________", + "rect": [98.36, 592, 111.2, 10.5] + }, + { + "text": "Signature:", + "rect": [72, 604, 49.58, 10.5] + }, + { + "text": "____________________", + "rect": [124.36, 604, 111.2, 10.5] + }, + { + "text": "Date:", + "rect": [72, 616, 23.58, 10.5] + }, + { + "text": "____________", + "rect": [98.36, 616, 66.72, 10.5] + } + ], + "rules": [] + }, + "expected": [ + { + "kind": "signature", + "rect": [124.36, 597.7, 111.2, 16.8] + }, + { + "kind": "date", + "rect": [98.36, 609.7, 66.72, 16.8] + } + ] +} diff --git a/src/signing/__fixtures__/labels/en-parens-under-underscores.json b/src/signing/__fixtures__/labels/en-parens-under-underscores.json new file mode 100644 index 00000000..5805d9ae --- /dev/null +++ b/src/signing/__fixtures__/labels/en-parens-under-underscores.json @@ -0,0 +1,34 @@ +{ + "about": "English captions in brackets below runs of underscores", + "text": { + "words": [ + { + "text": "________________________________", + "rect": [72, 642, 177.92, 10.5] + }, + { + "text": "______________________", + "rect": [330, 642, 122.32, 10.5] + }, + { + "text": "(signature)", + "rect": [120, 655.6, 42.77, 8.4] + }, + { + "text": "(date)", + "rect": [380, 655.6, 21.97, 8.4] + } + ], + "rules": [] + }, + "expected": [ + { + "kind": "signature", + "rect": [72, 615.5, 177.92, 36] + }, + { + "kind": "date", + "rect": [330, 633.5, 122.32, 18] + } + ] +} diff --git a/src/signing/__fixtures__/labels/en-prose-traps.json b/src/signing/__fixtures__/labels/en-prose-traps.json new file mode 100644 index 00000000..ac31ab8d --- /dev/null +++ b/src/signing/__fixtures__/labels/en-prose-traps.json @@ -0,0 +1,336 @@ +{ + "about": "Labels in running text, a heading under a separator rule: no places", + "text": { + "words": [ + { + "text": "Notes", + "rect": [72, 88.8, 36.4, 14.7] + }, + { + "text": "on", + "rect": [112.29, 88.8, 14.56, 14.7] + }, + { + "text": "the", + "rect": [130.74, 88.8, 21.84, 14.7] + }, + { + "text": "agreement", + "rect": [156.48, 88.8, 65.52, 14.7] + }, + { + "text": "The", + "rect": [72, 122, 15.6, 10.5] + }, + { + "text": "parties", + "rect": [90.38, 122, 36.4, 10.5] + }, + { + "text": "agree", + "rect": [129.56, 122, 26, 10.5] + }, + { + "text": "that", + "rect": [158.34, 122, 20.8, 10.5] + }, + { + "text": "the", + "rect": [181.92, 122, 15.6, 10.5] + }, + { + "text": "start", + "rect": [200.3, 122, 26, 10.5] + }, + { + "text": "date", + "rect": [229.08, 122, 20.8, 10.5] + }, + { + "text": "of", + "rect": [252.66, 122, 10.4, 10.5] + }, + { + "text": "this", + "rect": [265.84, 122, 20.8, 10.5] + }, + { + "text": "contract", + "rect": [289.42, 122, 41.6, 10.5] + }, + { + "text": "is", + "rect": [333.8, 122, 10.4, 10.5] + }, + { + "text": "the", + "rect": [346.98, 122, 15.6, 10.5] + }, + { + "text": "date", + "rect": [365.36, 122, 20.8, 10.5] + }, + { + "text": "it", + "rect": [388.94, 122, 10.4, 10.5] + }, + { + "text": "is", + "rect": [402.12, 122, 10.4, 10.5] + }, + { + "text": "signed", + "rect": [72, 134, 31.2, 10.5] + }, + { + "text": "by", + "rect": [105.98, 134, 10.4, 10.5] + }, + { + "text": "both", + "rect": [119.16, 134, 20.8, 10.5] + }, + { + "text": "of", + "rect": [142.74, 134, 10.4, 10.5] + }, + { + "text": "them.", + "rect": [155.92, 134, 23.58, 10.5] + }, + { + "text": "Signed", + "rect": [182.28, 134, 31.2, 10.5] + }, + { + "text": "copies", + "rect": [216.26, 134, 31.2, 10.5] + }, + { + "text": "are", + "rect": [250.24, 134, 15.6, 10.5] + }, + { + "text": "kept", + "rect": [268.62, 134, 20.8, 10.5] + }, + { + "text": "by", + "rect": [292.2, 134, 10.4, 10.5] + }, + { + "text": "both", + "rect": [305.38, 134, 20.8, 10.5] + }, + { + "text": "parties,", + "rect": [328.96, 134, 39.18, 10.5] + }, + { + "text": "and", + "rect": [370.92, 134, 15.6, 10.5] + }, + { + "text": "the", + "rect": [389.3, 134, 15.6, 10.5] + }, + { + "text": "signature", + "rect": [72, 146, 46.8, 10.5] + }, + { + "text": "of", + "rect": [121.58, 146, 10.4, 10.5] + }, + { + "text": "a", + "rect": [134.76, 146, 5.2, 10.5] + }, + { + "text": "witness", + "rect": [142.74, 146, 36.4, 10.5] + }, + { + "text": "is", + "rect": [181.92, 146, 10.4, 10.5] + }, + { + "text": "not", + "rect": [195.1, 146, 15.6, 10.5] + }, + { + "text": "needed.", + "rect": [213.48, 146, 33.98, 10.5] + }, + { + "text": "The", + "rect": [250.24, 146, 15.6, 10.5] + }, + { + "text": "place", + "rect": [268.62, 146, 26, 10.5] + }, + { + "text": "of", + "rect": [297.4, 146, 10.4, 10.5] + }, + { + "text": "signing", + "rect": [310.58, 146, 36.4, 10.5] + }, + { + "text": "doesn't", + "rect": [349.76, 146, 36.4, 10.5] + }, + { + "text": "matter.", + "rect": [388.94, 146, 33.98, 10.5] + }, + { + "text": "Place", + "rect": [72, 172.65, 31.2, 12.6] + }, + { + "text": "of", + "rect": [106.54, 172.65, 12.48, 12.6] + }, + { + "text": "performance", + "rect": [122.35, 172.65, 68.64, 12.6] + }, + { + "text": "Date", + "rect": [72, 192, 20.8, 10.5] + }, + { + "text": "and", + "rect": [95.58, 192, 15.6, 10.5] + }, + { + "text": "place", + "rect": [113.96, 192, 26, 10.5] + }, + { + "text": "are", + "rect": [142.74, 192, 15.6, 10.5] + }, + { + "text": "given", + "rect": [161.12, 192, 26, 10.5] + }, + { + "text": "in", + "rect": [189.9, 192, 10.4, 10.5] + }, + { + "text": "the", + "rect": [203.08, 192, 15.6, 10.5] + }, + { + "text": "header.", + "rect": [221.46, 192, 33.98, 10.5] + }, + { + "text": "Sign", + "rect": [258.22, 192, 20.8, 10.5] + }, + { + "text": "here", + "rect": [281.8, 192, 20.8, 10.5] + }, + { + "text": "only", + "rect": [305.38, 192, 20.8, 10.5] + }, + { + "text": "if", + "rect": [328.96, 192, 10.4, 10.5] + }, + { + "text": "you", + "rect": [342.14, 192, 15.6, 10.5] + }, + { + "text": "agree.", + "rect": [360.52, 192, 28.78, 10.5] + }, + { + "text": "Initials:", + "rect": [72, 222, 44.38, 10.5] + }, + { + "text": "see", + "rect": [119.16, 222, 15.6, 10.5] + }, + { + "text": "the", + "rect": [137.54, 222, 15.6, 10.5] + }, + { + "text": "appendix", + "rect": [155.92, 222, 41.6, 10.5] + }, + { + "text": "for", + "rect": [200.3, 222, 15.6, 10.5] + }, + { + "text": "the", + "rect": [218.68, 222, 15.6, 10.5] + }, + { + "text": "initials", + "rect": [237.06, 222, 41.6, 10.5] + }, + { + "text": "of", + "rect": [281.44, 222, 10.4, 10.5] + }, + { + "text": "every", + "rect": [294.62, 222, 26, 10.5] + }, + { + "text": "clerk.", + "rect": [323.4, 222, 28.78, 10.5] + }, + { + "text": "Payment", + "rect": [72, 263.2, 32.76, 9.45] + }, + { + "text": "is", + "rect": [107.26, 263.2, 9.36, 9.45] + }, + { + "text": "due", + "rect": [119.12, 263.2, 14.04, 9.45] + }, + { + "text": "upon", + "rect": [135.67, 263.2, 18.72, 9.45] + }, + { + "text": "signature", + "rect": [156.89, 263.2, 42.12, 9.45] + }, + { + "text": "of", + "rect": [201.51, 263.2, 9.36, 9.45] + }, + { + "text": "the", + "rect": [213.37, 263.2, 14.04, 9.45] + }, + { + "text": "agreement.", + "rect": [229.91, 263.2, 44.62, 9.45] + } + ], + "rules": [ + [72, 169.75, 450, 0.5], + [72, 259.75, 450, 0.5] + ] + }, + "expected": [] +} diff --git a/src/signing/__fixtures__/labels/en-roles.json b/src/signing/__fixtures__/labels/en-roles.json new file mode 100644 index 00000000..508f4560 --- /dev/null +++ b/src/signing/__fixtures__/labels/en-roles.json @@ -0,0 +1,57 @@ +{ + "about": "English signature captions that name the signer's role", + "text": { + "words": [ + { + "text": "Signature", + "rect": [72, 653.2, 42.12, 9.45] + }, + { + "text": "of", + "rect": [116.62, 653.2, 9.36, 9.45] + }, + { + "text": "Tenant", + "rect": [128.48, 653.2, 28.08, 9.45] + }, + { + "text": "Tenant's", + "rect": [320, 653.2, 37.44, 9.45] + }, + { + "text": "signature", + "rect": [359.94, 653.2, 42.12, 9.45] + }, + { + "text": "Landlord", + "rect": [72, 712, 41.6, 10.5] + }, + { + "text": "signature:", + "rect": [116.38, 712, 49.58, 10.5] + }, + { + "text": "______________________", + "rect": [168.74, 712, 122.32, 10.5] + } + ], + "rules": [ + [72, 649.75, 200, 0.5], + [320, 649.75, 200, 0.5] + ] + }, + "expected": [ + { + "kind": "signature", + "rect": [72, 613.75, 200, 36] + }, + { + "kind": "signature", + "rect": [320, 613.75, 200, 36] + }, + { + "kind": "signature", + "rect": [168.74, 705.7, 122.32, 16.8] + } + ] +} diff --git a/src/signing/__fixtures__/labels/en-table-trap.json b/src/signing/__fixtures__/labels/en-table-trap.json new file mode 100644 index 00000000..8195baec --- /dev/null +++ b/src/signing/__fixtures__/labels/en-table-trap.json @@ -0,0 +1,54 @@ +{ + "about": "A table whose header says Date, between drawn borders: no places", + "text": { + "words": [ + { + "text": "Payments", + "rect": [72, 262, 41.6, 10.5] + }, + { + "text": "received", + "rect": [116.38, 262, 41.6, 10.5] + }, + { + "text": "so", + "rect": [160.76, 262, 10.4, 10.5] + }, + { + "text": "far:", + "rect": [173.94, 262, 18.38, 10.5] + }, + { + "text": "Date", + "rect": [76, 304, 20.8, 10.5] + }, + { + "text": "Amount", + "rect": [276, 304, 31.2, 10.5] + }, + { + "text": "2026-01-05", + "rect": [76, 322, 52, 10.5] + }, + { + "text": "100", + "rect": [276, 322, 15.6, 10.5] + }, + { + "text": "EUR", + "rect": [294.38, 322, 15.6, 10.5] + }, + { + "text": "Datum", + "rect": [76, 340, 26, 10.5] + } + ], + "rules": [ + [72, 299.75, 400, 0.5], + [72, 317.75, 400, 0.5], + [72, 335.75, 400, 0.5], + [72, 353.75, 200, 0.5] + ] + }, + "expected": [] +} diff --git a/src/signing/__fixtures__/labels/form-pdf.json b/src/signing/__fixtures__/labels/form-pdf.json new file mode 100644 index 00000000..f2a23a10 --- /dev/null +++ b/src/signing/__fixtures__/labels/form-pdf.json @@ -0,0 +1,54 @@ +{ + "about": "src-tauri/pdf/fixtures/form.pdf: its words where pdftotext -bbox finds them, in boxes as the helper gives them, and its rule (0.5 w 100 490 m 300 490 l S) drawn by hand", + "text": { + "words": [ + { + "text": "Contract", + "rect": [100, 72.4, 45.35, 12.6] + }, + { + "text": "A-17", + "rect": [352, 128, 21.12, 10.5] + }, + { + "text": "Apple", + "rect": [352, 205, 25.57, 10.5] + }, + { + "text": "Pear", + "rect": [352, 216, 21.12, 10.5] + }, + { + "text": "Green", + "rect": [102, 228, 27.79, 10.5] + }, + { + "text": "Unterschrift", + "rect": [100, 364, 51.12, 10.5] + }, + { + "text": "Ort,", + "rect": [350, 364, 16.67, 10.5] + }, + { + "text": "Datum:", + "rect": [369.45, 364, 32.23, 10.5] + }, + { + "text": "______________", + "rect": [404.46, 364, 77.84, 10.5] + } + ], + "rules": [[100, 351.75, 200, 0.5]] + }, + "expected": [ + { + "kind": "signature", + "rect": [100, 315.75, 200, 36] + }, + { + "kind": "placeAndDate", + "rect": [404.46, 357.7, 77.84, 16.8] + } + ] +} diff --git a/src/signing/__fixtures__/labels/glued-labels.json b/src/signing/__fixtures__/labels/glued-labels.json new file mode 100644 index 00000000..49fee4b4 --- /dev/null +++ b/src/signing/__fixtures__/labels/glued-labels.json @@ -0,0 +1,26 @@ +{ + "about": "Labels written into the same word as their underscores", + "text": { + "words": [ + { + "text": "Signature:______________________________", + "rect": [72, 492, 216.38, 10.5] + }, + { + "text": "Datum:____________________", + "rect": [72, 532, 139.98, 10.5] + } + ], + "rules": [] + }, + "expected": [ + { + "kind": "signature", + "rect": [126.1, 485.7, 162.29, 16.8] + }, + { + "kind": "date", + "rect": [104.3, 525.7, 107.68, 16.8] + } + ] +} diff --git a/src/signing/__fixtures__/labels/initials.json b/src/signing/__fixtures__/labels/initials.json new file mode 100644 index 00000000..7f662e2d --- /dev/null +++ b/src/signing/__fixtures__/labels/initials.json @@ -0,0 +1,46 @@ +{ + "about": "Initials at the foot of a page, in English, German and French-style German", + "text": { + "words": [ + { + "text": "Initials:", + "rect": [420, 792, 44.38, 10.5] + }, + { + "text": "__________", + "rect": [467.16, 792, 55.6, 10.5] + }, + { + "text": "Kürzel", + "rect": [72, 792, 31.2, 10.5] + }, + { + "text": "________", + "rect": [105.98, 792, 44.48, 10.5] + }, + { + "text": "Paraphe:", + "rect": [230, 792, 39.18, 10.5] + }, + { + "text": "_________", + "rect": [271.96, 792, 50.04, 10.5] + } + ], + "rules": [] + }, + "expected": [ + { + "kind": "initials", + "rect": [467.16, 785.7, 55.6, 16.8] + }, + { + "kind": "initials", + "rect": [105.98, 785.7, 44.48, 16.8] + }, + { + "kind": "initials", + "rect": [271.96, 785.7, 50.04, 16.8] + } + ] +} diff --git a/src/signing/__fixtures__/labels/labels-above-lines.json b/src/signing/__fixtures__/labels/labels-above-lines.json new file mode 100644 index 00000000..e164222e --- /dev/null +++ b/src/signing/__fixtures__/labels/labels-above-lines.json @@ -0,0 +1,45 @@ +{ + "about": "Labels above drawn rules, and Sign here left of underscores", + "text": { + "words": [ + { + "text": "Signature", + "rect": [72, 492, 46.8, 10.5] + }, + { + "text": "Datum", + "rect": [320, 492, 26, 10.5] + }, + { + "text": "Sign", + "rect": [72, 592, 20.8, 10.5] + }, + { + "text": "here:", + "rect": [95.58, 592, 23.58, 10.5] + }, + { + "text": "____________________", + "rect": [121.94, 592, 111.2, 10.5] + } + ], + "rules": [ + [72, 539.75, 200, 0.5], + [320, 524.75, 150, 0.5] + ] + }, + "expected": [ + { + "kind": "signature", + "rect": [72, 502.5, 200, 37.25] + }, + { + "kind": "date", + "rect": [320, 502.5, 150, 22.25] + }, + { + "kind": "signature", + "rect": [121.94, 585.7, 111.2, 16.8] + } + ] +} diff --git a/src/signing/__fixtures__/labels/stamps-and-dates.json b/src/signing/__fixtures__/labels/stamps-and-dates.json new file mode 100644 index 00000000..82c26421 --- /dev/null +++ b/src/signing/__fixtures__/labels/stamps-and-dates.json @@ -0,0 +1,133 @@ +{ + "about": "Signature captions that ask for a stamp too, and the date of signing", + "text": { + "words": [ + { + "text": "Signatur/Stempel", + "rect": [72, 603.2, 74.88, 9.45] + }, + { + "text": "Unterschrift/Stempel", + "rect": [210, 603.2, 93.6, 9.45] + }, + { + "text": "Unterschrift", + "rect": [340, 603.2, 56.16, 9.45] + }, + { + "text": "und", + "rect": [398.66, 603.2, 14.04, 9.45] + }, + { + "text": "Stempel", + "rect": [415.2, 603.2, 32.76, 9.45] + }, + { + "text": "Stempel", + "rect": [470, 603.2, 32.76, 9.45] + }, + { + "text": "und", + "rect": [505.26, 603.2, 14.04, 9.45] + }, + { + "text": "Unterschrift", + "rect": [521.8, 603.2, 56.16, 9.45] + }, + { + "text": "Datum", + "rect": [72, 683.2, 23.4, 9.45] + }, + { + "text": "der", + "rect": [97.9, 683.2, 14.04, 9.45] + }, + { + "text": "Unterschrift", + "rect": [114.44, 683.2, 56.16, 9.45] + }, + { + "text": "Date", + "rect": [260, 683.2, 18.72, 9.45] + }, + { + "text": "of", + "rect": [281.22, 683.2, 9.36, 9.45] + }, + { + "text": "signature", + "rect": [293.08, 683.2, 42.12, 9.45] + }, + { + "text": "Date", + "rect": [72, 732, 20.8, 10.5] + }, + { + "text": "signed:", + "rect": [95.58, 732, 33.98, 10.5] + }, + { + "text": "______________", + "rect": [132.34, 732, 77.84, 10.5] + }, + { + "text": "Ort", + "rect": [320, 732, 15.6, 10.5] + }, + { + "text": "und", + "rect": [338.38, 732, 15.6, 10.5] + }, + { + "text": "Datum:", + "rect": [356.76, 732, 28.78, 10.5] + }, + { + "text": "____________________", + "rect": [388.32, 732, 111.2, 10.5] + } + ], + "rules": [ + [72, 599.75, 110, 0.5], + [210, 599.75, 110, 0.5], + [340, 599.75, 110, 0.5], + [470, 599.75, 110, 0.5], + [72, 679.75, 160, 0.5], + [260, 679.75, 160, 0.5] + ] + }, + "expected": [ + { + "kind": "signature", + "rect": [72, 563.75, 110, 36] + }, + { + "kind": "signature", + "rect": [210, 563.75, 110, 36] + }, + { + "kind": "signature", + "rect": [340, 563.75, 110, 36] + }, + { + "kind": "signature", + "rect": [470, 563.75, 110, 36] + }, + { + "kind": "date", + "rect": [72, 661.75, 160, 18] + }, + { + "kind": "date", + "rect": [260, 661.75, 160, 18] + }, + { + "kind": "date", + "rect": [132.34, 725.7, 77.84, 16.8] + }, + { + "kind": "placeAndDate", + "rect": [388.32, 725.7, 111.2, 16.8] + } + ] +} diff --git a/src/signing/__fixtures__/labels/whose-signature.json b/src/signing/__fixtures__/labels/whose-signature.json new file mode 100644 index 00000000..f78257c6 --- /dev/null +++ b/src/signing/__fixtures__/labels/whose-signature.json @@ -0,0 +1,65 @@ +{ + "about": "Signature labels that say whose they are", + "text": { + "words": [ + { + "text": "Unterschrift", + "rect": [72, 653.2, 56.16, 9.45] + }, + { + "text": "des", + "rect": [130.66, 653.2, 14.04, 9.45] + }, + { + "text": "Vermieters", + "rect": [147.2, 653.2, 46.8, 9.45] + }, + { + "text": "Signature", + "rect": [320, 653.2, 42.12, 9.45] + }, + { + "text": "of", + "rect": [364.62, 653.2, 9.36, 9.45] + }, + { + "text": "the", + "rect": [376.48, 653.2, 14.04, 9.45] + }, + { + "text": "tenant", + "rect": [393.03, 653.2, 28.08, 9.45] + }, + { + "text": "Signed", + "rect": [72, 712, 31.2, 10.5] + }, + { + "text": "by:", + "rect": [105.98, 712, 13.18, 10.5] + }, + { + "text": "______________________", + "rect": [121.94, 712, 122.32, 10.5] + } + ], + "rules": [ + [72, 649.75, 200, 0.5], + [320, 649.75, 200, 0.5] + ] + }, + "expected": [ + { + "kind": "signature", + "rect": [72, 613.75, 200, 36] + }, + { + "kind": "signature", + "rect": [320, 613.75, 200, 36] + }, + { + "kind": "signature", + "rect": [121.94, 705.7, 122.32, 16.8] + } + ] +} diff --git a/src/signing/exportPlan.test.ts b/src/signing/exportPlan.test.ts new file mode 100644 index 00000000..ce579555 --- /dev/null +++ b/src/signing/exportPlan.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { type ExportSigning, exportRequest } from "./exportPlan"; +import { initialsRect, isInitialsChoice } from "./initials"; + +const BOX = { page: 0, x: 70, y: 600, width: 230, height: 70 }; +const A4 = { width: 595.28, height: 841.89 }; + +const signing = (changes: Partial = {}): ExportSigning => ({ + passphrase: null, + place: "Berlin", + reason: "", + timestampServer: "https://tsa.example", + signatureBox: BOX, + others: [], + initials: null, + page: A4, + ...changes, +}); + +describe("a signed export's update", () => { + it("leaves a PDF without a digital signature, others or initials as it is", () => { + expect(exportRequest(signing())).toBeNull(); + }); + + it("signs over the block, with the time from the server", () => { + const { request, initials } = exportRequest( + signing({ passphrase: "secret", reason: "I agree" }), + )!; + expect(initials).toBe(false); + expect(request).toEqual({ + passphrase: "secret", + source: { kind: "body" }, + plan: { + stamps: [], + signature: { kind: "new", page: 1, rect: [70, 600, 230, 70] }, + others: [], + location: "Berlin", + reason: "I agree", + timestampServer: "https://tsa.example", + }, + }); + }); + + it("never signs without the block's box", () => { + expect(() => + exportRequest(signing({ passphrase: "secret", signatureBox: undefined })), + ).toThrow("the signature block isn't in the PDF"); + }); + + it("adds the others' fields and the initials without a signature", () => { + const { request, initials } = exportRequest( + signing({ + others: [ + { name: "Bea", box: { ...BOX, page: 2 } }, + { name: "Cy", box: null }, + ], + initials: "right", + }), + )!; + expect(initials).toBe(true); + expect(request.passphrase).toBeNull(); + expect(request.plan.signature).toEqual({ kind: "invisible" }); + expect(request.plan.timestampServer).toBeNull(); + // one not on the pages gets none + expect(request.plan.others).toEqual([ + { page: 3, rect: [70, 600, 230, 70], name: "Bea" }, + ]); + expect(request.plan.stamps).toEqual([ + { part: 1, page: 1, rect: initialsRect(A4, "right"), repeat: true }, + ]); + }); +}); + +describe("the initials on every page", () => { + it("go 36 pt from the bottom and the side chosen, 56 × 28 pt", () => { + expect(initialsRect({ width: 600, height: 800 }, "right")).toEqual([ + 508, 736, 56, 28, + ]); + expect(initialsRect({ width: 600, height: 800 }, "left")).toEqual([ + 36, 736, 56, 28, + ]); + }); + + it("are remembered only as the dialog chose them", () => { + expect(isInitialsChoice({ on: true, side: "left" })).toBe(true); + expect(isInitialsChoice({ on: "yes", side: "left" })).toBe(false); + expect(isInitialsChoice({ on: true, side: "top" })).toBe(false); + expect(isInitialsChoice(null)).toBe(false); + }); +}); diff --git a/src/signing/exportPlan.ts b/src/signing/exportPlan.ts new file mode 100644 index 00000000..095b47da --- /dev/null +++ b/src/signing/exportPlan.ts @@ -0,0 +1,75 @@ +import type { PageBox } from "../engine/boxes"; +import { type InitialsSide, initialsRect } from "./initials"; +import type { PdfRect } from "../pdf/ipc"; +import type { SignRequest } from "./ipc"; + +// What a signed export adds to the PDF the layout engine wrote, in one +// incremental update (`signing_sign_pdf` with the PDF as the body's part 0): +// the user's initials on every page, empty signature fields for the others +// who sign, and the digital signature over the filled in block. + +export interface ExportSigning { + // the key's passphrase, or null for no digital signature + passphrase: string | null; + // the place and reason the signature carries; the native side names the + // certificate's holder as who signed it + place: string; + reason: string; + timestampServer: string; + // where the filled in block is + signatureBox?: PageBox; + // the other signature blocks, by who signs them, where the PDF shows them + others: { name: string; box: PageBox | null }[]; + // the corner the initials go in on every page, or null for none + initials: InitialsSide | null; + // the size of the pages, in points + page: { width: number; height: number }; +} + +const rectOf = (box: PageBox): PdfRect => [box.x, box.y, box.width, box.height]; + +/** + * exportRequest returns what to ask the signer for, with the body's parts + * after the PDF: the initials' stamp when they go on the pages (part 1). + * null when there's nothing to add, so the PDF is written as the engine + * made it: no digital signature, no other signers and no initials. + */ +export const exportRequest = ( + signing: ExportSigning, +): { request: SignRequest; initials: boolean } | null => { + const others = signing.others.flatMap(({ name, box }) => + box ? [{ page: box.page + 1, rect: rectOf(box), name }] : [], + ); + const digital = signing.passphrase !== null; + if (!digital && others.length === 0 && !signing.initials) return null; + const box = signing.signatureBox; + if (digital && !box) throw new Error("the signature block isn't in the PDF"); + return { + initials: !!signing.initials, + request: { + passphrase: signing.passphrase, + source: { kind: "body" }, + plan: { + stamps: signing.initials + ? [ + { + part: 1, + page: 1, + rect: initialsRect(signing.page, signing.initials), + repeat: true, + }, + ] + : [], + // the block is in the page content already, from the engine + signature: + digital && box + ? { kind: "new", page: box.page + 1, rect: rectOf(box) } + : { kind: "invisible" }, + others, + location: signing.place || null, + reason: signing.reason || null, + timestampServer: (digital && signing.timestampServer) || null, + }, + }, + }; +}; diff --git a/src/signing/initials.ts b/src/signing/initials.ts new file mode 100644 index 00000000..d46eb7a8 --- /dev/null +++ b/src/signing/initials.ts @@ -0,0 +1,60 @@ +import { LINE_SIZE, type StampSpec } from "../pdf/stamps"; +import type { PdfRect } from "../pdf/ipc"; + +// The user's initials on every page of what they sign: one stamp, drawn at +// the same place on each page (`repeat`), in a bottom corner. + +// which bottom corner the initials go in +export type InitialsSide = "right" | "left"; + +// what the Sign dialog remembers of the initials: whether they go on every +// page, and where +export interface InitialsChoice { + on: boolean; + side: InitialsSide; +} + +export const INITIALS_DEFAULT: InitialsChoice = { on: false, side: "right" }; + +// the stamp's box, in points, and how far it is from the page's edges: also +// where initials placed on a PDF tab from the keyboard go +export const INITIALS_BOX = { width: 56, height: 28 }; +export const INITIALS_MARGIN = 36; + +// the stamp: the initials alone, contained in the box, as a PDF tab's +// initials stamp shows them +export const INITIALS_STAMP: StampSpec = { + image: "initials", + lines: [], + size: LINE_SIZE, + align: "center", +}; + +/** + * initialsRect returns where initials `size` points large (the stamp's box + * unless given) go on a page of `page`'s size, in points from its top left: + * `INITIALS_MARGIN` from the bottom and from the side chosen + */ +export const initialsRect = ( + page: { width: number; height: number }, + side: InitialsSide, + [width, height]: readonly [number, number] = [ + INITIALS_BOX.width, + INITIALS_BOX.height, + ], +): PdfRect => [ + side === "right" ? page.width - INITIALS_MARGIN - width : INITIALS_MARGIN, + page.height - INITIALS_MARGIN - height, + width, + height, +]; + +/** + * isInitialsChoice tells whether a stored value is what the dialog + * remembers + */ +export const isInitialsChoice = (value: unknown): value is InitialsChoice => + !!value && + typeof value === "object" && + typeof (value as InitialsChoice).on === "boolean" && + ["right", "left"].includes((value as InitialsChoice).side); diff --git a/src/signing/ipc.test.ts b/src/signing/ipc.test.ts new file mode 100644 index 00000000..8130c5fc --- /dev/null +++ b/src/signing/ipc.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); + +import { encodeParts, signPdf, type SignRequest } from "./ipc"; + +describe("signPdf", () => { + beforeEach(() => { + invoke.mockResolvedValue(new Uint8Array([9]).buffer); + }); + + it("packs the parts with their lengths, as the native side reads them", () => { + const body = encodeParts([new Uint8Array([1, 2, 3]), new Uint8Array()]); + const view = new DataView(body.buffer); + expect(view.getUint32(0, true)).toBe(2); + expect(view.getBigUint64(4, true)).toBe(3n); + expect([...body.slice(12, 15)]).toEqual([1, 2, 3]); + expect(view.getBigUint64(15, true)).toBe(0n); + expect(body.length).toBe(23); + }); + + it("sends the request in the header and the parts as the body", async () => { + const request: SignRequest = { + passphrase: null, + source: { kind: "body" }, + plan: { + stamps: [{ part: 1, page: 1, rect: [1, 2, 3, 4], repeat: true }], + signature: { kind: "invisible" }, + others: [{ page: 1, rect: [5, 6, 7, 8], name: "Bea Müller" }], + location: null, + reason: null, + timestampServer: null, + }, + }; + const parts = [new Uint8Array([1]), new Uint8Array([2])]; + expect(await signPdf(request, parts)).toEqual(new Uint8Array([9])); + const [command, body, options] = invoke.mock.calls[0]; + expect(command).toBe("signing_sign_pdf"); + expect(body).toEqual(encodeParts(parts)); + expect( + JSON.parse(decodeURIComponent(options.headers["blank-signing"])), + ).toEqual(request); + }); +}); diff --git a/src/signing/ipc.ts b/src/signing/ipc.ts index 2c98de7d..ee6287d0 100644 --- a/src/signing/ipc.ts +++ b/src/signing/ipc.ts @@ -1,5 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; +import type { Fill, PdfRect } from "../pdf/ipc"; + // The calls to the native side's signing (src-tauri/src/signing/commands.rs), // in one place. The key never comes here: the passphrase goes there once // per signing, and the signed PDF comes back. @@ -85,19 +87,74 @@ export const signingMessage = (error: unknown, fallback: string) => { } }; -// how to sign, besides the PDF and the passphrase -export interface SignOptions { - // the page of the signature's field, from 1 +// a stamp to put in the pages' content: the one-page PDF of the body's +// part `part`, on page `page` (from 1); `repeat` puts it on every page at +// the same place, moved into smaller pages +export interface StampPlan { + part: number; + page: number; + rect: PdfRect; + repeat: boolean; +} + +// where the signature goes: nowhere on the pages, an empty signature field +// the PDF has (its full name), or a new field; `stamp` is the body's part +// with the stamp it shows +export type SignaturePlan = + | { kind: "invisible" } + | { kind: "existing"; field: string; stamp?: number | null } + | { kind: "new"; page: number; rect: PdfRect; stamp?: number | null }; + +// an empty signature field for someone else to sign later, called +// "Signature of ‹name›" +export interface OtherSigner { page: number; - // where the signature shows, in points from the page's top left; none for - // an invisible signature - rect: [number, number, number, number] | null; + rect: PdfRect; + name: string; +} + +// what to add to the PDF, and what the signature says +export interface SignPlan { + stamps: StampPlan[]; + signature: SignaturePlan; + others: OtherSigner[]; location: string | null; reason: string | null; // an RFC 3161 server, for a timestamp timestampServer: string | null; } +// where the PDF comes from: the body's part 0 (an export), or a PDF tab's +// file, which must still be the one it opened, with the tab's fills +export type SignSource = + | { kind: "body" } + | { kind: "tab"; tab: string; path: string; sha256: string; fills: Fill[] }; + +export interface SignRequest { + // null: the stamps and fields alone, no signature + passphrase: string | null; + source: SignSource; + plan: SignPlan; +} + +/** + * encodeParts packs `parts` into one body: a little-endian u32 count, then + * each part as a little-endian u64 length and its bytes + */ +export const encodeParts = (parts: Uint8Array[]) => { + const size = parts.reduce((sum, part) => sum + 8 + part.length, 4); + const body = new Uint8Array(size); + const view = new DataView(body.buffer); + view.setUint32(0, parts.length, true); + let at = 4; + for (const part of parts) { + view.setBigUint64(at, BigInt(part.length), true); + body.set(part, at + 8); + at += 8 + part.length; + } + return body; +}; + export const certificate = () => invoke("signing_certificate"); @@ -130,23 +187,37 @@ export const checkPassphrase = (passphrase: string) => invoke("signing_check_passphrase", { passphrase }); /** - * signPdf signs `pdf` with the user's key and returns the signed PDF + * signPdf adds `request.plan` to a PDF and signs it with the user's key, or + * without a passphrase adds the stamps and fields alone, and returns the + * PDF. `parts` are the body: for `source.kind` "body" the PDF first, then + * the stamps' PDFs, which `plan` names by their index in `parts`. */ -export const signPdf = async ( - pdf: Uint8Array, - passphrase: string, - options: SignOptions, -) => +export const signPdf = async (request: SignRequest, parts: Uint8Array[]) => new Uint8Array( - await invoke("signing_sign_pdf", pdf, { + await invoke("signing_sign_pdf", encodeParts(parts), { headers: { - "blank-signing": encodeURIComponent( - JSON.stringify({ passphrase, options }), - ), + "blank-signing": encodeURIComponent(JSON.stringify(request)), }, }), ); +// what may be placed on a PDF a tab shows (`signing_pdf_rules`, see +// .claude/rules/pdf.md, "Stamps"): whether it is signed already, its DocMDP permission (1: no changes, +// 2: filling in and signing, 3: annotations too), whether it's encrypted or +// would need repairing, and the fields earlier signatures locked +export interface PdfRules { + signed: boolean; + docmdp: number | null; + encrypted: boolean; + damaged: boolean; + locked: string[]; +} + +// `sha256`: the PDF the tab opened, else it fails `fileChanged` or +// `fileMissing` +export const pdfRules = (path: string, sha256: string) => + invoke("signing_pdf_rules", { path, sha256 }); + // how far Blank trusts who signed (src-tauri/src/signing/trust.rs): the // user's own certificate, one they trusted, one a certificate authority // issued (which Blank doesn't check), or an unknown self-signed one @@ -176,7 +247,8 @@ export interface CheckedSignature { timestampTrusted: boolean; // the weak algorithm it relies on ("SHA-1"), if it does weak: string | null; - // for signedAgainLater: who signed next, after the changes + // for signedAgainLater, and for a valid one that later revisions only + // signed again: who signed next laterSigner: string | null; reason: string | null; location: string | null; diff --git a/src/signing/labels.test.ts b/src/signing/labels.test.ts new file mode 100644 index 00000000..215fb9e9 --- /dev/null +++ b/src/signing/labels.test.ts @@ -0,0 +1,160 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { PageText, PdfRect as Rect } from "../pdf/ipc"; +import { findLabels, type PlaceKind } from "./labels"; + +// The fixtures are pages as the helper reads them, each with the places it +// has: hand-made forms and traps, and src-tauri/pdf/fixtures/form.pdf. +interface Fixture { + about: string; + text: PageText; + expected: { kind: PlaceKind; rect: Rect }[]; +} + +const folder = join(__dirname, "__fixtures__", "labels"); +const fixtures = readdirSync(folder) + .filter((name) => name.endsWith(".json")) + .map((name) => ({ + name, + ...(JSON.parse(readFileSync(join(folder, name), "utf8")) as Fixture), + })); + +const iou = (a: Rect, b: Rect) => { + const width = Math.min(a[0] + a[2], b[0] + b[2]) - Math.max(a[0], b[0]); + const height = Math.min(a[1] + a[3], b[1] + b[3]) - Math.max(a[1], b[1]); + const shared = Math.max(0, width) * Math.max(0, height); + return shared / (a[2] * a[3] + b[2] * b[3] - shared); +}; + +// a found place is right when it asks for what an expected one does, about +// where it is +const matches = ( + found: { kind: PlaceKind; rect: Rect }, + expected: { kind: PlaceKind; rect: Rect }, +) => found.kind === expected.kind && iou(found.rect, expected.rect) >= 0.5; + +describe("found places", () => { + it("finds the places of the fixtures, with few wrong ones", () => { + // found places that are expected ones, and expected ones found + let right = 0; + let caughtCount = 0; + let foundCount = 0; + let expectedCount = 0; + const rows: string[] = []; + for (const fixture of fixtures) { + const found = findLabels(fixture.text, 0); + const hits = found.filter((place) => + fixture.expected.some((expected) => matches(place, expected)), + ).length; + const caught = fixture.expected.filter((expected) => + found.some((place) => matches(place, expected)), + ).length; + right += hits; + caughtCount += caught; + foundCount += found.length; + expectedCount += fixture.expected.length; + rows.push( + `${fixture.name}: ${hits}/${found.length} found right, ${caught}/${fixture.expected.length} expected found`, + ); + } + const precision = foundCount ? right / foundCount : 1; + const recall = expectedCount ? caughtCount / expectedCount : 1; + // which fixture missed, said only when one did + const report = `${rows.join("\n")}\nprecision ${precision.toFixed(3)}, recall ${recall.toFixed(3)}`; + expect(precision, report).toBe(1); + expect(recall, report).toBe(1); + }); + + it("finds form.pdf's signature over its rule and its place and date", () => { + const fixture = fixtures.find((each) => each.name === "form-pdf.json")!; + const found = findLabels(fixture.text, 2); + expect( + found.map(({ kind, label, page }) => ({ kind, label, page })), + ).toEqual([ + { kind: "placeAndDate", label: "Ort, Datum", page: 2 }, + { kind: "signature", label: "Unterschrift", page: 2 }, + ]); + expect(found[1].rect).toEqual([100, 315.75, 200, 36]); + }); + + it("finds nothing in running text or a table", () => { + for (const name of ["en-prose-traps.json", "en-table-trap.json"]) { + const fixture = fixtures.find((each) => each.name === name)!; + expect(findLabels(fixture.text, 0)).toEqual([]); + } + }); + + it("names a signature by the role its caption gives", () => { + const labels = (name: string) => + findLabels(fixtures.find((each) => each.name === name)!.text, 0).map( + (place) => place.label, + ); + expect(labels("de-roles.json")).toEqual([ + "Signature · Arbeitgeber", + "Signature · Arbeitnehmer", + "Signature · Mieter", + "Signature · Vermieter/in", + "Signature · Mieter/in", + "Signature · Erziehungsberechtigte(r)", + "Signature · Auftraggeber, Auftragnehmer", + ]); + expect(labels("en-roles.json")).toEqual([ + "Signature · Landlord", + "Signature of Tenant", + "Signature · Tenant", + ]); + }); + + it("takes no label, name or article for a role", () => { + const caption = (words: string) => { + const text: PageText = { words: [], rules: [[72, 100, 200, 0.5]] }; + let x = 72; + for (const word of words.split(" ")) { + text.words.push({ text: word, rect: [x, 104, word.length * 5, 9] }); + x += word.length * 5 + 3; + } + return findLabels(text, 0).map(({ kind, label }) => ({ kind, label })); + }; + expect(caption("Datum Unterschrift")).toEqual([]); + expect(caption("Name Unterschrift")).toEqual([]); + expect(caption("Your signature")).toEqual([]); + expect(caption("Unterschrift Kunde")).toEqual([ + { kind: "signature", label: "Signature · Kunde" }, + ]); + expect(caption("unterschrift kunde")).toEqual([]); + }); + + it("gives an unpaired label nothing", () => { + const text: PageText = { + words: [{ text: "Signature:", rect: [72, 100, 50, 9.25] }], + rules: [], + }; + expect(findLabels(text, 0)).toEqual([]); + }); + + it("finds a place once when underscores lie on a drawn rule", () => { + const text: PageText = { + words: [ + { text: "Date:", rect: [72, 100, 25, 9.25] }, + { text: "__________", rect: [100, 100, 55.6, 9.25] }, + ], + rules: [[100, 108.5, 56, 0.5]], + }; + expect(findLabels(text, 0)).toHaveLength(1); + }); + + it("leaves a line alone that a short rule or another word takes", () => { + const text: PageText = { + words: [ + { text: "Date:", rect: [72, 100, 25, 9.25] }, + { text: "today", rect: [100, 100, 25, 9.25] }, + { text: "__________", rect: [130, 100, 55.6, 9.25] }, + ], + rules: [[300, 100, 30, 0.5]], + }; + expect(findLabels(text, 0)).toEqual([]); + }); +}); diff --git a/src/signing/labels.ts b/src/signing/labels.ts new file mode 100644 index 00000000..f5165909 --- /dev/null +++ b/src/signing/labels.ts @@ -0,0 +1,430 @@ +import type { PageText, PdfRect as Rect } from "../pdf/ipc"; + +// Found places: where a PDF without a form asks for a signature, a date or a +// place, read from the words and lines of its pages. A label ("Unterschrift", +// "Ort, Datum:", "(signature)") counts only next to a line to write on (a run +// of underscores or dots, or a drawn rule), so a word in running text finds +// nothing: a place that isn't there is worse than one the user adds. + +export type PlaceKind = + "signature" | "date" | "place" | "placeAndDate" | "initials"; + +// a place found on the page `page` (from 0): what goes there, where, and the +// label it was found by, as the page writes it +export interface FoundPlace { + page: number; + kind: PlaceKind; + rect: Rect; + label: string; + // the size of the label's letters, in points, which a date or place + // written there takes + size: number; +} + +// a word's box spans 0.8 em above its baseline and 0.25 below (`PageText` +// in src-tauri/pdf/ipc), so its letters are its height / WORD_EM large +const WORD_EM = 1.05; + +// the labels, normalized (see `normalize`) +const LABELS: Record = { + signature: "signature", + unterschrift: "signature", + signed: "signature", + "sign here": "signature", + signatur: "signature", + date: "date", + datum: "date", + place: "place", + ort: "place", + "place, date": "placeAndDate", + "date, place": "placeAndDate", + "ort, datum": "placeAndDate", + "datum, ort": "placeAndDate", + unterschriften: "signature", + "signatur, stempel": "signature", + "stempel, signatur": "signature", + "unterschrift, stempel": "signature", + "stempel, unterschrift": "signature", + "unterschrift stempel": "signature", + "signature, stamp": "signature", + "stamp, signature": "signature", + "datum der unterschrift": "date", + unterschriftsdatum: "date", + "date of signature": "date", + "date of signing": "date", + "date signed": "date", + "signing date": "date", + "signature date": "date", + initials: "initials", + kürzel: "initials", + paraphe: "initials", +}; + +// the words that may follow a signature's label and say whose it is, as in +// "Signature of the tenant" or "Unterschrift des Mieters" +const WHOSE = new Set(["of", "by", "for", "des", "der", "von", "vom", "für"]); + +// how far a label may be from its line, in points +const BASELINE = 3; +const LEFT_GAP = 48; +const BELOW = 14; +const ABOVE_MIN = 12; +const ABOVE_MAX = 72; +// how far apart two places may be on every side and still be one +export const NEAR = 4; + +const RUN = /_{5,}|\.{8,}|…{3,}/gu; +const LETTER = /[\p{L}\p{N}]/u; + +/** + * normalize returns a label as LABELS has it: in lower case, without its + * colon, brackets or asterisk, with "/", "&", "und" and "and" between two + * words written as ", " + */ +const normalize = (text: string) => + text + .normalize("NFC") + .toLowerCase() + .replace(/[():*"“”„]/gu, "") + .replace(/\s+(?:und|and|&)\s+/gu, ", ") + .replace(/\s*[,/]\s*/gu, ", ") + .replace(/[\s,.;]+$/u, "") + .replace(/^[\s,.;]+/u, "") + .replace(/\s+/gu, " "); + +// the words for a signature that a role may stand next to, as in +// "Unterschrift Mieter", "Mieter/in (Unterschrift)" or "Tenant's signature" +const SIGNATURE_WORDS = new Set([ + "signature", + "unterschrift", + "unterschriften", + "signatur", +]); +// a role: a word with a capital, e.g. "Vermieter", "Mieter/in", +// "Erziehungsberechtigte(r)" or "Tenant's" +const ROLE = /^\p{Lu}[\p{L}'’/()-]*[,:]?$/u; +// capitalised words next to a signature's label that aren't whose it is +const NOT_ROLES = new Set([ + "the", + "a", + "an", + "your", + "my", + "our", + "his", + "her", + "their", + "this", + "please", + "die", + "der", + "das", + "ihre", + "ihr", + "eine", + "ein", + "seine", + "meine", + "unsere", + "bitte", + "name", + "vorname", + "nachname", + "stempel", + "stamp", + "seite", + "page", +]); + +/** + * roleOf returns the role one or two capitalised words name, e.g. "Tenant" + * for "Tenant's"; none for other words, or a label's + */ +const roleOf = (texts: string[]) => { + if (!texts.length || texts.length > 2) return undefined; + const roles = texts.every((text) => { + const word = normalize(text); + return ( + ROLE.test(text) && + !NOT_ROLES.has(word) && + !LABELS[word] && + !SIGNATURE_WORDS.has(word) + ); + }); + return roles + ? texts + .join(" ") + .replace(/[,:]$/u, "") + .replace(/['’]s$/u, "") + : undefined; +}; + +// what a label asks for, and whose signature it is if it names a role +interface Label { + kind: PlaceKind; + role?: string; +} + +/** + * labelOf returns what the words `texts` ask for, if they are a label as a + * whole, a signature's label followed by whose it is in up to three words, + * or a signature's label with a role before or after it + */ +const labelOf = (texts: string[]): Label | undefined => { + const whole = LABELS[normalize(texts.join(" "))]; + if (whole) return { kind: whole }; + for (const length of [2, 1]) { + if (texts.length <= length || texts.length > length + 3) continue; + const kind = LABELS[normalize(texts.slice(0, length).join(" "))]; + if ( + (kind === "signature" || kind === "initials") && + WHOSE.has(normalize(texts[length])) + ) + return { kind }; + } + const role = + (SIGNATURE_WORDS.has(normalize(texts[0])) && roleOf(texts.slice(1))) || + (SIGNATURE_WORDS.has(normalize(texts[texts.length - 1])) && + roleOf(texts.slice(0, -1))); + return role ? { kind: "signature", role } : undefined; +}; + +// a word of the page, or the part of one that is text or a line +interface Token { + text: string; + rect: Rect; + line: boolean; +} + +const right = (rect: Rect) => rect[0] + rect[2]; +const bottom = (rect: Rect) => rect[1] + rect[3]; +const centre = (rect: Rect) => rect[0] + rect[2] / 2; + +/** + * tokensOf splits the words at their runs of underscores or dots, e.g. + * "Datum:__________" into "Datum:" and a line, sharing the word's width out + * by characters + */ +const tokensOf = (words: PageText["words"]): Token[] => + words.flatMap(({ text, rect }) => { + const chars = [...text]; + const at = (index: number) => + rect[0] + (rect[2] * [...text.slice(0, index)].length) / chars.length; + const tokens: Token[] = []; + const add = (start: number, end: number, line: boolean) => { + const part = text.slice(start, end); + if (!line && !LETTER.test(part)) return; + const x = at(start); + tokens.push({ + text: part.trim(), + rect: [x, rect[1], at(end) - x, rect[3]], + line, + }); + }; + let last = 0; + for (const match of text.matchAll(RUN)) { + add(last, match.index, false); + add(match.index, match.index + match[0].length, true); + last = match.index + match[0].length; + } + add(last, text.length, false); + return tokens; + }); + +/** + * overlap returns the area two rects share + */ +export const overlap = (a: Rect, b: Rect) => + Math.max(0, Math.min(right(a), right(b)) - Math.max(a[0], b[0])) * + Math.max(0, Math.min(bottom(a), bottom(b)) - Math.max(a[1], b[1])); + +/** + * near tells whether two rects are within NEAR points on every side + */ +export const near = (a: Rect, b: Rect) => + Math.abs(a[0] - b[0]) <= NEAR && + Math.abs(a[1] - b[1]) <= NEAR && + Math.abs(right(a) - right(b)) <= NEAR && + Math.abs(bottom(a) - bottom(b)) <= NEAR; + +/** + * mergeNear keeps the first of places on the same page within NEAR points of + * each other, e.g. a run of underscores over a drawn rule + */ +export const mergeNear = ( + places: T[], +): T[] => + places.reduce((kept, place) => { + if ( + !kept.some( + (other) => other.page === place.page && near(other.rect, place.rect), + ) + ) + kept.push(place); + return kept; + }, []); + +/** + * findLabels returns the places the page `page` asks to be signed, dated or + * given a place on, in the order they were found; `text` is what the helper + * read of the page + */ +export const findLabels = (text: PageText, page: number): FoundPlace[] => { + const tokens = tokensOf(text.words); + const words = tokens.filter((token) => !token.line); + // every line as a thin rect where it is drawn: a run of characters at the + // bottom of its word + const lines: Rect[] = [ + ...tokens + .filter((token) => token.line) + .map((token): Rect => [ + token.rect[0], + bottom(token.rect) - 1, + token.rect[2], + 1, + ]), + ...text.rules.filter((rule) => rule[2] >= 40), + ]; + const usedWords = new Set(); + const usedLines = new Set(); + const found: FoundPlace[] = []; + + const sameLine = (a: Token, b: Token) => + Math.abs(bottom(a.rect) - bottom(b.rect)) <= BASELINE; + // the gap between two words of one phrase + const spacing = (token: Token) => Math.max(4, token.rect[3] * 0.8); + + // a place must be free to write in: no word of the page covers half of + // itself there, but the label's and the lines' + const free = (rect: Rect, label: Token[]) => + words.every( + (word) => + label.includes(word) || + overlap(word.rect, rect) <= (word.rect[2] * word.rect[3]) / 2, + ); + + const add = ( + { kind, role }: Label, + rect: Rect, + line: Rect, + label: Token[], + ) => { + if (!free(rect, label)) return false; + usedLines.add(line); + for (const word of label) usedWords.add(word); + const written = label + .map((word) => word.text) + .join(" ") + .replace(/^\(|\)$|:$/gu, "") + .trim(); + found.push({ + page, + kind, + rect, + label: role ? `Signature · ${role}` : written, + size: Math.max(...label.map((word) => word.rect[3])) / WORD_EM, + }); + return true; + }; + + // a caption: the words of the text line nearest to the line among + // `candidates` that stand over or under it, if no other word of their text + // line runs on from them + const caption = ( + candidates: Token[], + nearest: (a: Token, b: Token) => number, + ) => { + if (!candidates.length) return undefined; + const first = [...candidates].sort(nearest)[0]; + const label = candidates + .filter((word) => sameLine(word, first)) + .sort((a, b) => a.rect[0] - b.rect[0]); + const start = label[0].rect[0]; + const end = right(label[label.length - 1].rect); + const runsOn = words.some( + (word) => + !label.includes(word) && + sameLine(word, first) && + ((right(word.rect) <= start && + start - right(word.rect) < spacing(word)) || + (word.rect[0] >= end && word.rect[0] - end < spacing(word))), + ); + return runsOn ? undefined : label; + }; + + // a label left of a line on its baseline: the place is on the line + for (const line of lines) { + const before = words + .filter( + (word) => + !usedWords.has(word) && + Math.abs(bottom(word.rect) - bottom(line)) <= BASELINE && + right(word.rect) <= line[0] + 2, + ) + .sort((a, b) => right(b.rect) - right(a.rect)); + if (!before.length || line[0] - right(before[0].rect) > LEFT_GAP) continue; + const phrase = [before[0]]; + for (const word of before.slice(1, 5)) { + const next = phrase[phrase.length - 1]; + if (next.rect[0] - right(word.rect) > spacing(word)) break; + phrase.push(word); + } + for (let length = phrase.length; length > 0; length--) { + const label = phrase.slice(0, length).reverse(); + const asked = labelOf(label.map((word) => word.text)); + if (!asked) continue; + const height = 1.6 * Math.max(...label.map((word) => word.rect[3])); + add( + asked, + [line[0], bottom(line) - height, line[2], height], + line, + label, + ); + break; + } + } + + const over = (line: Rect, word: Token) => + centre(word.rect) >= line[0] - 2 && centre(word.rect) <= right(line) + 2; + + // a label just below a line: the place is above the line + for (const line of lines) { + if (usedLines.has(line)) continue; + const label = caption( + words.filter( + (word) => + !usedWords.has(word) && + over(line, word) && + word.rect[1] >= bottom(line) - 2 && + word.rect[1] - bottom(line) <= BELOW, + ), + (a, b) => a.rect[1] - b.rect[1], + ); + const asked = label && labelOf(label.map((word) => word.text)); + if (!label || !asked) continue; + const image = asked.kind === "signature" || asked.kind === "initials"; + const height = image ? 36 : 18; + add(asked, [line[0], line[1] - height, line[2], height], line, label); + } + + // a label above a line: the place is between them + for (const line of lines) { + if (usedLines.has(line)) continue; + const label = caption( + words.filter( + (word) => + !usedWords.has(word) && + over(line, word) && + line[1] - bottom(word.rect) >= ABOVE_MIN && + line[1] - bottom(word.rect) <= ABOVE_MAX, + ), + (a, b) => bottom(b.rect) - bottom(a.rect), + ); + const asked = label && labelOf(label.map((word) => word.text)); + if (!label || !asked) continue; + const top = Math.max(...label.map((word) => bottom(word.rect))); + add(asked, [line[0], top, line[2], line[1] - top], line, label); + } + + return mergeNear(found); +}; diff --git a/src/signing/targets.ts b/src/signing/targets.ts index 037836a3..6cec9dbb 100644 --- a/src/signing/targets.ts +++ b/src/signing/targets.ts @@ -21,6 +21,7 @@ export const targetsOf = (doc: Node): SignTarget[] => { name, place: node.attrs.place as string, dated: node.attrs.date === "signing", + role: node.attrs.role as string, label: label || `Signature ${targets.length + 1}`, }); }); diff --git a/src/state/dialogs.ts b/src/state/dialogs.ts index db4d7120..a3b8828a 100644 --- a/src/state/dialogs.ts +++ b/src/state/dialogs.ts @@ -9,6 +9,7 @@ import type { BoxAnchor } from "./popups"; import type { Mark, MarkSource, SavedMark } from "../signing/profile"; import type { SignatureDate } from "../markdown/blocks/atoms"; import type { CheckedSignature } from "../signing/ipc"; +import type { InitialsChoice } from "../signing/initials"; export interface LinkDialogRequest { url: string; @@ -212,14 +213,38 @@ export interface SignTarget { place: string; // whether today's date goes under it when it's signed dated: boolean; + // as what, e.g. "Tenant", "" for none + role: string; // e.g. "Ada Lovelace · Tenant", or "Signature 2" for one that names none label: string; } +// what the user chose in the Sign dialog +export interface SignChoices { + // the signature block to fill in, by its position (the export flow); + // null for a PDF tab + pos: number | null; + // where the user signs, and why (the signature's /Reason), "" for none + place: string; + reason: string; + // the key's passphrase, or null for no digital signature (only the + // visible signature) + passphrase: string | null; + // the user's initials on every page, and in which corner + initials: InitialsChoice; + // a PDF tab: whether the signed PDF opens in a tab of its own + open: boolean; +} + +// The Sign dialog (SignDialog.vue), for both flows: "export" signs the +// document as a PDF with its signature block filled in, "pdf" signs a PDF +// tab with what the user placed on its pages export interface SignDialogRequest { + mode: "export" | "pdf"; // what is signed, e.g. the document's name title: string; - // the signature blocks the document has, the one to fill in chosen + // the export flow: the signature blocks the document has, the one to + // fill in chosen; none for a PDF tab targets: SignTarget[]; chosen: number; // where the user signed last @@ -232,24 +257,65 @@ export interface SignDialogRequest { // the notes the dialog shows, e.g. that an imported Word document's // layout can differ from Word's warnings: string[]; + // whether Sign… waits for the user to say they checked every page, e.g. + // of an imported Word document + confirmPages: boolean; + // the user's initials on every page as they start, and why they can't + // go on the pages, if they can't (e.g. a PDF others signed); without + // saved initials the dialog says so itself + initials: InitialsChoice & { reason?: string }; + // a PDF tab: changes the repeated initials stamp at once, as the switch + // and the corner change + setInitials?(initials: InitialsChoice): void; + // whether the user's signature shows on a page: the export flow's block, + // or a signature stamp placed on a PDF tab. Without it a digital + // signature is invisible and a visible one can't be made. + stamped: boolean; + // why the PDF can't be signed without a digital signature, if it can't + // (a PDF tab others signed: a signature drawn into its pages would + // change what they signed) + visibleRefusal?: string; + // a PDF tab: the places still to fill in + emptyFields: number; // whether `passphrase` opens the user's key: what's wrong, or null check(passphrase: string): Promise; - // opens the dialog that makes the user a certificate, which gives the - // focus back to `opener` + // open the dialog that makes the user a certificate, or their initials, + // which gives the focus back to `opener` createCertificate(opener: EventTarget | null): void; - // fills in the block at `pos` (signed in `place` when it says none), - // asks where to save the signed PDF and, with - // the key's passphrase, signs it digitally too - submit(pos: number, place: string, passphrase: string | null): void; + createInitials(opener: EventTarget | null): void; + // Settings ▸ Signing, once the dialog closed + manage(): void; + // signs as chosen: asks where to save the signed PDF and writes it + submit(choices: SignChoices): void; cancel(): void; } // signDialog holds the request of the open dialog that signs, or null export const signDialog = shallowRef(null); +// The small dialog that asks where the user signs (PlaceDialog.vue), for a +// place stamp before Blank knows one +export interface PlaceDialogRequest { + // what the field starts with + place: string; + // the place typed, never empty + submit(place: string): void; + cancel(): void; +} + +// placeDialog holds the request of the open question where the user signs, +// or null +export const placeDialog = shallowRef(null); + export interface UnsavedDialogRequest { // the tab's name, e.g. "notes" label: string; + // what it asks and says, and its buttons' words, where they aren't + // saving's (a PDF tab asks whether to sign it) + title?: string; + text?: string; + saveLabel?: string; + discardLabel?: string; save(): void; discard(): void; cancel(): void; @@ -347,6 +413,7 @@ export const focusTakingDialogs = [ signaturePopover, signatureDialog, signDialog, + placeDialog, certificateDialog, confirmDialog, signaturesPopover, diff --git a/src/state/index.ts b/src/state/index.ts index d131a887..87af115e 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -27,6 +27,7 @@ export * from "./recent"; export * from "./settingsDialog"; export * from "./signing"; export * from "./spellcheck"; +export * from "./stamps"; export * from "./tabs"; export * from "./toolbar"; diff --git a/src/state/signing.ts b/src/state/signing.ts index 2dc47e8d..2819ffa8 100644 --- a/src/state/signing.ts +++ b/src/state/signing.ts @@ -6,6 +6,7 @@ import { language } from "./language"; import type { CertificateInfo } from "../signing/ipc"; import type { Mark, SavedMark } from "../signing/profile"; +import { INITIALS_DEFAULT, type InitialsChoice } from "../signing/initials"; // The user's signature and initials (src/signing/profile.ts), once read: // undefined until then, null for none. They're read when something first @@ -17,6 +18,10 @@ export const savedMarks = shallowRef< // where the user signed last, which a signature's place starts as export const signingPlace = shallowRef(""); +// whether the user put their initials on every page the last time they +// signed, and in which corner, which the Sign dialog starts with +export const signingInitials = shallowRef(INITIALS_DEFAULT); + // the user's certificate as the native side has it (src/signing/ipc.ts): // undefined until read, null for none export const signingCertificate = shallowRef< diff --git a/src/state/stamps.ts b/src/state/stamps.ts new file mode 100644 index 00000000..db2e85af --- /dev/null +++ b/src/state/stamps.ts @@ -0,0 +1,64 @@ +import { shallowRef } from "vue"; + +import type { StampBook, StampKind } from "../pdf/stampModel"; +import type { PdfField } from "../pdf/ipc"; +import type { FoundPlace } from "../signing/labels"; +import type { PdfRules } from "../signing/ipc"; + +// The stamps placed on the pages of PDF tabs (see .claude/rules/pdf.md, +// "Stamps"): written only through src/pdf/stampBooks.ts, stored by +// storage under `stamps:`, and what makes a PDF tab unsaved. + +export interface TabStamps { + // the stamps and their undo history + readonly book: StampBook; + // the SHA-256 of the PDF they were placed on, null while unknown + readonly sha256: string | null; +} + +// each PDF tab's stamps, by the tab's id; a tab without any has none +export const stampBooks = shallowRef>(new Map()); + +// the stamp selected in the PDF tab shown, by its id; null for none +export const selectedStamp = shallowRef(null); + +// the stamp whose tool was chosen with the mouse, which a press on a page +// places there; null while none is being placed +export const placingStamp = shallowRef(null); + +// what the PDF shown lets its signing tools place (`signing_pdf_rules`), for +// the PDF of `sha256`; null until read, and in a markdown tab +export const shownPdfRules = shallowRef<{ + readonly sha256: string; + readonly rules: PdfRules; +} | null>(null); + +// what Blank found to fill in or sign on each PDF tab's pages, by the tab's +// id: its form's fields and the places found by their labels, read in the +// background once it shows (src/pdf/placesRead.ts), for the PDF of `sha256` +export interface TabPlaces { + readonly sha256: string; + readonly fields: readonly PdfField[]; + readonly found: readonly FoundPlace[]; +} +export const pdfPlaces = shallowRef>(new Map()); + +// how often each page of a PDF tab was filled in anew, by `:`: +// part of its bitmaps' keys, so a page draws again once its fields change +export const pageRevisions = shallowRef>(new Map()); + +// why a field of the PDF shown didn't take what was typed (its font can't +// show some of the characters), shown under it; null for none +export const fieldProblem = shallowRef<{ + readonly tab: string; + readonly field: string; + readonly text: string; +} | null>(null); + +// the place that had the focus last in the PDF shown, by its id, which Next +// goes on from; null before any +export const lastPlace = shallowRef(null); + +// the PDF tabs Blank is signing right now, by id: their stamps, fields and +// places wait until it's done (src/pdf/sign.ts) +export const signingTabs = shallowRef>(new Set()); diff --git a/src/storage.test.ts b/src/storage.test.ts index 9bfd74e2..cd1f88f5 100644 --- a/src/storage.test.ts +++ b/src/storage.test.ts @@ -487,6 +487,90 @@ describe("storage", () => { }); }); + it("stores a PDF tab's stamps, and removes them once it has none or closes", async () => { + const { tabs, stampBooks, loadStamps } = await open("a"); + tabs.value = [{ ...tab("a"), kind: "pdf", path: "/a.pdf" }]; + const stamp = { + id: "s1", + kind: "signature" as const, + page: 0, + rect: [10, 20, 180, 60] as [number, number, number, number], + natural: [180, 60] as const, + spec: { + image: "signature" as const, + lines: [], + size: 11, + align: "center" as const, + }, + }; + const book = { + stamps: [stamp], + fills: [{ field: "name", value: "Ada" }], + dismissed: ["found:0:1:2"], + past: [], + future: [], + nudged: null, + }; + stampBooks.value = new Map([["a", { book, sha256: "abc" }]]); + await tick(); + expect(await localforage.getItem("stamps:a")).toEqual({ + sha256: "abc", + stamps: [stamp], + fills: book.fills, + dismissed: book.dismissed, + }); + expect(await loadStamps("a")).toEqual({ + sha256: "abc", + stamps: [stamp], + fills: book.fills, + dismissed: book.dismissed, + }); + + // all undone: nothing to keep + stampBooks.value = new Map([ + [ + "a", + { + book: { + ...book, + stamps: [], + fills: [], + dismissed: [], + future: [{ stamps: [stamp], fills: [], dismissed: [] }], + }, + sha256: "abc", + }, + ], + ]); + await tick(); + expect(await localforage.getItem("stamps:a")).toBeNull(); + expect(await loadStamps("a")).toBeNull(); + + stampBooks.value = new Map([["a", { book, sha256: "abc" }]]); + await tick(); + tabs.value = [tab("b")]; + await tick(); + expect(await localforage.getItem("stamps:a")).toBeNull(); + }); + + it("restores only the stamps it can read", async () => { + const { loadStamps } = await open("a"); + await localforage.setItem("stamps:a", { + sha256: 7, + stamps: [{ id: "x", kind: "seal" }], + fills: [{ field: 3 }, { field: "ok", value: true }], + dismissed: [1, "found:0:1:2"], + }); + expect(await loadStamps("a")).toEqual({ + sha256: null, + stamps: [], + fills: [{ field: "ok", value: true }], + dismissed: ["found:0:1:2"], + }); + await localforage.setItem("stamps:a", "garbage"); + expect(await loadStamps("a")).toBeNull(); + }); + it("removes the document of a closed tab", async () => { const { transaction, tabs, activeTabId } = await open("a", "b"); transaction.value = replace(doc(p("a"))); diff --git a/src/storage.ts b/src/storage.ts index e0ff9a56..d61348fd 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -15,7 +15,10 @@ import { currentViewAnchor, language, outlinePinned, + signingInitials, signingPlace, + stampBooks, + type TabStamps, PAGE_VIEW_MODES, pageLayoutState, pageView, @@ -41,6 +44,9 @@ import { CommandIdentifier } from "./config"; import { logWarning } from "./log"; import { closeMarker, fenceFor, formatMarker, schema } from "./markdown"; import { isPrintSettings, PRINT_DEFAULTS } from "./print/printModel"; +import type { StoredStamps } from "./pdf/stampBooks"; +import { isFill, isStamp } from "./pdf/stampModel"; +import { INITIALS_DEFAULT, isInitialsChoice } from "./signing/initials"; import { sendNotification } from "@tauri-apps/plugin-notification"; localforage.config({ @@ -79,10 +85,15 @@ export interface Session { const SESSION = "session"; const tabKey = (id: string) => `tab:${id}`; +// a PDF tab's stamps, fills and dismissed places: `{ sha256, stamps, +// fills, dismissed }` +const stampsKey = (id: string) => `stamps:${id}`; // the documents waiting to be written, and the ones last written, by tab const pendingDocs = new Map(); const storedDocs = new Map(); +// the stamps of PDF tabs waiting to be written, null for none, by tab +const pendingStamps = new Map(); // the tabs closed since the last write, whose documents are removed then const removedTabs = new Set(); let sessionPending = false; @@ -139,6 +150,8 @@ const writeNow = async () => { pending = false; const docs = [...pendingDocs]; pendingDocs.clear(); + const stamps = [...pendingStamps]; + pendingStamps.clear(); const session = sessionPending ? sessionOf() : null; sessionPending = false; const removed = [...removedTabs]; @@ -150,14 +163,36 @@ const writeNow = async () => { storedDocs.set(id, doc); }), ); + await Promise.all( + stamps.map(([id, kept]) => + kept && + (kept.book.stamps.length || + kept.book.fills.length || + kept.book.dismissed.length) + ? localforage.setItem(stampsKey(id), { + sha256: kept.sha256, + stamps: kept.book.stamps, + fills: kept.book.fills, + dismissed: kept.book.dismissed, + }) + : localforage.removeItem(stampsKey(id)), + ), + ); if (session) await localforage.setItem(SESSION, session); - await Promise.all(removed.map((id) => localforage.removeItem(tabKey(id)))); + await Promise.all( + removed.flatMap((id) => [ + localforage.removeItem(tabKey(id)), + localforage.removeItem(stampsKey(id)), + ]), + ); } catch (error) { // unless a newer document came meanwhile for (const [id, doc] of docs) { if (!pendingDocs.has(id) && storedDocs.get(id) !== doc) pendingDocs.set(id, doc); } + for (const [id, kept] of stamps) + if (!pendingStamps.has(id)) pendingStamps.set(id, kept); for (const id of removed) removedTabs.add(id); sessionPending ||= session !== null; pending = true; @@ -296,6 +331,18 @@ const watchSession = () => { }, { flush: "sync" }, ); + // the stamps of PDF tabs, as they change + watch( + stampBooks, + (books, before) => { + for (const [id, kept] of books) + if (before?.get(id) !== kept) pendingStamps.set(id, kept); + for (const id of before?.keys() ?? []) + if (!books.has(id)) pendingStamps.set(id, null); + schedule(); + }, + { flush: "sync" }, + ); let known = new Set(); watch( [tabs, activeTabId], @@ -305,6 +352,7 @@ const watchSession = () => { if (ids.has(id)) continue; removedTabs.add(id); pendingDocs.delete(id); + pendingStamps.delete(id); storedDocs.delete(id); } known = ids; @@ -369,6 +417,13 @@ export const bootStorage = async () => { (value): value is string => typeof value === "string", "", ); + // whether the initials went on every page then, and in which corner + await restore( + "signingInitials", + signingInitials, + isInitialsChoice, + INITIALS_DEFAULT, + ); sessionKept = await ownSession(); if (sessionKept) { @@ -508,6 +563,37 @@ export const loadTabDocument = async ( return doc; }; +/** + * loadStamps returns the stamps, fills and dismissed places stored for the + * PDF tab `id`, without those this Blank can't read, or null when none are + * stored + */ +export const loadStamps = async (id: string): Promise => { + if (!sessionKept) return null; + try { + const stored = await localforage.getItem<{ + sha256?: unknown; + stamps?: unknown; + fills?: unknown; + dismissed?: unknown; + }>(stampsKey(id)); + if (!stored || !Array.isArray(stored.stamps)) return null; + const list = (value: unknown): unknown[] => + Array.isArray(value) ? value : []; + return { + sha256: typeof stored.sha256 === "string" ? stored.sha256 : null, + stamps: stored.stamps.filter(isStamp), + fills: list(stored.fills).filter(isFill), + dismissed: list(stored.dismissed).filter( + (one): one is string => typeof one === "string", + ), + }; + } catch (error) { + logWarning("can't read the stamps of a PDF tab", error); + return null; + } +}; + /** * backupTabDocument copies the stored document of the tab `id` to * `tab-backup:`, so autosave can't overwrite the only copy of a document diff --git a/src/test/pdf.ts b/src/test/pdf.ts new file mode 100644 index 00000000..2d3b0917 --- /dev/null +++ b/src/test/pdf.ts @@ -0,0 +1,184 @@ +import type { PdfTabDocument } from "../pdf/document"; +import type { PageText, PdfField } from "../pdf/ipc"; +import { resetFills } from "../pdf/fills"; +import { resetPlaces } from "../pdf/placesRead"; +import { resetStampBooks } from "../pdf/stampBooks"; +import { type Stamp, stampSpec } from "../pdf/stampModel"; +import type { PdfRules } from "../signing/ipc"; +import type { SavedMark } from "../signing/profile"; +import { + activeTabId, + placeDialog, + placingStamp, + savedMarks, + selectedStamp, + shownPdf, + shownPdfRules, + signatureDialog, + signDialog, + signingCertificate, + signingPlace, + signingTabs, + type Tab, + tabs, +} from "../state"; + +// What the tests of PDF tabs share: a PDF as it opened, what it allows, its +// fields and words (form.pdf's), stamps, the user's marks, a PDF tab shown, +// and the shared state put back after each test. The modules they mock +// stay in each test file, since vi.mock only applies there. + +export const A4 = { width: 595, height: 842, rotation: 0 }; + +/** + * pdfDocument returns a PDF tab's document of `pages` A4 pages, opened + */ +export const pdfDocument = ( + pages = 2, + over: Partial = {}, +): PdfTabDocument => ({ + path: "/docs/contract.pdf", + opened: { + sha256: "abc", + pages: Array.from({ length: pages }, () => A4), + repaired: false, + encrypted: false, + }, + problem: null, + failedPages: [], + signatures: [], + ...over, +}); + +/** + * pdfRules returns what a PDF allows: nobody signed it, unless `over` says + */ +export const pdfRulesOf = (over: Partial = {}): PdfRules => ({ + signed: false, + docmdp: null, + encrypted: false, + damaged: false, + locked: [], + ...over, +}); + +/** + * pdfField returns a text field `name` of a form, unless `more` says else + */ +const pdfField = (name: string, more: Partial): PdfField => ({ + name, + kind: "text", + label: null, + value: "", + options: [], + flags: { + readOnly: false, + required: false, + multiline: false, + comb: false, + maxLen: 40, + }, + signed: false, + widgets: [], + ...more, +}); + +// form.pdf's fields: its name, a checkbox, a signature field +export const FORM_FIELDS: PdfField[] = [ + pdfField("name", { + label: "Your name", + widgets: [{ page: 0, rect: [100, 122, 200, 20], onValue: null }], + }), + pdfField("agree", { + kind: "checkbox", + value: "Off", + widgets: [{ page: 0, rect: [100, 177, 15, 15], onValue: "Yes" }], + }), + pdfField("signature", { + kind: "signature", + widgets: [{ page: 0, rect: [100, 292, 200, 50], onValue: null }], + }), +]; + +// form.pdf's words: "Unterschrift" under a rule, and "Ort, Datum: ____" +export const FORM_TEXT: PageText = { + words: [ + { text: "Unterschrift", rect: [100, 364.82, 51.12, 9.25] }, + { text: "Ort,", rect: [350, 364.82, 16.67, 9.25] }, + { text: "Datum:", rect: [369.45, 364.82, 32.23, 9.25] }, + { text: "______________", rect: [404.46, 364.82, 77.84, 9.25] }, + ], + rules: [[100, 351.75, 200, 0.5]], +}; + +// the user's signature or initials, a picture of 300 × 100 +export const MARK: SavedMark = { + source: { kind: "image" }, + png: "", + width: 300, + height: 100, +}; + +/** + * testStamp returns a signature stamp on the first page, unless `over` says + */ +export const testStamp = (over: Partial = {}): Stamp => ({ + id: "s1", + kind: "signature", + page: 0, + rect: [100, 100, 180, 60], + natural: [180, 60], + spec: stampSpec("signature"), + ...over, +}); + +/** + * pdfTab returns the PDF tab `id` of `path` + */ +export const pdfTab = (id = "t", path = "/docs/contract.pdf"): Tab => ({ + id, + kind: "pdf", + path, + importedFrom: null, + untitledNumber: null, + unsaved: false, + viewAnchor: null, +}); + +/** + * showPdf shows `doc` in the one tab, "t", with the user's marks and + * `rules` read already, unless null + */ +export const showPdf = ( + doc: PdfTabDocument, + rules: PdfRules | null = pdfRulesOf(), +) => { + tabs.value = [pdfTab("t", doc.path)]; + activeTabId.value = "t"; + shownPdf.value = doc; + shownPdfRules.value = + rules && doc.opened ? { sha256: doc.opened.sha256, rules } : null; + savedMarks.value = { signature: MARK, initials: MARK }; +}; + +/** + * resetPdfState puts back what the tests of PDF tabs change + */ +export const resetPdfState = () => { + resetStampBooks(); + resetPlaces(); + resetFills(); + shownPdf.value = null; + shownPdfRules.value = null; + savedMarks.value = undefined; + selectedStamp.value = null; + placingStamp.value = null; + signDialog.value = null; + signatureDialog.value = null; + placeDialog.value = null; + signingPlace.value = ""; + signingCertificate.value = null; + signingTabs.value = new Set(); + tabs.value = []; + activeTabId.value = null; +}; diff --git a/src/ui/App.vue b/src/ui/App.vue index e5a446ea..bc39b0d6 100644 --- a/src/ui/App.vue +++ b/src/ui/App.vue @@ -15,6 +15,7 @@ import { tableToolbar, signatureDialog, signDialog, + placeDialog, certificateDialog, confirmDialog, signaturePopover, @@ -34,6 +35,7 @@ import ContextMenu from "./ContextMenu.vue"; import ImageDialog from "./ImageDialog.vue"; import SignatureDialog from "./SignatureDialog.vue"; import SignDialog from "./SignDialog.vue"; +import PlaceDialog from "./PlaceDialog.vue"; import CertificateDialog from "./CertificateDialog.vue"; import ConfirmDialog from "./ConfirmDialog.vue"; import { keyOf } from "./keyOf"; @@ -127,6 +129,11 @@ watchFocusMode(); :key="keyOf(signDialog)" :request="signDialog" /> + { expect(row("plain").getAttribute("role")).toBe("menuitem"); expect(row("plain").querySelector(".check")).not.toBeNull(); }); + it("starts on the chosen item of a select opened from the keyboard", async () => { + contextMenu.value = { + items: [ + { id: "a", label: "A", radio: true, checked: false, run: () => {} }, + { id: "b", label: "B", radio: true, checked: true, run: () => {} }, + ], + anchor: { left: 0, top: 0, bottom: 0 }, + keyboard: true, + close: () => {}, + }; + await settle(); + expect(document.activeElement).toBe(row("b")); + }); }); describe("context menu listeners", () => { diff --git a/src/ui/FormatToolbar.test.ts b/src/ui/FormatToolbar.test.ts index 78b6589b..ba57c223 100644 --- a/src/ui/FormatToolbar.test.ts +++ b/src/ui/FormatToolbar.test.ts @@ -5,7 +5,6 @@ import type { EditorHandle } from "../editor/handle"; import { schema } from "../markdown"; import { type MenuItem, - announcement, contextMenu, cycleFocus, focusStop, @@ -66,7 +65,7 @@ describe("the formatting toolbar", () => { shownPdf.value = null; }); - it("greys out the style and Insert menus in a PDF tab, and a press says why", async () => { + it("gives its row to the signing toolbar in a PDF tab", async () => { shownPdf.value = { path: "/docs/contract.pdf", opened: null, @@ -75,20 +74,8 @@ describe("the formatting toolbar", () => { signatures: null, }; await mount(); - const why = "Blank can't change the text of a PDF"; - const menus = [ - ...toolbar().querySelectorAll( - "[aria-haspopup]:not([data-more])", - ), - ]; - expect(menus).toHaveLength(2); - for (const menu of menus) { - expect(menu.getAttribute("aria-disabled")).toBe("true"); - expect(menu.dataset.tip).toBe(why); - press(menu); - expect(contextMenu.value).toBeNull(); - expect(announcement.value?.text).toBe(why); - } + expect(document.querySelector("#format-toolbar")).toBeNull(); + expect(document.querySelector("#signing-toolbar")).not.toBeNull(); }); it("is a toolbar of the formatting buttons, with tooltips naming their keys", async () => { diff --git a/src/ui/FormatToolbar.vue b/src/ui/FormatToolbar.vue index 3f029578..6b544ccc 100644 --- a/src/ui/FormatToolbar.vue +++ b/src/ui/FormatToolbar.vue @@ -13,8 +13,7 @@ import { CommandIdentifier } from "../config"; import { blockStyleAt } from "../editor/formatState"; import { useEditor } from "../editor/handle"; import { commandFor } from "../editor/plugins/keymap"; -import { FOCUS_ORDER, shownPdf, toolbarFocused } from "../state"; -import { pdfRefusal } from "../pdf/commands"; +import { FOCUS_ORDER, toolbarFocused } from "../state"; import type { Anchor } from "../state"; import { useFocusRegion } from "./composables/useFocusRegion"; import { useResizeObserver } from "./composables/useResizeObserver"; @@ -148,13 +147,6 @@ const onKey = (event: KeyboardEvent) => { } }; -// in a PDF tab the style and Insert menus are greyed out, saying why: both -// would change the text -const textRefusal = computed(() => - shownPdf.value - ? (pdfRefusal(CommandIdentifier.BLOCKTYPE_PARAGRAPH) ?? undefined) - : undefined, -); const styleItems = () => styleMenuItems(editor.state.value, editor.can, runById); const insertItems = (anchor: Anchor) => @@ -194,8 +186,6 @@ const moreItems = (anchor: Anchor) => label="Text style" :text="style" :items="styleItems" - :tip="textRefusal" - :disabled="!!textRefusal" :tabindex="tabindexOf(entry.key)" :data-part="entry.part" /> @@ -205,8 +195,6 @@ const moreItems = (anchor: Anchor) => icon="plus" text="Insert" :items="insertItems" - :tip="textRefusal" - :disabled="!!textRefusal" :tabindex="tabindexOf(entry.key)" :data-part="entry.part" /> diff --git a/src/ui/PdfField.vue b/src/ui/PdfField.vue new file mode 100644 index 00000000..f41a72bc --- /dev/null +++ b/src/ui/PdfField.vue @@ -0,0 +1,279 @@ + + +