mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
ff641c669e
commit
67ff9303c8
37 files changed
+2384
-146
No files matched your search
+2
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
printcraft-cos = { workspace = true }
|
||||
printcraft-fonts = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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"])),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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: &[
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
|
||||
@@ -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()))]);
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user