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