diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 897cf13b..b4337b2a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (212) +## In this repository (213) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -220,6 +220,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material | `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PrintCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs) | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index ab350669..db28bd6f 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2673,3 +2673,14 @@ licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "ef1fec17d88794ba6c34c5cf63bdadbd234b3d4dffbcc3d55468b47e3f398472" + +[[asset]] +path = "crates/ui-egui/tests/data/form.pdf" +title = "One-page AcroForm test fixture (text, check box, radio group, combo box)" +author = "PrintCraft contributors" +source = "Contributor-original: hand-written PDF objects, no third-party content" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "document" +usage = "Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs)" +sha256 = "c6f8b656e225101129aa36bd5467adc8dba16730915f19caca17f6a546d0b040" diff --git a/Cargo.lock b/Cargo.lock index f13c7393..88bc5975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3032,6 +3032,7 @@ version = "0.1.0" dependencies = [ "hayro-syntax", "printcraft-cos", + "printcraft-fonts", "thiserror 2.0.21", ] @@ -3089,6 +3090,7 @@ version = "0.1.0" dependencies = [ "printcraft-annot", "printcraft-cos", + "printcraft-forms", "printcraft-organize", "printcraft-render", "thiserror 2.0.21", @@ -3104,6 +3106,20 @@ dependencies = [ "weezl 0.2.1", ] +[[package]] +name = "printcraft-fonts" +version = "0.1.0" + +[[package]] +name = "printcraft-forms" +version = "0.1.0" +dependencies = [ + "hayro-syntax", + "printcraft-cos", + "printcraft-fonts", + "thiserror 2.0.21", +] + [[package]] name = "printcraft-geom" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 080927d2..be6d7a94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ printcraft-cos = { path = "crates/cos", version = "0.1.0" } printcraft-crypt = { path = "crates/crypt", version = "0.1.0" } printcraft-organize = { path = "crates/organize", version = "0.1.0" } printcraft-annot = { path = "crates/annot", version = "0.1.0" } +printcraft-fonts = { path = "crates/fonts", version = "0.1.0" } +printcraft-forms = { path = "crates/forms", version = "0.1.0" } printcraft-render = { path = "crates/render", version = "0.1.0" } printcraft-engine = { path = "crates/engine", version = "0.1.0" } printcraft-ui-egui = { path = "crates/ui-egui", version = "0.1.0" } diff --git a/README.md b/README.md index 072d045d..cd85eeb5 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ printcraft-cli edit in.pdf --rotate 1,2:90 --delete 5 --title "Q3" --out out.pd ## Built for agents, too -Every engine feature is reachable without the GUI, through one table of JSON-Schema-described tools: open, inspect, render pages to PNG, extract and find text, rotate, delete, move and insert pages, edit bookmarks and page labels, add, reply to, restyle and delete comments (highlight a phrase just by naming it), set metadata, undo and redo, save, combine, extract and split. Three front doors share it: +Every engine feature is reachable without the GUI, through one table of JSON-Schema-described tools: open, inspect, render pages to PNG, extract and find text, rotate, delete, move and insert pages, edit bookmarks and page labels, add, reply to, restyle and delete comments (highlight a phrase just by naming it), list and fill in form fields, protect with passwords, set metadata, undo and redo, save, combine, extract and split. Three front doors share it: - **`printcraft-cli run`**, for one-off calls and JSON scripts: @@ -319,7 +319,9 @@ PrintCraft is a Cargo workspace of focused crates, layered so the core never dep | `printcraft-crypt` | The standard security handler: RC4, AES-128/256, revisions 2–6, permissions | | `printcraft-cos` | The PDF object layer: tolerant parsing, repair, copy-on-write edits, incremental and full writing | | `printcraft-organize` | Page operations, combine / extract / split, bookmarks, page labels, document information | +| `printcraft-fonts` | Font metrics and encodings for generated appearances | | `printcraft-annot` | Comments: builders and appearance streams for notes, text markup, shapes, ink and text boxes; replies, status, edits | +| `printcraft-forms` | Interactive forms: the field model, filling with regenerated appearances, Clear form | | `printcraft-render` | Rendering, inspection and text extraction with reading order | | `printcraft-engine` | The façade every frontend uses: sessions, edits, undo, saving, the tool catalogue | | `printcraft-automation` | Agent control: the headless tool table, `printcraft-cli run`, and the opt-in MCP server | @@ -357,6 +359,8 @@ PrintCraft is young and moving fast. The aim is a workbench where you can view, - viewing, search and navigation; - organizing pages, combining, extracting and splitting; bookmarks and page labels; - commenting: sticky notes, highlight / underline / strikethrough, text boxes, freehand drawing, lines, arrows, rectangles and ovals, with replies, status, colours, moving, resizing and a searchable Comments panel; +- filling in forms: text fields, check boxes, radio buttons, combo and list boxes, Tab between fields, Clear form; +- password protection: a password to open, permissions (printing, changes, copying), AES-256, removing security; - document information; - opening encrypted documents, honouring their permissions, and saving them encrypted; - undo and safe saving; @@ -370,9 +374,9 @@ PrintCraft is young and moving fast. The aim is a workbench where you can view, |---|---| | Page boxes | M4 | | Callouts, clouds, stamps, FDF/XFDF, comment summaries | M5 | -| Filling and authoring forms, JavaScript | M6 | +| Authoring forms, JavaScript (formatting, calculations) | M6 | | Editing text and images in place, headers, watermarks | M7 | -| Adding passwords, redaction | M8 | +| Certificate security, redaction, sanitizing | M8 | | Digital signatures (PAdES) | M9 | | OCR, export to Office formats, printing | M10 | | Optimize, preflight, PDF/A | M11 | diff --git a/ROADMAP.md b/ROADMAP.md index 63a74063..c45e5493 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,19 +36,19 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro | | M3 | Viewer app (native + web) | 80–150 | 72% | 22–42 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel | | M4 | Engine, history, save, organize | 100–180 | 80% | 20–36 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: bookmark editing, page labels (Number pages), CLI `run`. Missing: page boxes/crop, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 0% | 120–200 | Read-only comments panel exists | -| M6 | Forms + JavaScript | 160–320 | 0% | 160–320 | Fields are displayed, not filled | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 40% | 70–120 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, with appearance streams; replies, status, move/resize/restyle/delete; quick-bar tools and Comments panel; agent tools. Missing: callouts, clouds, polygons, stamps, carets, FDF/XFDF, summaries, flatten | +| M6 | Forms + JavaScript | 160–320 | 15% | 135–270 | Done: filling text, check box, radio, combo and list fields with regenerated appearances, Tab order, Clear form, agent tools. Missing: JavaScript (AF functions, events), form authoring, FDF/XFDF data | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 0% | 250–500 | Longest pole | -| M8 | Security + redaction | 100–180 | 10% | 90–160 | Done: opening protected documents, honouring permissions, and the engine side of setting and removing passwords. Missing: Protect UI, certificate security, sanitize, redaction | +| M8 | Security + redaction | 100–180 | 25% | 75–135 | Done: opening protected documents, honouring permissions, Protect Using Password (open and permissions passwords, all compatibility levels, Advanced options), Remove security. Missing: certificate security, sanitize, redaction | | M9 | Signatures (PAdES, validation) | 160–280 | 0% | 160–280 | | | M10 | OCR, create, export, print | 200–350 | 0% | 200–350 | | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 0% | 200–350 | | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 0% | 200–380 | | -| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 15% | 50–100 | Done: headless tool table (20 tools), opt-in MCP server over stdio, CLI `run`/`tools`. Missing: Action Wizard, more tools as features land, AI providers | +| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 22% | 45–95 | Done: headless tool table (38 tools: pages, bookmarks, labels, comments, forms, protection…), opt-in MCP server over stdio, CLI `run`/`tools`, UI control channel with drag. Missing: Action Wizard, more tools as features land, AI providers | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 0% | 120–250 | | -| | **Total** | **2,085–3,840** | **≈ 10%** | **≈ 1,870–3,465** | | +| | **Total** | **2,085–3,840** | **≈ 13%** | **≈ 1,800–3,350** | | -**Overall progress: about 10% of the effort.** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen. +**Overall progress: about 13% of the effort.** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen. ## Critical path @@ -67,6 +67,14 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run Newest first. One line per session: the date, what moved, and the new overall percentage. +- **2026-10-01 (session 8):** + - Commenting: sticky notes, highlights, underline, strikethrough, text boxes, freehand, lines, arrows, rectangles and ovals, with replies, status, colours, moving and resizing; Acrobat-style quick bar and Comments panel; six agent tools (an agent can highlight a phrase by naming it). + - Protect Using Password, with Acrobat's permission levels and compatibility options; Remove security. + - Filling in forms: text, check boxes, radio buttons, combo and list boxes, Tab between fields, Clear form; three agent tools. + - Performance: opening a 190 MB manual went from 172 s to under 2 s; comment and form edits on it are 6× faster. + - Fixed: saving a password-protected document failed; encrypted documents were not reported as encrypted. + - Overall ≈ 13%. + - **2026-10-01 (session 7):** - Bookmark editing and page numbering (Number pages), with undo, agent tools and UI. - A render watchdog: pathological pages are skipped after 20 s instead of spinning forever. diff --git a/crates/annot/Cargo.toml b/crates/annot/Cargo.toml index 77623b50..36b75bbd 100644 --- a/crates/annot/Cargo.toml +++ b/crates/annot/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] printcraft-cos = { workspace = true } +printcraft-fonts = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 6509c0a0..a2610370 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -11,6 +11,8 @@ //! Helvetica's proportions by character class (no font program or metrics file is bundled). use printcraft_cos::{Dict, Object, PdfString, Stream}; +pub use printcraft_fonts::{helvetica_width as text_width, wrap}; +use printcraft_fonts::{literal, win_ansi}; use crate::{NOTE_SIZE, Rgb, n}; @@ -96,85 +98,6 @@ pub fn parse_da(d: &Dict) -> (Rgb, f64) { (col.map(|x| x.clamp(0.0, 1.0)), size.min(400.0)) } -/// Approximate advance of `s` in Helvetica at `size` points. -pub fn text_width(s: &str, size: f64) -> f64 { - let units: f64 = s - .chars() - .map(|c| match c { - ' ' | 'i' | 'j' | 'l' | '\'' | '!' | '|' | '.' | ',' | ':' | ';' | 'I' => 260.0, - 'f' | 't' | 'r' | '(' | ')' | '[' | ']' | '/' | '-' | '"' => 333.0, - 'm' => 833.0, - 'w' => 722.0, - 'M' => 833.0, - 'W' => 944.0, - 'J' | 'c' | 'k' | 's' | 'v' | 'x' | 'y' | 'z' => 500.0, - '0'..='9' | 'a'..='z' | '$' | '#' | '?' | '_' => 556.0, - 'A'..='Z' => 680.0, - '@' => 1015.0, - _ if c.is_whitespace() => 260.0, - _ => 584.0, - }) - .sum(); - units * size / 1000.0 -} - -/// WinAnsi-encode text for a literal string, escaping delimiters; unmappable characters → `?`. -fn win_ansi(s: &str) -> Vec { - let mut out = Vec::new(); - for c in s.chars() { - let b = match c { - '\u{20}'..='\u{7e}' => c as u8, - '\u{a0}'..='\u{ff}' => c as u32 as u8, - '€' => 0x80, - '‚' => 0x82, - '„' => 0x84, - '…' => 0x85, - '‘' => 0x91, - '’' => 0x92, - '“' => 0x93, - '”' => 0x94, - '•' => 0x95, - '–' => 0x96, - '—' => 0x97, - '™' => 0x99, - '\t' => b' ', - _ => b'?', - }; - if matches!(b, b'(' | b')' | b'\\') { - out.push(b'\\'); - } - out.push(b); - } - out -} - -/// Greedy line breaking within `width` (paragraphs split on newlines; long words are split). -pub fn wrap(text: &str, size: f64, width: f64) -> Vec { - let mut lines = Vec::new(); - for para in text.split(['\n', '\r']) { - let mut line = String::new(); - for word in para.split(' ') { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if text_width(&candidate, size) <= width || line.is_empty() && text_width(word, size) <= width { - line = candidate; - continue; - } - if !line.is_empty() { - lines.push(std::mem::take(&mut line)); - } - // A word wider than the box: break it by characters. - for ch in word.chars() { - if !line.is_empty() && text_width(&format!("{line}{ch}"), size) > width { - lines.push(std::mem::take(&mut line)); - } - line.push(ch); - } - } - lines.push(line); - } - lines -} - fn ext_gstate(opacity: f64, multiply: bool) -> Option { if opacity >= 1.0 && !multiply { return None; @@ -401,9 +324,9 @@ pub fn build(d: &Dict) -> Option { 2 => rect[2] - pad - lw, _ => rect[0] + pad, }; - out.extend(format!("1 0 0 1 {} {} Tm (", n(x), n(y)).bytes()); - out.extend(win_ansi(&line)); - out.extend_from_slice(b") Tj\n"); + out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + out.extend(literal(&win_ansi(&line))); + out.extend_from_slice(b" Tj\n"); y -= size * 1.2; } out.extend_from_slice(b"ET\n"); diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index cf6aff99..6e74881b 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -756,3 +756,91 @@ pub fn set_style( })?; set_appearance(doc, r) } + +// ── reading ───────────────────────────────────────────────────────────────────────────────── + +/// One comment as the viewer lists it (the same fields and rules as +/// `printcraft_render::Annotation`), read straight from the object graph. The engine uses it to +/// refresh the comment list after a comment edit without re-inspecting the whole document. +#[derive(Clone, Debug, PartialEq)] +pub struct Summary { + pub page: usize, + pub index: usize, + pub subtype: String, + pub author: Option, + pub contents: Option, + /// `/M` as written (a PDF date string). + pub modified: Option, + pub name: Option, + pub in_reply_to: Option, + pub rect: [f32; 4], + pub color: Option<[f32; 3]>, + pub state: Option, + pub quads: Vec<[f32; 8]>, +} + +fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { + let o = doc.resolve(d.get(key)?); + let s = match &*o { + Object::String(s) => s.to_text(), + Object::Name(n) => String::from_utf8_lossy(n).into_owned(), + _ => return None, + }; + let s = s.trim_matches('\0').trim().to_string(); + (!s.is_empty()).then_some(s) +} + +/// Every comment (not links, form widgets or pop-ups), ordered by page and then top edge. +pub fn summaries(doc: &Document) -> Vec { + let mut out = Vec::new(); + let Ok(pages) = page_refs(doc) else { return out }; + for (page, p) in pages.iter().enumerate() { + for (index, entry) in annots(doc, *p).iter().enumerate() { + let obj = doc.resolve(entry); + let Some(d) = obj.as_dict() else { continue }; + let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; + if matches!(subtype.as_str(), "Link" | "Widget" | "Popup") { + continue; + } + let nums = |k: &[u8]| -> Vec { + d.get(k) + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0) as f32).collect())) + .unwrap_or_default() + }; + let r = nums(b"Rect"); + let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] }; + let c = nums(b"C"); + let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]); + if subtype == "FreeText" + && let Some(da) = text_value(doc, d, b"DA") + { + let t: Vec<&str> = da.split_whitespace().collect(); + if let Some(i) = t.iter().position(|x| *x == "rg") + && i >= 3 + { + let f = |k: usize| t[k].parse::().unwrap_or(0.0); + color = Some([f(i - 3), f(i - 2), f(i - 1)]); + } + } + let quads = nums(b"QuadPoints").chunks_exact(8).map(|q| <[f32; 8]>::try_from(q).unwrap_or_default()).collect(); + let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); + out.push(Summary { + page, + index, + subtype, + author: text_value(doc, d, b"T"), + contents: text_value(doc, d, b"Contents"), + modified: text_value(doc, d, b"M"), + name: text_value(doc, d, b"NM"), + in_reply_to, + rect, + color, + state: text_value(doc, d, b"State"), + quads, + }); + } + } + out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); + out +} diff --git a/crates/automation/README.md b/crates/automation/README.md index 3e84c5e7..725c191c 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -3,7 +3,7 @@ Agent control for PrintCraft: a headless tool table over the engine, and an opt-in MCP server. - **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit. -- **Status:** 33 tools covering inspection, rendering, text, page edits, page labels, bookmarks, comments, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). Planned next: tools for forms and protection as those features land, and MCP resources for page images. The running app's UI is driven separately, through the `ui.*` control channel in `printcraft-ui-egui`. +- **Status:** 38 tools covering inspection, rendering, text, page edits, page labels, bookmarks, comments, form filling, password protection, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). Planned next: form authoring and redaction tools as those features land, and MCP resources for page images. The running app's UI is driven separately, through the `ui.*` control channel in `printcraft-ui-egui`. ## API diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs new file mode 100644 index 00000000..9c37d7d0 --- /dev/null +++ b/crates/automation/src/forms.rs @@ -0,0 +1,115 @@ +//! Form tools: list fields, fill them (several at once, one undo step) and clear the form. + +use printcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, field_flags}; +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError, failed}; + +fn kind_name(k: FormFieldKind) -> &'static str { + match k { + FormFieldKind::Text => "text", + FormFieldKind::CheckBox => "checkbox", + FormFieldKind::Radio => "radio", + FormFieldKind::PushButton => "button", + FormFieldKind::Combo => "combo", + FormFieldKind::List => "list", + FormFieldKind::Signature => "signature", + } +} + +/// A JSON value → the value for this field (booleans for check boxes, arrays for lists…). +fn value_for(f: &FormField, v: &Value) -> Result { + let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name)); + Ok(match (f.kind, v) { + (FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b), + (FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None), + (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())), + (FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice( + a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::>()?, + ), + (FormFieldKind::Combo | FormFieldKind::List, Value::String(s)) => FieldValue::Choice(if s.is_empty() { Vec::new() } else { vec![s.clone()] }), + (_, Value::String(s)) => FieldValue::Text(s.clone()), + (_, Value::Number(n)) => FieldValue::Text(n.to_string()), + (_, Value::Bool(b)) => FieldValue::Text(b.to_string()), + _ => return Err(wrong("use a string (text, radio option, choice), true/false (check box) or an array (multi-select list)")), + }) +} + +impl Automation { + pub(crate) fn form_fields(&self, a: &Args) -> Result { + let doc = self.doc(a)?; + let out: Vec = doc + .form + .iter() + .map(|f| { + let w = f.widgets.first(); + let rect = w.and_then(|w| { + let p = doc.info.pages.get(w.page?)?; + let (a, b) = (p.user_to_view(w.rect[0] as f32, w.rect[1] as f32), p.user_to_view(w.rect[2] as f32, w.rect[3] as f32)); + Some([a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(|v| (v * 100.0).round() / 100.0)) + }); + let mut v = json!({ + "name": f.name, + "type": kind_name(f.kind), + "value": match f.kind { + FormFieldKind::CheckBox => json!(!f.value.is_empty()), + FormFieldKind::List if f.has(field_flags::MULTI_SELECT) => json!(f.value), + _ => json!(f.value.first()), + }, + "page": w.and_then(|w| w.page).map(|p| p + 1), + "rect": rect, + "read_only": f.read_only(), + "required": f.has(field_flags::REQUIRED), + }); + let o = v.as_object_mut().expect("object"); + if let Some(t) = &f.tooltip { + o.insert("tooltip".into(), json!(t)); + } + match f.kind { + FormFieldKind::Radio => { + o.insert("options".into(), json!(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>())); + } + FormFieldKind::Combo | FormFieldKind::List => { + o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect()); + o.insert("multi_select".into(), json!(f.has(field_flags::MULTI_SELECT))); + o.insert("editable".into(), json!(f.has(field_flags::EDIT))); + } + FormFieldKind::Text => { + o.insert("multiline".into(), json!(f.has(field_flags::MULTILINE))); + if let Some(m) = f.max_len { + o.insert("max_length".into(), json!(m)); + } + } + _ => {} + } + v + }) + .collect(); + Ok(json!({ "count": out.len(), "fields": out })) + } + + pub(crate) fn form_fill(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let values = a + .get("values") + .and_then(Value::as_object) + .ok_or_else(|| ToolError::InvalidArgs("values must be an object of field name → value".into()))?; + if values.is_empty() { + return Err(ToolError::InvalidArgs("values is empty".into())); + } + let mut edits = Vec::new(); + for (name, v) in values { + let f = doc.form.iter().find(|f| &f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?; + edits.push(Edit::SetFieldValue { name: name.clone(), value: value_for(f, v)? }); + } + let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Fill in form".into(), edits } }; + let mut out = self.apply(a, edit)?; + out["filled"] = json!(values.len()); + Ok(out) + } + + pub(crate) fn form_reset(&mut self, a: &Args) -> Result { + let names = a.get("fields").map(|_| a.strs("fields").map(|v| v.into_iter().map(str::to_owned).collect::>())).transpose()?; + self.apply(a, Edit::ResetForm { names }) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6c64bb64..24c78538 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -10,6 +10,7 @@ //! - An optional root directory confines every path a tool reads or writes. mod comments; +mod forms; #[cfg(feature = "mcp")] pub mod mcp; mod tools; @@ -215,6 +216,9 @@ impl Automation { out["security"] = security(self.doc(&a)?); out } + "form_fields" => self.form_fields(&a)?, + "form_fill" => self.form_fill(&a)?, + "form_reset" => self.form_reset(&a)?, "comment_list" => self.comment_list(&a)?, "comment_add" => self.comment_add(&a)?, "comment_reply" => self.comment_reply(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e07fd143..6b5738ac 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -195,6 +195,18 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), + t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page and rect (top-left-origin points), read-only and required flags.") + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "form_fill", + "Fill in form fields", + "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values.", + ) + .with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])), + t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.") + .destructive() + .with(schema(json!({ "doc": doc(), "fields": { "type": "array", "items": { "type": "string" } } }), &["doc"])), t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index ad53ac2e..309ef67f 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -440,3 +440,43 @@ fn protecting_through_tools() { let mut c = auto(&dir); ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } + +#[test] +fn forms_through_tools() { + let dir = workdir("forms"); + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../dist/demo/printcraft-showcase.pdf"); + if !src.exists() { + eprintln!("skipped: run `cargo xtask demo-pdf` for the showcase form"); + return; + } + std::fs::copy(&src, dir.join("show.pdf")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "show.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "form_fields", json!({ "doc": doc })); + let fields = list["fields"].as_array().unwrap().clone(); + assert!(fields.len() >= 5, "{list}"); + let pick = |t: &str| fields.iter().find(|f| f["type"] == t && f["read_only"] == false).cloned(); + let text = pick("text").expect("a text field"); + let mut values = serde_json::Map::new(); + values.insert(text["name"].as_str().unwrap().into(), json!("Filled by an agent")); + if let Some(cb) = pick("checkbox") { + values.insert(cb["name"].as_str().unwrap().into(), json!(true)); + } + if let Some(combo) = pick("combo") { + values.insert(combo["name"].as_str().unwrap().into(), combo["options"][1]["label"].clone()); + } + let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": values })); + assert_eq!(r["undo"], "Fill in form"); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + let get = |name: &str| after["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap()["value"].clone(); + assert_eq!(get(text["name"].as_str().unwrap()), "Filled by an agent"); + // The page shows it. + let page = text["page"].as_u64().unwrap(); + let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Filled by an agent" })); + assert_eq!(found["count"], 1, "rendered on page {page}"); + assert!(matches!(a.call("form_fill", &json!({ "doc": doc, "values": { "no such field": "x" } })), Err(ToolError::Failed(_)))); + ok(&mut a, "form_reset", json!({ "doc": doc })); + let reset = ok(&mut a, "form_fields", json!({ "doc": doc })); + let v = reset["fields"].as_array().unwrap().iter().find(|f| f["name"] == text["name"]).unwrap()["value"].clone(); + assert_ne!(v, "Filled by an agent"); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 2d14be6a..e5a90604 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -13,4 +13,5 @@ printcraft-render = { workspace = true } printcraft-cos = { workspace = true } printcraft-organize = { workspace = true } printcraft-annot = { workspace = true } +printcraft-forms = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 1518ae8f..7d00abc4 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -303,7 +303,10 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ badge: None, availability: Planned("M6"), sections: &[ - ToolSection { title: "Fields", items: &[item("Field list", "list", "form.fields", Ready)] }, + ToolSection { + title: "Fields", + items: &[item("Field list", "list", "form.fields", Ready), item("Clear form", "eraser", "form.clear", Ready)], + }, ToolSection { title: "Add form components", items: &[ diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5d49d8d2..a440788d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -24,6 +24,8 @@ pub enum Needs { Modification, /// The open document's security allows adding and changing comments. Annotate, + /// The document has form fields and its security allows filling them in. + FillForms, /// The document's security may be changed (owner, or nothing restricted). Security, /// The document is protected and its security may be removed. @@ -160,6 +162,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.square", "Draw a rectangle", None, None, Annotate, "square"), c("comment.circle", "Draw an oval", None, None, Annotate, "circle"), c("form.fields", "Form fields panel", VIEW, None, Document, "list"), + c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"), c("protect.password", "Protect using password…", FILE, None, Security, "lock"), c("protect.remove", "Remove security", FILE, None, ProtectedSecurity, "lock-open"), c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"), @@ -199,6 +202,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Assembly => doc.is_some_and(|d| d.allows_assembly()), Modification => doc.is_some_and(|d| d.allows_modification()), Annotate => doc.is_some_and(|d| d.allows_annotation()), + FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()), Security => doc.is_some_and(|d| d.allows_security_change()), ProtectedSecurity => doc.is_some_and(|d| d.allows_security_change() && d.security_summary().is_some()), Undo => doc.is_some_and(|d| d.can_undo().is_some()), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 18d7debe..7fd5f00b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub use printcraft_organize::{SplitBy, split_ranges}; pub use printcraft_organize::LabelStyle; pub use printcraft_cos::Algorithm; +pub use printcraft_forms::{Field as FormField, FieldKind as FormFieldKind, FieldValue, Widget as FormWidget, flags as field_flags}; /// Comment geometry helpers (text-box line breaking) for frontends. pub use printcraft_annot::appearance as annot_text; @@ -48,8 +49,39 @@ struct Keys { reopen: Option, } -/// One undo/redo step: its label, the document state and its passwords. -type Snapshot = (String, printcraft_cos::Document, Keys); +/// What an edit can change, and so what the view data must be rebuilt from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Scope { + /// Anything: re-inspect the whole document. + Full, + /// Only comments: re-read the comment list from the object graph. + Comments, + /// Only form field values (and their widget appearances). + Form, +} + +fn scope_of(edit: &Edit) -> Scope { + match edit { + Edit::AddAnnotation(_) + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MoveAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } => Scope::Comments, + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } => Scope::Form, + Edit::Batch { edits, .. } => { + let mut scopes = edits.iter().map(scope_of); + let first = scopes.next().unwrap_or(Scope::Full); + if scopes.all(|s| s == first) { first } else { Scope::Full } + } + _ => Scope::Full, + } +} + +/// One undo/redo step: its label, the document state, its passwords, and what it changed. +type Snapshot = (String, printcraft_cos::Document, Keys, Scope); /// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). #[derive(Clone)] @@ -78,17 +110,19 @@ pub struct Document { snapshot_generation: u64, /// Why the document cannot be edited (e.g. encryption), if so. pub read_only_reason: Option, + /// Interactive form fields of the current state (empty without a form). + pub form: Arc>, editor: Option, config: RenderConfig, } impl Document { pub fn can_undo(&self) -> Option<&str> { - self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, _, _)| l.as_str()) + self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l.as_str()) } pub fn can_redo(&self) -> Option<&str> { - self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, _, _)| l.as_str()) + self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l.as_str()) } pub fn editable(&self) -> bool { @@ -110,6 +144,11 @@ impl Document { self.editable() && self.permissions().is_none_or(|p| p.modify()) } + /// Filling in form fields is allowed (Table 22, bits 6 and 9). + pub fn allows_form_filling(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) + } + /// Adding and changing comments is allowed (Table 22, bit 6). pub fn allows_annotation(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.annotate()) @@ -409,6 +448,15 @@ pub enum Edit { opacity: Option, width: Option, }, + /// Fill in a form field. + SetFieldValue { + name: String, + value: FieldValue, + }, + /// Clear form: the named fields (all when `None`) back to their defaults. + ResetForm { + names: Option>, + }, /// Protect with passwords and permissions (written by the next save, which is a full rewrite). Protect(Protection), /// Remove password security (needs the owner password). @@ -444,6 +492,8 @@ impl Edit { Edit::MoveAnnotation { .. } => "Move comment".into(), Edit::ResizeAnnotation { .. } => "Resize comment".into(), Edit::StyleAnnotation { .. } => "Change comment properties".into(), + Edit::SetFieldValue { name, .. } => format!("Fill in {name}"), + Edit::ResetForm { .. } => "Clear form".into(), Edit::Protect(_) => "Protect with password".into(), Edit::RemoveProtection => "Remove security".into(), Edit::Batch { label, .. } => label.clone(), @@ -510,6 +560,13 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), Err(EditError::NotPermitted("comments")) } } + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } => { + if p.fill_forms() { + Ok(()) + } else { + Err(EditError::NotPermitted("filling in form fields")) + } + } Edit::Protect(_) | Edit::RemoveProtection => { if unrestricted(p) { Ok(()) @@ -630,6 +687,10 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::StyleAnnotation { page, index, color, opacity, width } => { printcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?; } + Edit::SetFieldValue { name, value } => printcraft_forms::set_value(doc, name, value)?, + Edit::ResetForm { names } => { + printcraft_forms::reset(doc, names.as_deref())?; + } Edit::Protect(p) => { p.validate()?; let seed = cx.entropy(); @@ -661,6 +722,27 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Ok(()) } +/// The comment list, read from the object graph (as `inspect` lists comments). +fn comment_list(doc: &printcraft_cos::Document) -> Vec { + printcraft_annot::summaries(doc) + .into_iter() + .map(|s| printcraft_render::Annotation { + page: s.page, + subtype: s.subtype, + author: s.author, + contents: s.contents, + modified: s.modified.map(|m| printcraft_render::pretty_date(&m)), + name: s.name, + in_reply_to: s.in_reply_to, + rect: s.rect, + color: s.color, + index: s.index, + state: s.state, + quads: s.quads, + }) + .collect() +} + /// The passwords after `edit`, if it changes them. fn keys_after(edit: &Edit) -> Option { match edit { @@ -706,6 +788,8 @@ pub enum EditError { Comment(#[from] printcraft_annot::AnnotError), #[error("{0}")] Protection(String), + #[error("{0}")] + Form(#[from] printcraft_forms::FormError), #[error("the edited document could not be written: {0}")] Write(String), #[error("the edited document could not be reopened: {0}")] @@ -776,6 +860,7 @@ impl Session { Ok(Err(e)) => (None, Some(e.to_string())), Err(_) => (None, Some("the document structure could not be read for editing".into())), }; + let form = editor.as_ref().map(|e| printcraft_forms::fields(&e.cos)).unwrap_or_default(); self.next_id += 1; let id = DocId(self.next_id); self.docs.push(Document { @@ -790,6 +875,7 @@ impl Session { generation: 0, snapshot_generation: 0, read_only_reason, + form: Arc::new(form), editor, config, }); @@ -815,16 +901,17 @@ impl Session { let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((edit.label(), previous, previous_keys)); + let scope = scope_of(&edit); + editor.undo.push((edit.label(), previous, previous_keys, scope)); if editor.undo.len() > MAX_UNDO { editor.undo.remove(0); } editor.redo.clear(); Self::adopt_keys(doc); - if let Err(e) = Self::refresh(doc) { + if let Err(e) = Self::refresh_scoped(doc, scope) { // Roll back: the edit produced something we cannot display. if let Some(ed) = doc.editor.as_mut() - && let Some((_, prev, keys)) = ed.undo.pop() + && let Some((_, prev, keys, _)) = ed.undo.pop() { ed.cos = prev; ed.keys = keys; @@ -841,12 +928,12 @@ impl Session { pub fn undo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; - let (label, prev, keys) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; + let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; let current = std::mem::replace(&mut editor.cos, prev); let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.redo.push((label.clone(), current, current_keys)); + editor.redo.push((label.clone(), current, current_keys, scope)); Self::adopt_keys(doc); - Self::refresh(doc)?; + Self::refresh_scoped(doc, scope)?; doc.dirty = true; doc.generation += 1; Ok(label) @@ -855,12 +942,12 @@ impl Session { pub fn redo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; - let (label, next, keys) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; + let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; let current = std::mem::replace(&mut editor.cos, next); let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label.clone(), current, current_keys)); + editor.undo.push((label.clone(), current, current_keys, scope)); Self::adopt_keys(doc); - Self::refresh(doc)?; + Self::refresh_scoped(doc, scope)?; doc.dirty = true; doc.generation += 1; Ok(label) @@ -874,6 +961,44 @@ impl Session { } } + /// Rebuild the working bytes and renderer, and the view data that `scope` may have changed. + /// Comment and form edits skip the full re-inspection (seconds on very large files): the + /// comment list and field values are re-read from the object graph instead. + fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { + if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { + return Self::refresh(doc); + } + let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + let form = Arc::new(printcraft_forms::fields(&editor.cos)); + match scope { + Scope::Comments => doc.info.annotations = comment_list(&editor.cos), + Scope::Form => { + for f in &mut doc.info.fields { + if let Some(ff) = form.iter().find(|x| x.name == f.name) { + f.value = match ff.kind { + printcraft_forms::FieldKind::CheckBox | printcraft_forms::FieldKind::Radio => { + Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) + } + _ if ff.value.is_empty() => None, + _ => Some(ff.value.join(", ")), + }; + } + } + } + Scope::Full => unreachable!("handled above"), + } + doc.info.file_size = bytes.len(); + doc.form = form; + doc.bytes = bytes.clone(); + doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone()); + Ok(()) + } + /// Rebuild working bytes, inspection and renderer from the current edit state. fn refresh(doc: &mut Document) -> Result<(), EditError> { let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; @@ -891,6 +1016,7 @@ impl Session { } } doc.info = info; + doc.form = Arc::new(printcraft_forms::fields(&editor.cos)); doc.bytes = bytes.clone(); doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone()); Ok(()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 42369470..9962a87b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,95 @@ fn protection_is_validated_undoable_and_never_logged() { assert!(s.get(id).unwrap().info.encrypted); assert_eq!(page_texts(&s, id), ["Page 1"]); } + +/// A one-page form: a text field and a check box (with appearance states). +fn form_fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 250 180 270] /P 3 0 R /MK << /BC [0 0 0] >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (ok) /V /Off /AS /Off /Rect [20 200 35 215] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Length 0 >>\nstream\n\nendstream", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn filling_a_form_shows_saves_and_undoes() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); + assert_eq!(s.get(id).unwrap().form.len(), 2); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada Lovelace".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "ok".into(), value: FieldValue::Check(true) }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.can_undo(), Some("Fill in ok")); + assert_eq!(d.form[0].value, ["Ada Lovelace"]); + assert_eq!(d.form[1].value, ["Yes"]); + assert_eq!(page_texts(&s, id), ["Ada Lovelace"], "the new appearance shows the text"); + let err = s.apply(id, Edit::SetFieldValue { name: "missing".into(), value: FieldValue::Text("x".into()) }).unwrap_err(); + assert_eq!(err.to_string(), "there is no field named \"missing\""); + s.apply(id, Edit::ResetForm { names: None }).unwrap(); + assert!(s.get(id).unwrap().form.iter().all(|f| f.value.is_empty())); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form[0].value, ["Ada Lovelace"]); + let saved = s.save_bytes(id).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("f.pdf", None, saved, None).unwrap(); + assert_eq!(s2.get(id2).unwrap().form[1].value, ["Yes"]); +} + +#[test] +fn comment_edits_refresh_the_list_exactly_as_a_full_inspection_would() { + let (mut s, id) = session_with(3); + let add = |page: usize, shape: Shape, contents: &str| { + Edit::AddAnnotation(NewAnnotation { + page, + shape, + style: Style::default_for(&Shape::Rectangle { rect: [0.0; 4] }), + contents: contents.into(), + author: "Ada".into(), + }) + }; + s.apply(id, add(0, Shape::Rectangle { rect: [10.0, 10.0, 60.0, 60.0] }, "box")).unwrap(); + s.apply(id, add(1, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 170.0, 90.0, 170.0, 20.0, 150.0, 90.0, 150.0]] }, "")) + .unwrap(); + s.apply(id, add(1, Shape::TextBox { rect: [10.0, 200.0, 150.0, 240.0], font_size: 11.0 }, "Text box")).unwrap(); + s.apply(id, add(2, Shape::Note { at: [100.0, 280.0], icon: NoteIcon::Comment }, "Sticky")).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 2, index: 0, text: "Reply".into(), author: "Bob".into() }).unwrap(); + s.apply(id, Edit::SetAnnotationStatus { page: 2, index: 0, state: ReviewState::Accepted, author: "Bob".into() }).unwrap(); + s.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: 5.0, dy: 5.0 }).unwrap(); + s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).unwrap(); + s.undo(id).unwrap(); + let d = s.get(id).unwrap(); + let full = printcraft_render::inspect(d.bytes.clone(), None).unwrap(); + assert_eq!(format!("{:?}", d.info.annotations), format!("{:?}", full.annotations)); + assert_eq!(d.info.file_size, full.file_size); + assert_eq!(d.info.annotations.len(), 6); +} + +#[test] +fn form_edits_refresh_field_values_without_a_full_inspection() { + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "ok".into(), value: FieldValue::Check(true) }).unwrap(); + let d = s.get(id).unwrap(); + let full = printcraft_render::inspect(d.bytes.clone(), None).unwrap(); + let values = |fs: &[printcraft_render::Field]| fs.iter().map(|f| (f.name.clone(), f.value.clone())).collect::>(); + assert_eq!(values(&d.info.fields), values(&full.fields)); + assert_eq!(values(&d.info.fields), [("name".to_string(), Some("Ada".to_string())), ("ok".to_string(), Some("Yes".to_string()))]); +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml new file mode 100644 index 00000000..5d9e4424 --- /dev/null +++ b/crates/fonts/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "printcraft-fonts" +description = "Font metrics and text encoding helpers for generated appearances (standard fonts, WinAnsi)" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/crates/fonts/README.md b/crates/fonts/README.md new file mode 100644 index 00000000..3afce40e --- /dev/null +++ b/crates/fonts/README.md @@ -0,0 +1,14 @@ +# printcraft-fonts + +Layer L2. Font helpers for the appearance streams PrintCraft generates (comments, form fields). + +Today it holds what generated appearances need without a font program: + +- `helvetica_width`: an approximation of Helvetica's proportions by character class. No metrics + file or font program from any vendor is bundled; widths are PrintCraft's own estimates, good + enough for line breaking and alignment, not for typesetting. +- `wrap`: greedy line breaking with that measure (paragraphs on newlines, long words split). +- `win_ansi`: Unicode → WinAnsiEncoding bytes (`?` for characters it can't represent), and + `literal` to write bytes as a PDF literal string. + +The full font subsystem (parsing, shaping, subsetting and embedding) arrives with M2.2/M7. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs new file mode 100644 index 00000000..d40ecc9c --- /dev/null +++ b/crates/fonts/src/lib.rs @@ -0,0 +1,111 @@ +//! printcraft-fonts — font metrics and encodings for generated appearances (L2). +//! +//! See the README: the metrics are approximations by character class (no vendor metrics files +//! are bundled). The full font subsystem lands in M2.2/M7. + +/// Approximate advance of `s` in Helvetica (or Arial) at `size` points. +pub fn helvetica_width(s: &str, size: f64) -> f64 { + let units: f64 = s + .chars() + .map(|c| match c { + ' ' | 'i' | 'j' | 'l' | '\'' | '!' | '|' | '.' | ',' | ':' | ';' | 'I' => 260.0, + 'f' | 't' | 'r' | '(' | ')' | '[' | ']' | '/' | '-' | '"' => 333.0, + 'm' => 833.0, + 'w' => 722.0, + 'M' => 833.0, + 'W' => 944.0, + 'J' | 'c' | 'k' | 's' | 'v' | 'x' | 'y' | 'z' => 500.0, + '0'..='9' | 'a'..='z' | '$' | '#' | '?' | '_' => 556.0, + 'A'..='Z' => 680.0, + '@' => 1015.0, + _ if c.is_whitespace() => 260.0, + _ => 584.0, + }) + .sum(); + units * size / 1000.0 +} + +/// Greedy line breaking within `width` points (paragraphs split on newlines; words longer +/// than a line are broken by character). +pub fn wrap(text: &str, size: f64, width: f64) -> Vec { + let mut lines = Vec::new(); + for para in text.split(['\n', '\r']) { + let mut line = String::new(); + for word in para.split(' ') { + let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if helvetica_width(&candidate, size) <= width || line.is_empty() && helvetica_width(word, size) <= width { + line = candidate; + continue; + } + if !line.is_empty() { + lines.push(std::mem::take(&mut line)); + } + for ch in word.chars() { + if !line.is_empty() && helvetica_width(&format!("{line}{ch}"), size) > width { + lines.push(std::mem::take(&mut line)); + } + line.push(ch); + } + } + lines.push(line); + } + lines +} + +/// Encode text in WinAnsiEncoding (ISO 32000-2 Annex D); unmappable characters become `?`. +pub fn win_ansi(s: &str) -> Vec { + s.chars() + .map(|c| match c { + '\u{20}'..='\u{7e}' => c as u8, + '\u{a0}'..='\u{ff}' => c as u32 as u8, + '€' => 0x80, + '‚' => 0x82, + '„' => 0x84, + '…' => 0x85, + '‘' => 0x91, + '’' => 0x92, + '“' => 0x93, + '”' => 0x94, + '•' => 0x95, + '–' => 0x96, + '—' => 0x97, + '™' => 0x99, + '\t' => b' ', + _ => b'?', + }) + .collect() +} + +/// Bytes as a PDF literal string, `(` … `)`, with delimiters escaped. +pub fn literal(bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(bytes.len() + 2); + out.push(b'('); + for &b in bytes { + match b { + b'(' | b')' | b'\\' => out.extend_from_slice(&[b'\\', b]), + b'\r' => out.extend_from_slice(b"\\r"), + b'\n' => out.extend_from_slice(b"\\n"), + _ => out.push(b), + } + } + out.push(b')'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn widths_wrap_and_encode() { + assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0); + assert_eq!(helvetica_width("", 12.0), 0.0); + let lines = wrap("the quick brown fox jumps over the lazy dog", 12.0, 80.0); + assert!(lines.len() > 2 && lines.iter().all(|l| helvetica_width(l, 12.0) <= 80.0)); + assert_eq!(wrap("a\nb", 12.0, 100.0), ["a", "b"]); + let long = wrap("Supercalifragilisticexpialidocious", 12.0, 40.0); + assert!(long.len() > 3 && long.concat() == "Supercalifragilisticexpialidocious"); + assert_eq!(win_ansi("Café — 5€ ☃"), b"Caf\xe9 \x97 5\x80 ?"); + assert_eq!(literal(b"a(b)\\c"), b"(a\\(b\\)\\\\c)"); + } +} diff --git a/crates/forms/Cargo.toml b/crates/forms/Cargo.toml new file mode 100644 index 00000000..c48de0f4 --- /dev/null +++ b/crates/forms/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "printcraft-forms" +description = "Interactive forms (AcroForm): field model, filling, appearances, reset (ISO 32000-2 §12.7)" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +printcraft-cos = { workspace = true } +printcraft-fonts = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +hayro-syntax = { workspace = true } diff --git a/crates/forms/README.md b/crates/forms/README.md new file mode 100644 index 00000000..15fdc8ad --- /dev/null +++ b/crates/forms/README.md @@ -0,0 +1,33 @@ +# printcraft-forms + +Interactive forms (AcroForm, ISO 32000-2 §12.7). Layer L3; depends on `printcraft-cos` and +`printcraft-fonts`. + +## API + +```rust +let all: Vec = fields(&doc); // terminal fields with widgets, options, flags, /DA +set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?; +set_value(&mut doc, "agree", &FieldValue::Check(true))?; +set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?; +set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?; +reset(&mut doc, None)?; // Clear form (back to /DV) +``` + +## Behaviour + +- Inheritable attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`, `/MaxLen`) are resolved down the + field tree; the form's `/DA` and `/Q` are the last fallback. +- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb, + multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection. + The `/DA` font is used when `/DR` has it as a simple font; otherwise Helvetica (WinAnsi). +- Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances + get PrintCraft's own (a drawn check mark or dot; no symbol font needed). +- Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a + failed call changes nothing. +- `/RV` (rich text) is removed when a value is set so it can't contradict `/V`. + +## Not yet + +JavaScript actions (format, keystroke, validate, calculate; M6.4/M6.5), rich-text values, +`/MK /R` rotation, field creation and properties (M6.6), FDF/XFDF import and export (M6.7). diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs new file mode 100644 index 00000000..0d76ddcf --- /dev/null +++ b/crates/forms/src/appearance.rs @@ -0,0 +1,331 @@ +//! Field appearances (§12.7.4.3 and §12.5.5), execution plan M6.2. +//! +//! Text and choice fields are drawn from their `/DA` (font, size, colour) and the widget's `/MK` +//! (background and border colours) and `/BS` (border width and style), inside a `/Tx BMC … EMC` +//! marked-content block as viewers expect. Supported: single-line, multiline (wrapped), comb, +//! password, quadding, auto font size (`0 Tf`), combo boxes and list boxes (selection shown). +//! +//! The `/DA` font is used when the form's `/DR` defines it as a simple font; text is encoded in +//! WinAnsi. Otherwise (composite fonts, missing resources) Helvetica is used, so text is always +//! visible. Widths use the approximate Helvetica metrics of `printcraft-fonts`. + +use printcraft_cos::{Dict, Document, Object, Stream}; +use printcraft_fonts::{helvetica_width, literal, win_ansi, wrap}; + +use crate::{Field, FieldKind, Widget, acroform, flags}; + +/// Format a number for content streams. +fn n(v: f64) -> String { + let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +/// A parsed default appearance string: font resource name, size (0 = auto) and colour operator. +#[derive(Clone, Debug, PartialEq)] +pub struct Da { + pub font: String, + pub size: f64, + /// The colour operation as written (`0 g`, `1 0 0 rg`, `0 0 0 1 k`). + pub color: String, +} + +pub fn parse_da(da: &str) -> Da { + let toks: Vec<&str> = da.split_whitespace().collect(); + let mut out = Da { font: "Helv".into(), size: 0.0, color: "0 g".into() }; + for (i, t) in toks.iter().enumerate() { + match *t { + "Tf" if i >= 2 => { + out.font = toks[i - 2].trim_start_matches('/').to_string(); + out.size = toks[i - 1].parse::().ok().filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0).min(300.0); + } + "g" if i >= 1 && toks[i - 1].parse::().is_ok() => out.color = format!("{} g", toks[i - 1]), + "rg" if i >= 3 && toks[i - 3..i].iter().all(|x| x.parse::().is_ok()) => out.color = format!("{} rg", toks[i - 3..i].join(" ")), + "k" if i >= 4 && toks[i - 4..i].iter().all(|x| x.parse::().is_ok()) => out.color = format!("{} k", toks[i - 4..i].join(" ")), + _ => {} + } + } + out +} + +/// The font resource for `name` from the form's `/DR`, if it is a simple font we can encode for. +fn dr_font(doc: &Document, name: &str) -> Option { + let af = acroform(doc)?; + let dr = doc.resolve(af.get(b"DR")?); + let fonts = doc.resolve(dr.as_dict()?.get(b"Font")?); + let entry = fonts.as_dict()?.get(name.as_bytes())?.clone(); + let font = doc.resolve(&entry); + let fd = font.as_dict()?; + match fd.name(b"Subtype") { + Some(b"Type1" | b"TrueType" | b"MMType1") => {} + _ => return None, + } + // Symbolic fonts (ZapfDingbats, Symbol) can't show WinAnsi text. + if matches!(fd.name(b"BaseFont"), Some(b"ZapfDingbats" | b"Symbol")) { + return None; + } + Some(entry) +} + +fn helvetica() -> Object { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Object::Dict(f) +} + +fn color_array(doc: &Document, mk: Option<&Dict>, key: &[u8]) -> Option { + let a = doc.resolve(mk?.get(key)?); + let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + match v.as_slice() { + [g] => Some(format!("{} g", n(*g))), + [r, g, b] => Some(format!("{} {} {} rg", n(*r), n(*g), n(*b))), + [c, m, y, k] => Some(format!("{} {} {} {} k", n(*c), n(*m), n(*y), n(*k))), + _ => None, + } +} + +fn stroke_op(fill: &str) -> String { + match fill.rsplit_once(' ') { + Some((nums, "g")) => format!("{nums} G"), + Some((nums, "rg")) => format!("{nums} RG"), + Some((nums, "k")) => format!("{nums} K"), + _ => "0 G".into(), + } +} + +/// Background and border from `/MK` and `/BS`; returns (content, border width). +fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let mut c = String::new(); + if let Some(bg) = color_array(doc, mk.as_ref(), b"BG") { + c.push_str(&format!("{bg}\n0 0 {} {} re f\n", n(w), n(h))); + } + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + if let Some(bc) = color_array(doc, mk.as_ref(), b"BC") + && bw > 0.0 + { + let dashed = bs.as_ref().is_some_and(|b| b.name(b"S") == Some(b"D")); + c.push_str(&format!( + "{}\n{} w\n{}{} {} {} {} re S\n[] 0 d\n", + stroke_op(&bc), + n(bw), + if dashed { "[3] 0 d\n" } else { "" }, + n(bw / 2.0), + n(bw / 2.0), + n(w - bw), + n(h - bw) + )); + return (c, bw); + } + (c, 0.0) +} + +/// The appearance of a text or choice field's widget showing `values`. +pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + let (font_name, font_obj) = match dr_font(doc, &da.font) { + Some(o) => (da.font.clone(), o), + None => ("Helv".to_string(), helvetica()), + }; + let (mut c, bw) = frame(doc, &wd, width, height); + let pad = 2.0 + bw; + let inner_w = (width - 2.0 * pad).max(1.0); + let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding); + let mut body: Vec = Vec::new(); + let show = |body: &mut Vec, x: f64, y: f64, text: &str| { + body.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + body.extend(literal(&win_ansi(text))); + body.extend_from_slice(b" Tj\n"); + }; + let x_for = |text: &str, size: f64| -> f64 { + let tw = helvetica_width(text, size); + match q { + 1 => pad + (inner_w - tw) / 2.0, + 2 => width - pad - tw, + _ => pad, + } + }; + let mut size = da.size; + match f.kind { + FieldKind::List => { + // Every option, one per line from the top; selected ones highlighted. + if size == 0.0 { + size = 12.0; + } + let line = size * 1.15; + let top = wd.get(b"TI").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).max(0) as usize; + let mut y = height - pad; + for (export, display) in f.options.iter().skip(top) { + if y - line < 0.0 { + break; + } + if values.contains(export) { + c.push_str(&format!("0.6 0.75 0.86 rg\n{} {} {} {} re f\n", n(bw), n(y - line), n(width - 2.0 * bw), n(line))); + } + show(&mut body, pad, y - line + size * 0.25, display); + y -= line; + } + } + _ => { + let text: String = if f.kind == FieldKind::Combo { + values.iter().map(|v| f.options.iter().find(|(e, _)| e == v).map_or(v.clone(), |(_, d)| d.clone())).collect::>().join(", ") + } else { + values.first().cloned().unwrap_or_default() + }; + let text = if f.has(flags::PASSWORD) { "*".repeat(text.chars().count()) } else { text }; + let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0); + if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { + if size == 0.0 { + // Auto size: the largest size (≤ 12) whose wrapped lines fit the height. + size = 12.0; + while size > 4.0 && wrap(&text, size, inner_w).len() as f64 * size * 1.15 > height - 2.0 * pad { + size -= 0.5; + } + } + let mut y = height - pad - size * 0.85; + for line in wrap(&text, size, inner_w) { + if y < -size { + break; + } + show(&mut body, x_for(&line, size), y, &line); + y -= size * 1.15; + } + } else { + if size == 0.0 { + size = ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0); + let tw = helvetica_width(&text, size); + if tw > inner_w && !comb { + size = (size * inner_w / tw).max(4.0); + } + } + // Centre Helvetica's ascent (0.718) and descent (0.207) vertically. + let y = (height - 0.925 * size) / 2.0 + 0.207 * size; + if comb { + let cells = f.max_len.unwrap_or(1).max(1); + let cell = width / cells as f64; + for (i, ch) in text.chars().take(cells).enumerate() { + let s = ch.to_string(); + show(&mut body, cell * i as f64 + (cell - helvetica_width(&s, size)) / 2.0, y, &s); + } + } else { + show(&mut body, x_for(&text, size), y, &text); + } + } + } + } + let mut content = c.into_bytes(); + content.extend( + format!( + "/Tx BMC\nq\n{} {} {} {} re W n\nBT\n/{} {} Tf\n{}\n", + n(bw), + n(bw), + n(width - 2.0 * bw), + n(height - 2.0 * bw), + font_name, + n(size), + da.color + ) + .bytes(), + ); + content.extend(body); + content.extend_from_slice(b"ET\nQ\nEMC\n"); + let mut fonts = Dict::new(); + fonts.set(font_name.into_bytes(), font_obj); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"Resources".to_vec(), Object::Dict(res)); + Stream::flate(d, &content) +} + +/// On/Off appearances for a check box or radio button that has none (a check mark or a dot, +/// drawn as paths, so no symbol font is needed). +pub fn check_box_states(doc: &Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let (frame_c, _) = frame(doc, &wd, width, height); + let form = |content: String| -> Object { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect())); + Object::Stream(Stream::flate(d, content.as_bytes())) + }; + let s = width.min(height); + let (cx, cy) = (width / 2.0, height / 2.0); + let mark = if kind == FieldKind::Radio { + let r = s * 0.25; + let k = 0.5523 * r; + format!( + "0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + ) + } else { + format!( + "0 G\n{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + n((s * 0.1).max(1.0)), + n(cx - s * 0.28), + n(cy), + n(cx - s * 0.08), + n(cy - s * 0.22), + n(cx + s * 0.3), + n(cy + s * 0.25) + ) + }; + let mut nd = Dict::new(); + nd.set(on_name.as_bytes().to_vec(), form(format!("{frame_c}{mark}"))); + nd.set(b"Off".to_vec(), form(frame_c)); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(nd)); + ap +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_appearance_strings_parse() { + assert_eq!(parse_da("/Helv 0 Tf 0 g"), Da { font: "Helv".into(), size: 0.0, color: "0 g".into() }); + assert_eq!(parse_da("0.1 0.2 0.3 rg /F1 11 Tf"), Da { font: "F1".into(), size: 11.0, color: "0.1 0.2 0.3 rg".into() }); + assert_eq!(parse_da("/Cour 9 Tf 0 0 0 1 k").color, "0 0 0 1 k"); + assert_eq!(parse_da("garbage").font, "Helv"); + assert_eq!(stroke_op("1 0 0 rg"), "1 0 0 RG"); + } +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs new file mode 100644 index 00000000..85b502e7 --- /dev/null +++ b/crates/forms/src/lib.rs @@ -0,0 +1,529 @@ +//! printcraft-forms — interactive forms (AcroForm, ISO 32000-2 §12.7), execution plan M6.1–M6.2. +//! +//! - [`fields`]: the field tree flattened to terminal fields, each with its widgets (page, +//! rectangle, on-state), inherited attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`, +//! `/MaxLen`) and choice options. +//! - [`set_value`]: fill a field. Text and choice fields get new appearance streams +//! ([`appearance`]); check boxes and radio buttons switch `/V` and each widget's `/AS` between +//! the states their appearances already define. +//! - [`reset`]: Acrobat's Clear form (back to `/DV`). +//! +//! Not yet: JavaScript actions (format, keystroke, validate, calculate — M6.4/M6.5) and rich text +//! values (`/RV`, which is removed when a value is set so it can't contradict `/V`). + +use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; + +pub mod appearance; + +#[cfg(test)] +mod tests; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum FormError { + #[error("the document has no form fields")] + NoForm, + #[error("there is no field named {0:?}")] + NoSuchField(String), + #[error("{0:?} is read-only")] + ReadOnly(String), + #[error("{0}")] + Invalid(String), + #[error("{0}")] + Cos(#[from] printcraft_cos::CosError), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldKind { + Text, + CheckBox, + Radio, + PushButton, + Combo, + List, + Signature, +} + +/// Field flags (§12.7.4, Tables 226, 228, 229, 231). +pub mod flags { + pub const READ_ONLY: u32 = 1; + pub const REQUIRED: u32 = 1 << 1; + pub const MULTILINE: u32 = 1 << 12; + pub const PASSWORD: u32 = 1 << 13; + pub const NO_TOGGLE_TO_OFF: u32 = 1 << 14; + pub const RADIO: u32 = 1 << 15; + pub const PUSH_BUTTON: u32 = 1 << 16; + pub const COMBO: u32 = 1 << 17; + pub const EDIT: u32 = 1 << 18; + pub const MULTI_SELECT: u32 = 1 << 21; + pub const DO_NOT_SCROLL: u32 = 1 << 23; + pub const COMB: u32 = 1 << 24; + pub const RADIOS_IN_UNISON: u32 = 1 << 25; +} + +/// One widget (the field's appearance on a page). +#[derive(Clone, Debug, PartialEq)] +pub struct Widget { + pub obj: ObjRef, + /// 0-based page index, when the widget is on a page. + pub page: Option, + /// In user space, normalized. + pub rect: [f64; 4], + /// Check boxes and radio buttons: the name of the "on" appearance state. + pub on_state: Option, + /// The current appearance state (`/AS`). + pub state: Option, +} + +/// A terminal form field. +#[derive(Clone, Debug, PartialEq)] +pub struct Field { + /// Fully qualified name (`parent.child`). + pub name: String, + pub obj: ObjRef, + pub kind: FieldKind, + /// Text: the text. Check box / radio: the selected state (none when off). Choice: the + /// selected export values. + pub value: Vec, + pub default: Vec, + pub flags: u32, + pub max_len: Option, + /// Choice options: (export value, display text). + pub options: Vec<(String, String)>, + /// Default appearance string (`/DA`, inherited from the form if absent). + pub da: String, + /// Quadding: 0 left, 1 centred, 2 right. + pub quadding: i64, + pub tooltip: Option, + pub widgets: Vec, +} + +impl Field { + pub fn has(&self, flag: u32) -> bool { + self.flags & flag != 0 + } + + pub fn read_only(&self) -> bool { + self.has(flags::READ_ONLY) + } + + /// The value as one string: text, the state name, or the selected display texts. + pub fn display_value(&self) -> String { + match self.kind { + FieldKind::Combo | FieldKind::List => self + .value + .iter() + .map(|v| self.options.iter().find(|(e, _)| e == v).map_or(v.clone(), |(_, d)| d.clone())) + .collect::>() + .join(", "), + _ => self.value.join(", "), + } + } +} + +/// A value to put in a field. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldValue { + Text(String), + /// Check box: checked or not. + Check(bool), + /// Radio group: the on-state to select (`None`: none selected). + Radio(Option), + /// Combo box or list box: export values (several only for multi-select lists). + Choice(Vec), +} + +// ── reading ───────────────────────────────────────────────────────────────────────────────── + +fn text_of(o: &Object) -> Option { + match o { + Object::String(s) => Some(s.to_text()), + Object::Name(n) => Some(String::from_utf8_lossy(n).into_owned()), + _ => None, + } +} + +fn values_of(doc: &Document, o: Option<&Object>) -> Vec { + let Some(o) = o else { return Vec::new() }; + match &*doc.resolve(o) { + Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(), + Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), + other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(), + } +} + +fn nums(doc: &Document, o: Option<&Object>) -> Option> { + let o = doc.resolve(o?); + o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() +} + +/// Leaf page objects in order (a small walker; `organize` and `annot` have their own). +fn page_refs(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![pages]; + while let Some(node) = stack.pop() { + if !seen.insert(node) || seen.len() > 1_000_000 { + continue; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + match d.get(b"Kids").map(|k| doc.resolve(k)) { + Some(kids) if d.name(b"Type") != Some(b"Page") => { + if let Some(a) = kids.as_array() { + stack.extend(a.iter().rev().filter_map(|k| k.as_ref())); + } + } + _ => out.push(node), + } + } + out +} + +/// The AcroForm dictionary. +pub fn acroform(doc: &Document) -> Option { + let root = doc.root()?; + let cat = doc.get(root); + let af = cat.as_dict()?.get(b"AcroForm")?.clone(); + doc.resolve(&af).as_dict().cloned() +} + +#[derive(Clone, Default)] +struct Inherited { + name: String, + ft: Option>, + ff: Option, + v: Option, + dv: Option, + da: Option, + q: Option, + max_len: Option, +} + +/// Every terminal field, in tree order. +pub fn fields(doc: &Document) -> Vec { + let Some(af) = acroform(doc) else { return Vec::new() }; + let mut page_of = std::collections::HashMap::new(); + for (i, p) in page_refs(doc).iter().enumerate() { + if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { + for e in doc.resolve(&a).as_array().into_iter().flatten() { + if let Some(r) = e.as_ref() { + page_of.entry(r).or_insert(i); + } + } + } + } + let base = Inherited { + da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))), + q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()), + ..Default::default() + }; + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { + if let Some(r) = f.as_ref() { + walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); + } + } + out +} + +fn walk( + doc: &Document, + r: ObjRef, + parent: &Inherited, + page_of: &std::collections::HashMap, + seen: &mut std::collections::HashSet, + out: &mut Vec, + depth: usize, +) { + if depth > 64 || !seen.insert(r) { + return; + } + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { return }; + let mut inh = parent.clone(); + if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { + inh.name = if inh.name.is_empty() { t } else { format!("{}.{t}", inh.name) }; + } + if let Some(ft) = d.name(b"FT") { + inh.ft = Some(ft.to_vec()); + } + if let Some(ff) = d.get(b"Ff").and_then(|o| doc.resolve(o).as_int()) { + inh.ff = Some(ff as u32); + } + for (k, slot) in [(&b"V"[..], &mut inh.v), (b"DV", &mut inh.dv)] { + if let Some(v) = d.get(k) { + *slot = Some(v.clone()); + } + } + if let Some(da) = d.get(b"DA").and_then(|o| text_of(&doc.resolve(o))) { + inh.da = Some(da); + } + if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) { + inh.q = Some(q); + } + if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) { + inh.max_len = usize::try_from(m).ok(); + } + let kids: Vec = + d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); + // Kids with /T are fields; kids without are this field's widgets. + let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); + if !field_kids.is_empty() { + for k in field_kids { + walk(doc, k, &inh, page_of, seen, out, depth + 1); + } + return; + } + let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; + let ff = inh.ff.unwrap_or(0); + let kind = match inh.ft.as_deref() { + Some(b"Tx") => FieldKind::Text, + Some(b"Btn") if ff & flags::PUSH_BUTTON != 0 => FieldKind::PushButton, + Some(b"Btn") if ff & flags::RADIO != 0 => FieldKind::Radio, + Some(b"Btn") => FieldKind::CheckBox, + Some(b"Ch") if ff & flags::COMBO != 0 => FieldKind::Combo, + Some(b"Ch") => FieldKind::List, + Some(b"Sig") => FieldKind::Signature, + _ => return, + }; + let widgets = widget_refs + .into_iter() + .filter_map(|w| { + let wo = doc.get(w); + let wd = wo.as_dict()?; + let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]); + let on_state = wd + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); + Some(Widget { + obj: w, + page: page_of.get(&w).copied(), + rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])], + on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)), + state: wd.name(b"AS").map(|s| String::from_utf8_lossy(s).into_owned()), + }) + }) + .collect(); + let options = d + .get(b"Opt") + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|o| match &*doc.resolve(o) { + Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), + other => text_of(other).map(|t| (t.clone(), t)), + }) + .collect(); + out.push(Field { + name: inh.name.clone(), + obj: r, + kind, + value: values_of(doc, inh.v.as_ref()), + default: values_of(doc, inh.dv.as_ref()), + flags: ff, + max_len: inh.max_len, + options, + da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), + quadding: inh.q.unwrap_or(0), + tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), + widgets, + }); +} + +// ── writing ───────────────────────────────────────────────────────────────────────────────── + +/// Fill the field `name`. +pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { + let all = fields(doc); + if all.is_empty() { + return Err(FormError::NoForm); + } + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + if f.read_only() { + return Err(FormError::ReadOnly(name.into())); + } + write_value(doc, f, value) +} + +fn invalid(m: impl Into) -> Result { + Err(FormError::Invalid(m.into())) +} + +fn write_value(doc: &mut Document, f: &Field, value: &FieldValue) -> Result<(), FormError> { + match (f.kind, value) { + (FieldKind::Text, FieldValue::Text(t)) => { + if let Some(max) = f.max_len + && t.chars().count() > max + { + return invalid(format!("{:?} takes at most {max} characters", f.name)); + } + doc.update_dict(f.obj, |d| { + if t.is_empty() { + d.remove(b"V"); + } else { + d.set(b"V".to_vec(), PdfString::text(t)); + } + d.remove(b"RV"); + })?; + redraw(doc, f, std::slice::from_ref(t)) + } + (FieldKind::CheckBox, v) => { + let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); + let on = match v { + FieldValue::Check(on) => *on, + // The on-state name or a yes/no word also work (agents, FDF-style data). + FieldValue::Text(t) | FieldValue::Radio(Some(t)) => { + let t = t.trim(); + if t == state || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { + true + } else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) { + false + } else { + return invalid(format!("{:?} is a check box: use true or false", f.name)); + } + } + FieldValue::Radio(None) => false, + FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), + }; + set_states(doc, f, on.then_some(state.as_str())) + } + (FieldKind::Radio, v) => { + let choice = match v { + FieldValue::Radio(c) => c.clone(), + FieldValue::Text(t) if t.is_empty() => None, + FieldValue::Text(t) => Some(t.clone()), + FieldValue::Check(false) => None, + _ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)), + }; + if let Some(c) = &choice + && !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str())) + { + let opts: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).collect(); + return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", "))); + } + if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { + return invalid(format!("{:?} must keep one option selected", f.name)); + } + set_states(doc, f, choice.as_deref()) + } + (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { + let vals: Vec = match value { + FieldValue::Choice(v) => v.clone(), + FieldValue::Text(t) if t.is_empty() => Vec::new(), + FieldValue::Text(t) => vec![t.clone()], + _ => unreachable!("matched above"), + }; + if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { + return invalid(format!("{:?} takes a single value", f.name)); + } + // Accept export values or display texts; free text only in editable combo boxes. + let mut exports = Vec::new(); + for v in &vals { + match f.options.iter().find(|(e, d)| e == v || d == v) { + Some((e, _)) => exports.push(e.clone()), + None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), + None if f.options.is_empty() => exports.push(v.clone()), + None => { + let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); + return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); + } + } + } + let indices: Vec = + exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); + doc.update_dict(f.obj, |d| { + match exports.as_slice() { + [] => { + d.remove(b"V"); + } + [one] => d.set(b"V".to_vec(), PdfString::text(one)), + many => d.set(b"V".to_vec(), Object::Array(many.iter().map(|e| Object::String(PdfString::text(e))).collect())), + } + if indices.is_empty() { + d.remove(b"I"); + } else { + d.set(b"I".to_vec(), Object::Array(indices)); + } + })?; + redraw(doc, f, &exports) + } + (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), + (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), + (kind, v) => invalid(format!("{:?} is a {kind:?} field and can't take {v:?}", f.name)), + } +} + +/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. +fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { + let v = on.unwrap_or("Off"); + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), Object::name(v)))?; + for w in &f.widgets { + let state = match (on, w.on_state.as_deref()) { + (Some(c), Some(s)) if c == s => s, + _ => "Off", + }; + // A widget without appearances for its states gets PrintCraft's own. + let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); + if !has_ap { + let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); + let ap = appearance::check_box_states(doc, w, f.kind, &on_name); + doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + } + doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), Object::name(state)))?; + } + Ok(()) +} + +/// Regenerate the normal appearance of every widget of a text or choice field. +fn redraw(doc: &mut Document, f: &Field, values: &[String]) -> Result<(), FormError> { + for w in &f.widgets { + let stream = appearance::field_appearance(doc, f, w, values); + let ap = doc.add(Object::Stream(stream)); + let mut apd = Dict::new(); + apd.set(b"N".to_vec(), Object::Ref(ap)); + doc.update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + d.remove(b"AS"); + })?; + } + Ok(()) +} + +/// Acrobat's Clear form: every field (or the named ones) back to its default value. +pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { + let all = fields(doc); + if all.is_empty() { + return Err(FormError::NoForm); + } + if let Some(n) = names + && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n)) + { + return Err(FormError::NoSuchField(missing.clone())); + } + let mut changed = 0; + for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) { + if f.value == f.default { + continue; + } + let v = match f.kind { + FieldKind::Text => FieldValue::Text(f.default.first().cloned().unwrap_or_default()), + FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()), + FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()), + FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()), + FieldKind::PushButton | FieldKind::Signature => continue, + }; + // A reset is not blocked by NoToggleToOff: go through the writer directly. + let mut tmp = f.clone(); + tmp.flags &= !flags::NO_TOGGLE_TO_OFF; + write_value(doc, &tmp, &v)?; + changed += 1; + } + Ok(changed) +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs new file mode 100644 index 00000000..a6802545 --- /dev/null +++ b/crates/forms/src/tests.rs @@ -0,0 +1,203 @@ +use std::sync::Arc; + +use printcraft_cos::{Document, Object, SaveOptions, write_incremental}; + +use super::*; + +/// One page with a form: text fields (plain, multiline, comb, password, read-only, nested), +/// a check box with appearances, one without, a radio group, a combo and a multi-select list, +/// and a push button. +fn fixture() -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 16 0 R 17 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R] >>".into(), // 3 + "<< /Fields [10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 17 0 R 20 0 R 23 0 R 24 0 R 25 0 R 26 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /ZaDb 6 0 R >> >> >>".into(), // 4 + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 5 + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>".into(), // 6 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 7 (empty appearance) + "<< /Length 0 >>\nstream\n\nendstream".into(), // 8 + "null".into(), // 9 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 700 250 720] /P 3 0 R /V (Ada) /DV (Ada) /MK << /BG [1 1 0.9] /BC [0 0 0] >> >>".into(), // 10 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (notes) /Ff 4096 /DA (/Helv 10 Tf 0 0 1 rg) /Rect [50 600 250 680] /P 3 0 R >>".into(), // 11 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (zip) /Ff 16777216 /MaxLen 5 /Rect [50 560 150 580] /P 3 0 R >>".into(), // 12 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /Rect [50 530 150 550] /P 3 0 R >>".into(), // 13 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (id) /Ff 1 /V (A-1) /Rect [50 500 150 520] /P 3 0 R >>".into(), // 14 + "<< /T (address) /Kids [16 0 R] >>".into(), // 15 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /Parent 15 0 R /Q 1 /Rect [50 470 250 490] /P 3 0 R >>".into(), // 16 + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 8 0 R >> >> >>".into(), // 17 + "null".into(), // 18 + "null".into(), // 19 + "<< /FT /Btn /T (size) /Ff 49152 /V /Off /DV /S /Kids [21 0 R 22 0 R] >>".into(), // 20 radio, NoToggleToOff + "<< /Type /Annot /Subtype /Widget /Parent 20 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 7 0 R /Off 8 0 R >> >> >>".into(), // 21 + "<< /Type /Annot /Subtype /Widget /Parent 20 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 7 0 R /Off 8 0 R >> >> >>".into(), // 22 + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [300 600 450 620] /P 3 0 R >>".into(), // 23 + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (toppings) /Ff 2097152 /Opt [(Cheese) (Ham) (Olives)] /Rect [300 500 450 580] /P 3 0 R >>".into(), // 24 + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (go) /Ff 65536 /Rect [300 450 380 470] /P 3 0 R >>".into(), // 25 + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (bare) /Rect [400 700 415 715] /P 3 0 R /Foo (kept) >>".into(), // 26 check box without AP + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") +} + +fn reopen(doc: &Document) -> Document { + let bytes = write_incremental(doc, &SaveOptions::default()).expect("writes"); + hayro_syntax::Pdf::new(bytes.clone()).expect("hayro-syntax parses the output"); + Document::open(Arc::new(bytes)).expect("reopens") +} + +fn field<'a>(all: &'a [Field], name: &str) -> &'a Field { + all.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}")) +} + +fn ap(doc: &Document, w: &Widget) -> String { + let wd = doc.get(w.obj).as_dict().cloned().unwrap(); + let n = wd.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").expect("normal appearance stream"); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +#[test] +fn the_field_tree_is_read_with_inheritance() { + let doc = fixture(); + let all = fields(&doc); + let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["name", "notes", "zip", "pin", "id", "address.city", "agree", "size", "country", "toppings", "go", "bare"]); + let name = field(&all, "name"); + assert_eq!((name.kind, name.value.as_slice(), name.da.as_str()), (FieldKind::Text, &["Ada".to_string()][..], "/Helv 0 Tf 0 g")); + assert_eq!(name.widgets[0].page, Some(0)); + assert_eq!(field(&all, "zip").max_len, Some(5)); + assert!(field(&all, "id").read_only()); + let size = field(&all, "size"); + assert_eq!(size.kind, FieldKind::Radio); + assert_eq!(size.widgets.iter().map(|w| w.on_state.clone().unwrap()).collect::>(), ["S", "L"]); + assert!(size.value.is_empty(), "/Off is no value"); + assert_eq!(size.default, ["S"]); + assert_eq!(field(&all, "country").options, [("ca".into(), "Canada".into()), ("fr".into(), "France".into())]); + assert_eq!(field(&all, "toppings").kind, FieldKind::List); + assert_eq!(field(&all, "go").kind, FieldKind::PushButton); + assert_eq!(field(&all, "address.city").quadding, 1); +} + +#[test] +fn text_fields_get_new_appearances() { + let mut doc = fixture(); + set_value(&mut doc, "name", &FieldValue::Text("Grace (Hopper)".into())).unwrap(); + set_value(&mut doc, "notes", &FieldValue::Text("A long note that has to wrap over several lines of the box".into())).unwrap(); + set_value(&mut doc, "zip", &FieldValue::Text("12345".into())).unwrap(); + set_value(&mut doc, "pin", &FieldValue::Text("1234".into())).unwrap(); + set_value(&mut doc, "address.city", &FieldValue::Text("Zürich".into())).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(field(&all, "name").value, ["Grace (Hopper)"]); + let name_ap = ap(&doc, &field(&all, "name").widgets[0]); + assert!(name_ap.contains("/Tx BMC") && name_ap.contains("EMC"), "{name_ap}"); + assert!(name_ap.contains("(Grace \\(Hopper\\)) Tj"), "{name_ap}"); + assert!(name_ap.contains("1 1 0.9 rg"), "background from /MK: {name_ap}"); + let notes = ap(&doc, &field(&all, "notes").widgets[0]); + assert!(notes.matches(" Tj").count() >= 2, "wrapped: {notes}"); + assert!(notes.contains("/Helv 10 Tf") && notes.contains("0 0 1 rg")); + let zip = ap(&doc, &field(&all, "zip").widgets[0]); + assert_eq!(zip.matches(" Tj").count(), 5, "one cell per character: {zip}"); + let pin = ap(&doc, &field(&all, "pin").widgets[0]); + assert!(pin.contains("(****) Tj") && !pin.contains("1234"), "{pin}"); + // WinAnsi bytes for non-ASCII text. + let w = &field(&all, "address.city").widgets[0]; + let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + let raw = s.decoded().unwrap(); + assert!(raw.windows(8).any(|x| x == b"(Z\xfcrich)")); +} + +#[test] +fn check_boxes_and_radios_switch_states() { + let mut doc = fixture(); + set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap(); + set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))).unwrap(); + set_value(&mut doc, "bare", &FieldValue::Text("yes".into())).unwrap(); + let doc2 = reopen(&doc); + let all = fields(&doc2); + assert_eq!(field(&all, "agree").value, ["Yes"]); + assert_eq!(field(&all, "agree").widgets[0].state.as_deref(), Some("Yes")); + let size = field(&all, "size"); + assert_eq!(size.value, ["L"]); + assert_eq!(size.widgets.iter().map(|w| w.state.clone().unwrap()).collect::>(), ["Off", "L"]); + // The bare check box got PrintCraft's own appearances and kept its other keys. + let bare = field(&all, "bare"); + assert_eq!(bare.value, ["Yes"]); + assert_eq!(bare.widgets[0].on_state.as_deref(), Some("Yes")); + assert!(doc2.get(bare.widgets[0].obj).as_dict().unwrap().contains(b"Foo")); + let mut doc = doc2; + // Radio validation and NoToggleToOff. + assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(Some("XL".into()))), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(None)), Err(FormError::Invalid(_)))); + set_value(&mut doc, "agree", &FieldValue::Check(false)).unwrap(); + assert!(field(&fields(&doc), "agree").value.is_empty()); +} + +#[test] +fn choices_accept_exports_or_display_text() { + let mut doc = fixture(); + set_value(&mut doc, "country", &FieldValue::Text("France".into())).unwrap(); + set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into(), "Olives".into()])).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let country = field(&all, "country"); + assert_eq!(country.value, ["fr"]); + assert_eq!(country.display_value(), "France"); + assert!(ap(&doc, &country.widgets[0]).contains("(France) Tj")); + let toppings = field(&all, "toppings"); + assert_eq!(toppings.value, ["Ham", "Olives"]); + let list = ap(&doc, &toppings.widgets[0]); + assert_eq!(list.matches("0.6 0.75 0.86 rg").count(), 2, "two highlighted rows: {list}"); + assert_eq!(doc.get(toppings.obj).as_dict().unwrap().get(b"I").unwrap().as_array().unwrap().len(), 2); + let mut doc = doc; + assert!(matches!(set_value(&mut doc, "country", &FieldValue::Text("Spain".into())), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "country", &FieldValue::Choice(vec!["ca".into(), "fr".into()])), Err(FormError::Invalid(_)))); +} + +#[test] +fn errors_are_specific_and_change_nothing() { + let mut doc = fixture(); + assert_eq!(set_value(&mut doc, "nope", &FieldValue::Text("x".into())), Err(FormError::NoSuchField("nope".into()))); + assert_eq!(set_value(&mut doc, "id", &FieldValue::Text("x".into())), Err(FormError::ReadOnly("id".into()))); + assert!(matches!(set_value(&mut doc, "zip", &FieldValue::Text("123456".into())), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "go", &FieldValue::Text("x".into())), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "agree", &FieldValue::Text("maybe".into())), Err(FormError::Invalid(_)))); + assert!(!doc.is_modified()); +} + +#[test] +fn reset_restores_defaults() { + let mut doc = fixture(); + set_value(&mut doc, "name", &FieldValue::Text("Grace".into())).unwrap(); + set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap(); + set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))).unwrap(); + assert_eq!(reset(&mut doc, Some(&["agree".to_string()])).unwrap(), 1); + assert!(field(&fields(&doc), "agree").value.is_empty()); + assert_eq!(field(&fields(&doc), "name").value, ["Grace"], "only the named field"); + let n = reset(&mut doc, None).unwrap(); + assert!(n >= 2); + let all = fields(&reopen(&doc)); + assert_eq!(field(&all, "name").value, ["Ada"]); + assert_eq!(field(&all, "size").value, ["S"], "back to /DV"); + assert!(matches!(reset(&mut doc, Some(&["nope".to_string()])), Err(FormError::NoSuchField(_)))); +} + +#[test] +fn documents_without_forms_say_so() { + let mut doc = Document::new_empty(); + assert!(fields(&doc).is_empty()); + assert_eq!(set_value(&mut doc, "x", &FieldValue::Text("y".into())), Err(FormError::NoForm)); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index b59df553..979ca91c 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -268,12 +268,23 @@ pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result { doc: &'a Document, page_index: HashMap, + /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. + /// Looking each name up by walking the tree was O(links × names): minutes on manuals. + named: HashMap, &'a Object>, } impl<'a> Inspector<'a> { fn new(doc: &'a Document) -> Self { let page_index = doc.get_pages().into_iter().map(|(n, id)| (id, n as usize - 1)).collect(); - Self { doc, page_index } + let mut me = Self { doc, page_index, named: HashMap::new() }; + let mut named = HashMap::new(); + if let Some(tree) = + doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) + { + me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); + } + me.named = named; + me } fn fill(&self, info: &mut DocInfo) { @@ -384,16 +395,39 @@ impl<'a> Inspector<'a> { fn named_dest(&self, key: &[u8]) -> Option<&'a Object> { let catalog = self.doc.catalog().ok()?; - if let Some(tree) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| self.dict(o)) { - let mut seen = HashSet::new(); - if let Some((_, v)) = self.name_tree(tree, &mut seen, 0).into_iter().find(|(k, _)| k.as_bytes() == key) { - return Some(v); - } + if let Some(v) = self.named.get(key) { + return Some(v); } // PDF 1.1 style /Dests dictionary. catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) } + /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). + fn name_tree_raw(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>) { + if depth > 32 || !seen.insert(node as *const _) { + return; + } + if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { + for pair in pairs.chunks(2) { + if let [k, v] = pair { + let key = match self.resolve(k) { + Object::String(s, _) => s.clone(), + Object::Name(n) => n.clone(), + _ => continue, + }; + out.entry(key).or_insert(v); + } + } + } + if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { + for k in kids { + if let Some(d) = self.dict(k) { + self.name_tree_raw(d, seen, depth + 1, out); + } + } + } + } + fn name_tree(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32) -> Vec<(String, &'a Object)> { let mut out = Vec::new(); if depth > 32 || !seen.insert(node as *const _) { @@ -801,7 +835,8 @@ fn alpha(n: usize) -> String { } /// `D:20260930104512-04'00'` → `2026-09-30 10:45`. -fn pretty_date(s: &str) -> String { +/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged. +pub fn pretty_date(s: &str) -> String { let d = s.trim_start_matches("D:"); if d.len() >= 12 && d[..12].bytes().all(|b| b.is_ascii_digit()) { format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12]) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cc36b39e..5a32895a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,7 +12,7 @@ pub mod text; pub use inspect::{ Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, Link, LinkTarget, OutlineItem, PageInfo, attachment_data, - inspect, + inspect, pretty_date, }; pub use raster::{MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, effective_scale}; pub use text::{PageText, TextGlyph}; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0d09f6ce..4b2347c7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -128,6 +128,8 @@ pub struct DocView { pub pending_edit: Option, /// Commenting state: selected comment, gestures, composer. pub comments: crate::comments::CommentView, + /// Form filling state: the focused field. + pub forms: crate::forms_ui::FormView, /// A non-edit action requested by the organize toolbar, handled by the app. pub pending_action: Option, } @@ -196,6 +198,7 @@ impl DocView { pending_edit: None, pending_action: None, comments: Default::default(), + forms: Default::default(), } } @@ -776,6 +779,8 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { }; let prefs = &app.comment_prefs; let allowed = doc.allows_annotation(); + let form = doc.form.clone(); + let can_fill = doc.allows_form_filling(); let mut hover_text: Option<(Pos2, String)> = None; let mut clicked_link: Option = None; let mut canvas_action: Option = None; @@ -887,7 +892,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { // Comments: tools, selection, moving and resizing come before text selection. let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed }; - let consumed = comments::page_input(ui, &resp, &pcx, view); + // Form fields take clicks first with the Select tool (as Acrobat fills fields in + // every viewing mode); then comments; then text selection. + let on_field = tool == QuickTool::Select && crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view); + let consumed = on_field || comments::page_input(ui, &resp, &pcx, view); // Text layer: find matches, selection, I-beam and drag-to-select. let to_screen = |g: [f32; 4]| xf.view_rect(g); @@ -958,6 +966,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { comments::page_after_text(&resp, &pcx, view); comments::paint_page(ui, painter, &pcx, view); + crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view); // Form-field highlight (Acrobat's "Highlight existing fields"). if view.highlight_fields { @@ -1087,6 +1096,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { view.pending_edit = Some(e); } + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form) { + view.pending_edit = Some(e); + } + let form_notice = view.forms.notice.take(); let mut tool = app.quick_tool; comments::keys(ui.ctx(), view, &mut tool, allowed); match canvas_action { @@ -1101,6 +1114,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { None => {} } app.quick_tool = tool; + if let Some(n) = form_notice { + app.notify(n); + } quick_bar(app, avail, ui); } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index c876ea33..c553453e 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -22,6 +22,7 @@ impl PrintCraftApp { let why = match spec.needs { commands::Needs::Undo => "Nothing to undo".to_string(), commands::Needs::Redo => "Nothing to redo".to_string(), + commands::Needs::FillForms if self.active.is_some() => "This document has no form fields you can fill in".to_string(), commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => { if self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_security_change()) { "This document isn't password-protected".to_string() @@ -129,6 +130,12 @@ impl PrintCraftApp { } } "form.fields" => self.right = Some(RightPanel::Fields), + "form.clear" => { + if let Some(i) = active { + self.views[i].forms.focus = None; + } + self.apply_edit(Edit::ResetForm { names: None }); + } "page.organize" => { if let Some(i) = active { self.views[i].organize = !self.views[i].organize; diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs new file mode 100644 index 00000000..8820425b --- /dev/null +++ b/crates/ui-egui/src/forms_ui.rs @@ -0,0 +1,293 @@ +//! Filling in forms on the page (execution plan M6; Acrobat fills fields in every viewing mode). +//! +//! Text fields open an in-place editor over the widget (Enter or clicking away commits, Escape +//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click. Combo +//! and list boxes open a list of their options. Every change is one undoable engine edit. + +use egui::{Color32, CornerRadius, Rect, Stroke, vec2}; +use printcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, field_flags}; +use printcraft_render::DocInfo; + +use crate::canvas::{DocView, PageXform}; +use crate::theme::Tokens; + +const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); + +/// The field being edited. +#[derive(Clone, Debug, PartialEq)] +pub struct Focus { + pub name: String, + /// Which of the field's widgets (a field can appear on several pages). + pub widget: usize, + /// Text being typed (text fields). + pub text: String, + /// Choices picked so far (list boxes with multiple selection). + pub picked: Vec, + pub request_focus: bool, + /// Select the whole value when the editor opens (entered with Tab), so typing replaces it. + pub select_all: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct FormView { + pub focus: Option, + /// A message for the app to show (e.g. "buttons run JavaScript"). + pub notice: Option, +} + +fn widget_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { + xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) +} + +fn fillable(f: &FormField) -> bool { + !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) +} + +fn open_focus(f: &FormField, widget: usize) -> Focus { + Focus { + name: f.name.clone(), + widget, + text: f.value.first().cloned().unwrap_or_default(), + picked: f.value.clone(), + request_focus: true, + select_all: false, + } +} + +/// Clicks on form widgets of one page. Returns `true` when the click belongs to a field. +#[allow(clippy::too_many_arguments)] +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + form: &[FormField], + allowed: bool, + view: &mut DocView, +) -> bool { + let pointer = ui.input(|i| i.pointer.hover_pos()); + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; + let hit = form.iter().find_map(|f| { + f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && widget_rect(xf, info, page, w.rect).contains(p)).map(|(i, w)| (f, i, w)) + }); + let Some((f, wi, w)) = hit else { return false }; + let usable = allowed && fillable(f); + ui.ctx().set_cursor_icon(match (usable, f.kind) { + (false, _) => egui::CursorIcon::NotAllowed, + (true, FormFieldKind::Text) => egui::CursorIcon::Text, + _ => egui::CursorIcon::PointingHand, + }); + if !resp.clicked() { + return resp.is_pointer_button_down_on(); + } + if !allowed { + view.forms.notice = Some("The document's security settings don't allow filling in form fields".into()); + return true; + } + match f.kind { + _ if f.read_only() => view.forms.notice = Some(format!("{} is read-only", f.name)), + FormFieldKind::PushButton => view.forms.notice = Some("Buttons run JavaScript actions, which arrive with the JavaScript engine (M6)".into()), + FormFieldKind::Signature => view.forms.notice = Some("Signing arrives with digital signatures (M9)".into()), + FormFieldKind::CheckBox => { + view.forms.focus = None; + view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Check(f.value.is_empty()) }); + } + FormFieldKind::Radio => { + view.forms.focus = None; + let on = w.on_state.clone(); + // Clicking the selected button again turns it off unless the group forbids it. + let value = if f.value.first() == on.as_ref() && !f.has(field_flags::NO_TOGGLE_TO_OFF) { None } else { on }; + if value.as_ref() != f.value.first() { + view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Radio(value) }); + } + } + FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List => { + if view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) { + commit(view, form); + view.forms.focus = Some(open_focus(f, wi)); + } + } + } + true +} + +/// The edit for the focused field, if its draft differs from the field's value. +fn commit(view: &mut DocView, form: &[FormField]) { + let Some(focus) = view.forms.focus.take() else { return }; + let Some(f) = form.iter().find(|f| f.name == focus.name) else { return }; + let value = match f.kind { + FormFieldKind::Text if f.value.first().map(String::as_str).unwrap_or("") != focus.text => FieldValue::Text(focus.text), + FormFieldKind::List if f.has(field_flags::MULTI_SELECT) && focus.picked != f.value => FieldValue::Choice(focus.picked), + _ => return, + }; + view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value }); +} + +/// Paint focus and hover frames on a page. +pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { + let pointer = ui.input(|i| i.pointer.hover_pos()); + for f in form { + for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page)) { + let r = widget_rect(xf, info, page, w.rect); + let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi); + if focused { + painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside); + } else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) { + painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); + } + } + } +} + +/// The in-place editor or option list for the focused field. Returns an edit to apply. +pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, form: &[FormField]) -> Option { + let focus = view.forms.focus.clone()?; + let Some(f) = form.iter().find(|f| f.name == focus.name) else { + view.forms.focus = None; + return None; + }; + let w = f.widgets.get(focus.widget)?; + let page = w.page?; + let xf = view.page_xform(page)?; + let rect = widget_rect(&xf, info, page, w.rect); + let zoom = xf.rect.width() / xf.pw.max(1.0); + let t = Tokens::get(ctx); + let mut edit = None; + let mut close = false; + let mut next: Option = None; // Tab / Shift+Tab + match f.kind { + FormFieldKind::Text => { + let da = f.da.split_whitespace().collect::>(); + let size = + da.iter().position(|x| *x == "Tf").and_then(|i| da.get(i.wrapping_sub(1))).and_then(|s| s.parse::().ok()).filter(|s| *s > 0.0); + let multiline = f.has(field_flags::MULTILINE); + let font = (size.unwrap_or(((w.rect[3] - w.rect[1]) as f32 * 0.6).clamp(6.0, 12.0)) * zoom).clamp(6.0, 64.0); + egui::Area::new(egui::Id::new(("form-editor", view.id.0))).order(egui::Order::Foreground).fixed_pos(rect.min).show(ctx, |ui| { + ui.set_min_size(rect.size()); + let fx = view.forms.focus.as_mut().expect("checked"); + let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; + te = te + .desired_width(rect.width() - 6.0) + .font(egui::FontId::proportional(font)) + .background_color(Color32::from_rgb(0xFF, 0xFF, 0xF4)) + .text_color(Color32::BLACK) + .margin(egui::Margin::symmetric(3, 1)) + .password(f.has(field_flags::PASSWORD)) + .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget))); + if let Some(max) = f.max_len { + te = te.char_limit(max); + } + if multiline { + te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); + } + let r = ui.add_sized(rect.size(), te); + if fx.request_focus { + r.request_focus(); + fx.request_focus = false; + if std::mem::take(&mut fx.select_all) + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + let n = fx.text.chars().count(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), r.id); + } + } + let (enter, esc, tab, shift) = ui + .input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift)); + if esc { + close = true; + } else if tab { + next = Some(!shift); + } else if r.lost_focus() && (!multiline || !enter) { + // Enter in a single-line field, or clicking elsewhere, commits. + commit(view, form); + } + }); + } + FormFieldKind::Combo | FormFieldKind::List => { + let multi = f.kind == FormFieldKind::List && f.has(field_flags::MULTI_SELECT); + let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))) + .order(egui::Order::Foreground) + .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) + .show(ctx, |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(rect.width().max(160.0)); + ui.label(egui::RichText::new(&f.name).small().color(t.text_faint)); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + let fx = view.forms.focus.as_mut().expect("checked"); + for (export, display) in &f.options { + let on = if multi { fx.picked.contains(export) } else { f.value.first() == Some(export) }; + if multi { + let mut c = on; + if ui.checkbox(&mut c, display).changed() { + if c { + fx.picked.push(export.clone()); + } else { + fx.picked.retain(|x| x != export); + } + } + } else if ui.selectable_label(on, display).clicked() { + edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) }); + close = true; + } + } + }); + if multi && ui.button("Done").clicked() { + commit(view, form); + } + }); + }); + let clicked_away = ctx.input(|i| i.pointer.any_click()) + && !resp.response.contains_pointer() + && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p)); + let (tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Tab), i.modifiers.shift)); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + close = true; + } else if tab { + next = Some(!shift); + } else if clicked_away { + commit(view, form); + } + } + _ => close = true, + } + if close { + view.forms.focus = None; + } + if let Some(forward) = next { + commit(view, form); + let pending = view.pending_edit.take(); + // The next field that takes typing or a choice, in form order (wrapping). + let order: Vec<(usize, &FormField)> = form + .iter() + .enumerate() + .filter(|(_, x)| { + fillable(x) + && matches!(x.kind, FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List) + && x.widgets.iter().any(|w| w.page.is_some()) + }) + .collect(); + if let Some(pos) = order.iter().position(|(_, x)| x.name == f.name) { + let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; + let target = order[k].1; + let wi = target.widgets.iter().position(|w| w.page.is_some()).unwrap_or(0); + view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); + if let Some(p) = target.widgets[wi].page + && view.page_screen_rect(p).is_none() + { + view.go_to_page(p); + } + } + edit = edit.or(pending); + } + edit.or_else(|| view.pending_edit.take()) +} + +/// Where a field's widget is on screen (tests and automation). +pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: usize) -> Option { + let w = f.widgets.get(widget)?; + let page = w.page?; + let xf = view.page_xform(page)?; + Some(widget_rect(&xf, info, page, w.rect)) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c0f9cb9..c6d04051 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod control; mod dialogs; mod editing; mod files; +pub mod forms_ui; mod home; mod icon_data; pub mod icons; diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index d998b283..8a77322f 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -52,7 +52,11 @@ fn every_registered_command_is_implemented() { continue; } let mut app = PrintCraftApp::new(); - app.open_bytes("doc.pdf", None, fixture(3)).unwrap(); + if spec.id.starts_with("form.") { + app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + } else { + app.open_bytes("doc.pdf", None, fixture(3)).unwrap(); + } // Give undo/redo something to do. app.apply_edit(printcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); if spec.id == "edit.redo" { diff --git a/crates/ui-egui/tests/data/form.pdf b/crates/ui-egui/tests/data/form.pdf new file mode 100644 index 00000000..073fbd0e --- /dev/null +++ b/crates/ui-egui/tests/data/form.pdf @@ -0,0 +1,60 @@ +%PDF-1.7 +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 8 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 9 0 R 10 0 R 11 0 R 12 0 R] >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /TU (Your name) /Rect [20 350 280 372] /P 3 0 R /MK << /BC [0 0 0] >> >> +endobj +5 0 obj +<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [20 310 36 326] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >> +endobj +6 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +7 0 obj +<< /Length 0 >> +stream + +endstream +endobj +8 0 obj +<< /FT /Btn /T (size) /Ff 32768 /V /Off /Kids [9 0 R 10 0 R] >> +endobj +9 0 obj +<< /Type /Annot /Subtype /Widget /Parent 8 0 R /AS /Off /Rect [20 270 36 286] /P 3 0 R /AP << /N << /S 7 0 R /Off 7 0 R >> >> >> +endobj +10 0 obj +<< /Type /Annot /Subtype /Widget /Parent 8 0 R /AS /Off /Rect [60 270 76 286] /P 3 0 R /AP << /N << /L 7 0 R /Off 7 0 R >> >> >> +endobj +11 0 obj +<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [20 220 200 240] /P 3 0 R >> +endobj +12 0 obj +<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /Rect [20 180 280 200] /P 3 0 R >> +endobj +xref +0 13 +0000000000 65535 f +0000000009 00000 n +0000000171 00000 n +0000000252 00000 n +0000000348 00000 n +0000000487 00000 n +0000000647 00000 n +0000000744 00000 n +0000000793 00000 n +0000000872 00000 n +0000001016 00000 n +0000001161 00000 n +0000001316 00000 n +trailer +<< /Size 13 /Root 1 0 R >> +startxref +1418 +%%EOF diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs new file mode 100644 index 00000000..8b038e45 --- /dev/null +++ b/crates/ui-egui/tests/forms.rs @@ -0,0 +1,122 @@ +//! Filling in a form in the real shell (egui_kittest): typing, Tab, check boxes, radios, choices. + +use egui_kittest::Harness; +use printcraft_ui_egui::PrintCraftApp; + +fn harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.set_option("left", "closed").unwrap(); + // The whole 300×400 pt page on screen. + app.set_option("zoom", "150").unwrap(); + app + }); + for _ in 0..60 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h +} + +fn value(h: &Harness<'static, PrintCraftApp>, name: &str) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() +} + +/// Click the centre of a field's widget. +fn click_field(h: &mut Harness<'static, PrintCraftApp>, name: &str, widget: usize) { + let p = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + printcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, widget).expect("on screen").center() + }; + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); +} + +#[test] +fn typing_into_a_text_field_and_tabbing_on() { + let mut h = harness(); + click_field(&mut h, "name", 0); + assert!(h.state().views[0].forms.focus.is_some(), "the editor opened"); + h.event(egui::Event::Text("Ada Lovelace".into())); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "name"), ["Ada Lovelace"]); + assert_eq!( + h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), + Some("country"), + "Tab moves to the next field that takes typing or a choice" + ); + // The country list is open; Escape closes it. Escape also abandons a draft. + h.key_press(egui::Key::Escape); + h.run_steps(2); + click_field(&mut h, "city", 0); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); + h.event(egui::Event::Text("Paris".into())); + h.run_steps(1); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(value(&h, "city").is_empty()); + assert!(h.state().views[0].forms.focus.is_none()); +} + +#[test] +fn check_boxes_radios_and_choices() { + let mut h = harness(); + click_field(&mut h, "agree", 0); + assert_eq!(value(&h, "agree"), ["Yes"]); + click_field(&mut h, "agree", 0); + assert!(value(&h, "agree").is_empty()); + click_field(&mut h, "size", 1); + assert_eq!(value(&h, "size"), ["L"]); + click_field(&mut h, "size", 0); + assert_eq!(value(&h, "size"), ["S"]); + click_field(&mut h, "country", 0); + egui_kittest::kittest::Queryable::get_by_label(&h, "France").click(); + h.run_steps(4); + assert_eq!(value(&h, "country"), ["fr"]); + // Clear form takes everything back; undo restores it. + assert!(h.state_mut().execute("form.clear")); + h.run_steps(2); + assert!(value(&h, "country").is_empty() && value(&h, "size").is_empty()); + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(value(&h, "country"), ["fr"]); +} + +#[test] +fn tabbing_into_a_filled_field_selects_it_so_typing_replaces() { + let mut h = harness(); + click_field(&mut h, "country", 0); + egui_kittest::kittest::Queryable::get_by_label(&h, "Canada").click(); + h.run_steps(3); + click_field(&mut h, "city", 0); + h.event(egui::Event::Text("Paris".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "city"), ["Paris"]); + // name → (Tab) country → (Tab) city, whose text is selected on entry. + click_field(&mut h, "name", 0); + h.key_press(egui::Key::Tab); + h.run_steps(3); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); + h.event(egui::Event::Text("Lyon".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "city"), ["Lyon"]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d4e9c984..382fb5ab 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4299,11 +4299,11 @@ title = "Fill text fields" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.fields"] +tools = ["form_fill", "form_fields"] +evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; no format/keystroke JavaScript yet (M6.4)" [[feature]] id = "form.fill-checkbox-radio" @@ -4311,11 +4311,11 @@ title = "Toggle checkboxes and radio buttons (radios in unison)" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.fields"] +tools = ["form_fill"] +evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states"] +notes = "NoToggleToOff honoured; widgets without appearances get our own" [[feature]] id = "form.fill-choice" @@ -4323,11 +4323,11 @@ title = "Choose from combo boxes and list boxes" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.fields"] +tools = ["form_fill"] +evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"] +notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text" [[feature]] id = "form.push-buttons" @@ -4347,11 +4347,11 @@ title = "Tab between fields" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] +status = "shipped" +commands = ["form.fields"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/forms.rs::tabbing_into_a_filled_field_selects_it_so_typing_replaces"] +notes = "Tab / Shift+Tab move between text and choice fields in field order; entering with Tab selects the value" [[feature]] id = "form.appearance-regeneration" @@ -4359,11 +4359,11 @@ title = "Regenerate field appearances (auto-size, comb, multiline, password)" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["form.fields"] +tools = ["form_fill"] +evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"] +notes = "auto-size, comb, multiline, password, quadding, /MK colours; approximate Helvetica metrics, no /MK rotation yet" [[feature]] id = "form.rich-text-fields" @@ -4539,11 +4539,11 @@ title = "Clear or reset a form" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.clear"] +tools = ["form_reset"] +evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices"] +notes = "back to /DV; undoable" [[feature]] id = "form.submit-form"