M6.1/M6.2: form filling: new fonts (L2) and forms (L3) crates; field tree with inheritance, widgets, options; set_value with validation and regenerated appearances (auto-size, comb, multiline, password, quadding, /MK, combo, list selection), check/radio states (own AP when missing), reset to /DV; engine SetFieldValue/ResetForm with fill-forms permission; UI in-place editor (Enter/click-away commit, Escape, Tab with select-all), check/radio toggle, choice popup, Clear form; tools form_fields/form_fill/form_reset. Perf: named destinations indexed once (190 MB manual opens in 1.8 s instead of 172 s); comment and form edits refresh view data from the object graph instead of a full re-inspection (6x faster per edit on large files; equivalence-tested). annot uses the shared fonts helpers. Docs, parity, roadmap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 22:37:35 -04:00
1 parent ff641c669e
commit 67ff9303c8
37 files changed
+2384 -146

No files matched your search

+2 -1
View File
@@ -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)
+11
View File
@@ -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"
Generated
+16
View File
@@ -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"
+2
View File
@@ -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" }
+7 -3
View File
@@ -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 |
+14 -6
View File
@@ -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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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.
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
printcraft-cos = { workspace = true }
printcraft-fonts = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
+5 -82
View File
@@ -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<u8> {
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<String> {
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<Dict> {
if opacity >= 1.0 && !multiply {
return None;
@@ -401,9 +324,9 @@ pub fn build(d: &Dict) -> Option<Stream> {
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");
+88
View File
@@ -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<String>,
pub contents: Option<String>,
/// `/M` as written (a PDF date string).
pub modified: Option<String>,
pub name: Option<String>,
pub in_reply_to: Option<String>,
pub rect: [f32; 4],
pub color: Option<[f32; 3]>,
pub state: Option<String>,
pub quads: Vec<[f32; 8]>,
}
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
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<Summary> {
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<f32> {
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::<f32>().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
}
+1 -1
View File
@@ -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
+115
View File
@@ -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<FieldValue> {
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::<Result<_>>()?,
),
(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<Value> {
let doc = self.doc(a)?;
let out: Vec<Value> = 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::<Vec<_>>()));
}
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<Value> {
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<Value> {
let names = a.get("fields").map(|_| a.strs("fields").map(|v| v.into_iter().map(str::to_owned).collect::<Vec<_>>())).transpose()?;
self.apply(a, Edit::ResetForm { names })
}
}
+4
View File
@@ -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)?,
+12
View File
@@ -195,6 +195,18 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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"])),
+40
View File
@@ -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");
}
+1
View File
@@ -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 }
+4 -1
View File
@@ -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: &[
+4
View File
@@ -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<DocId>)
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()),
+139 -13
View File
@@ -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<String>,
}
/// 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<String>,
/// Interactive form fields of the current state (empty without a form).
pub form: Arc<Vec<printcraft_forms::Field>>,
editor: Option<Editor>,
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<f64>,
width: Option<f64>,
},
/// 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<Vec<String>>,
},
/// 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_render::Annotation> {
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<Keys> {
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<String, EditError> {
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<String, EditError> {
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(())
+92
View File
@@ -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<u8> {
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::<Vec<_>>();
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()))]);
}
+11
View File
@@ -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]
+14
View File
@@ -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.
+111
View File
@@ -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<String> {
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<u8> {
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<u8> {
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)");
}
}
+17
View File
@@ -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 }
+33
View File
@@ -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<Field> = 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).
+331
View File
@@ -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::<f64>().ok().filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0).min(300.0);
}
"g" if i >= 1 && toks[i - 1].parse::<f64>().is_ok() => out.color = format!("{} g", toks[i - 1]),
"rg" if i >= 3 && toks[i - 3..i].iter().all(|x| x.parse::<f64>().is_ok()) => out.color = format!("{} rg", toks[i - 3..i].join(" ")),
"k" if i >= 4 && toks[i - 4..i].iter().all(|x| x.parse::<f64>().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<Object> {
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<String> {
let a = doc.resolve(mk?.get(key)?);
let v: Vec<f64> = 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<u8> = Vec::new();
let show = |body: &mut Vec<u8>, 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::<Vec<_>>().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");
}
}
+529
View File
@@ -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<usize>,
/// In user space, normalized.
pub rect: [f64; 4],
/// Check boxes and radio buttons: the name of the "on" appearance state.
pub on_state: Option<String>,
/// The current appearance state (`/AS`).
pub state: Option<String>,
}
/// 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<String>,
pub default: Vec<String>,
pub flags: u32,
pub max_len: Option<usize>,
/// 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<String>,
pub widgets: Vec<Widget>,
}
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::<Vec<_>>()
.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<String>),
/// Combo box or list box: export values (several only for multi-select lists).
Choice(Vec<String>),
}
// ── reading ─────────────────────────────────────────────────────────────────────────────────
fn text_of(o: &Object) -> Option<String> {
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<String> {
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<Vec<f64>> {
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<ObjRef> {
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<Dict> {
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<Vec<u8>>,
ff: Option<u32>,
v: Option<Object>,
dv: Option<Object>,
da: Option<String>,
q: Option<i64>,
max_len: Option<usize>,
}
/// Every terminal field, in tree order.
pub fn fields(doc: &Document) -> Vec<Field> {
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<ObjRef, usize>,
seen: &mut std::collections::HashSet<ObjRef>,
out: &mut Vec<Field>,
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<ObjRef> =
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<ObjRef> = 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<ObjRef> = 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<T>(m: impl Into<String>) -> Result<T, FormError> {
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<String> = 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<Object> =
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<usize, FormError> {
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)
}
+203
View File
@@ -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<String> = 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::<Vec<_>>(), ["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::<Vec<_>>(), ["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));
}
+42 -7
View File
@@ -268,12 +268,23 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
struct Inspector<'a> {
doc: &'a Document,
page_index: HashMap<ObjectId, usize>,
/// 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<Vec<u8>, &'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<Vec<u8>, &'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])
+1 -1
View File
@@ -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};
+17 -1
View File
@@ -128,6 +128,8 @@ pub struct DocView {
pub pending_edit: Option<Edit>,
/// 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<ViewAction>,
}
@@ -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<LinkTarget> = None;
let mut canvas_action: Option<comments::CanvasAction> = 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);
}
+7
View File
@@ -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;
+293
View File
@@ -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<String>,
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<Focus>,
/// A message for the app to show (e.g. "buttons run JavaScript").
pub notice: Option<String>,
}
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<Edit> {
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<bool> = None; // Tab / Shift+Tab
match f.kind {
FormFieldKind::Text => {
let da = f.da.split_whitespace().collect::<Vec<_>>();
let size =
da.iter().position(|x| *x == "Tf").and_then(|i| da.get(i.wrapping_sub(1))).and_then(|s| s.parse::<f32>().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<Rect> {
let w = f.widgets.get(widget)?;
let page = w.page?;
let xf = view.page_xform(page)?;
Some(widget_rect(&xf, info, page, w.rect))
}
+1
View File
@@ -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;
+5 -1
View File
@@ -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" {
+60
View File
@@ -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
+122
View File
@@ -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<String> {
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"]);
}
+29 -29
View File
@@ -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"