diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9afb1f35..92af4f15 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (215) +## In this repository (217) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -12,6 +12,8 @@ Every asset PrintCraft includes, bundles or uses to build its published material | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index f87e63af..058fba17 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -52,6 +52,28 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "70ef15bcaa5721820aef032ad1e65370fb2667fd2e2d63eb24fbf3c2f57346cc" +[[asset]] +path = "assets/icons/columns-3.svg" +title = "Lucide icon \"columns-3\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "54f7a2f2c3ffe9c0f13189e85571192651aaafe1eea0726de97701f67921aafb" + +[[asset]] +path = "assets/icons/rows-3.svg" +title = "Lucide icon \"rows-3\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "401a4ceb06a88210638e3264e05f2a5281acad42df5c96363160db75f7fd105a" + [[asset]] path = "assets/icons/app-window.svg" title = "Lucide icon \"app-window\"" diff --git a/assets/icons/columns-3.svg b/assets/icons/columns-3.svg new file mode 100644 index 00000000..fad45b61 --- /dev/null +++ b/assets/icons/columns-3.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/rows-3.svg b/assets/icons/rows-3.svg new file mode 100644 index 00000000..48276e11 --- /dev/null +++ b/assets/icons/rows-3.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 92d7261a..0c8e5e77 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -41,6 +41,52 @@ fn format_arg(v: &Value) -> Result { }) } +/// `{"border": "#FF0000" | "none", "fill": …, "width": 1-3, "style": "solid"|…, "text_color": …, +/// "font": "helvetica"|"times"|"courier"}`, changing only what is given. +fn look_arg(v: &Value, mut l: printcraft_engine::FieldLook) -> Result { + use printcraft_engine::{BorderStyle, FieldFont}; + let o = v.as_object().ok_or_else(|| bad("appearance must be an object"))?; + let colour = |k: &str| -> Result>> { + match o.get(k) { + None => Ok(None), + Some(Value::String(s)) if s == "none" => Ok(Some(None)), + Some(Value::String(s)) => Ok(Some(Some(crate::comments::parse_color(s)?))), + Some(_) => Err(bad(format!("{k} must be a colour or \"none\""))), + } + }; + if let Some(c) = colour("border")? { + l.border = c; + } + if let Some(c) = colour("fill")? { + l.fill = c; + } + if let Some(c) = colour("text_color")? { + l.text = c.unwrap_or([0.0; 3]); + } + if let Some(w) = o.get("width").and_then(Value::as_f64) { + l.width = w.clamp(0.0, 12.0); + } + if let Some(s) = o.get("style").and_then(Value::as_str) { + l.style = match s { + "solid" => BorderStyle::Solid, + "dashed" => BorderStyle::Dashed, + "beveled" => BorderStyle::Beveled, + "inset" => BorderStyle::Inset, + "underline" => BorderStyle::Underline, + x => return Err(bad(format!("unknown style {x:?}"))), + }; + } + if let Some(f) = o.get("font").and_then(Value::as_str) { + l.font = match f { + "helvetica" => FieldFont::Helvetica, + "times" => FieldFont::Times, + "courier" => FieldFont::Courier, + x => return Err(bad(format!("unknown font {x:?} (helvetica, times, courier)"))), + }; + } + Ok(l) +} + /// `{"min": 0, "max": 100}` or `"none"`. fn validate_arg(v: &Value) -> Result { if v.as_str() == Some("none") { @@ -294,6 +340,10 @@ impl Automation { }, options: a.get("options").map(|_| a.strs("options")).transpose()?.map(|v| v.into_iter().map(str::to_owned).collect()), font_size: a.opt_num("font_size")?, + look: match a.get("appearance") { + None => None, + Some(v) => Some(look_arg(v, self.doc(a)?.field_look(&name).ok_or_else(|| failed("this field has no widget"))?)?), + }, format: a.get("format").map(format_arg).transpose()?, validate: a.get("validate").map(validate_arg).transpose()?, calculate: a.get("calculate").map(calculate_arg).transpose()?, @@ -307,6 +357,29 @@ impl Automation { Ok(out) } + pub(crate) fn form_tab_order(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let order = match a.str("order")? { + "row" | "rows" => printcraft_engine::TabOrder::Row, + "column" | "columns" => printcraft_engine::TabOrder::Column, + "structure" => printcraft_engine::TabOrder::Structure, + "annotations" | "unspecified" => printcraft_engine::TabOrder::Annotations, + o => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))), + }; + let mut out = self.apply(a, Edit::SetTabOrder { pages, order })?; + let doc = self.doc(a)?; + let mut seq: Vec<(usize, &str)> = + doc.form.iter().flat_map(|f| f.widgets.iter().filter(|w| w.page.is_some()).map(move |w| (w.tab, f.name.as_str()))).collect(); + seq.sort(); + seq.dedup_by(|x, y| x.1 == y.1); + out["tab_order"] = json!(seq.iter().map(|x| x.1).collect::>()); + Ok(out) + } + pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result { let name = a.str("field")?.to_owned(); if !self.doc(a)?.form.iter().any(|f| f.name == name) { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 4712569c..c38ec673 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -259,6 +259,7 @@ impl Automation { "form_add_field" => self.form_add_field(&a)?, "form_set_props" => self.form_set_props(&a)?, "form_delete_field" => self.form_delete_field(&a)?, + "form_tab_order" => self.form_tab_order(&a)?, "redact_mark" => self.redact_mark(&a)?, "redact_apply" => self.redact_apply(&a)?, "redact_clear" => self.redact_clear(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 8d8b9d9e..b2b1efc9 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -251,12 +251,15 @@ pub fn tools() -> Vec { "options": { "type": "array", "items": { "type": "string" } }, "font_size": { "type": "number", "minimum": 0 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." }, + "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." }, "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." }, "calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." }, }), &["doc", "field"], )), + t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Returns the resulting order of fields. Undoable.") + .with(schema(json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)") }), &["doc", "order"])), t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 043e1ede..80abdefa 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -745,3 +745,29 @@ fn form_formats_and_calculations_through_tools() { Err(ToolError::InvalidArgs(_)) )); } + +#[test] +fn tab_order_and_field_appearance_through_tools() { + let dir = workdir("taborder"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // Added out of reading order: B (top right), A (top left), C (below). + for (name, x, y) in [("B", 110, 20), ("A", 10, 20), ("C", 10, 60)] { + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [x, y, x + 80, y + 20], "name": name })); + } + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "row" })); + assert_eq!(r["tab_order"], json!(["A", "B", "C"])); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column" })); + assert_eq!(r["tab_order"], json!(["A", "C", "B"])); + ok( + &mut a, + "form_set_props", + json!({ "doc": doc, "field": "A", "appearance": { "border": "red", "fill": "none", "style": "underline", "font": "courier" } }), + ); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "A": "typed" } })); + assert!(page_text(&mut a, doc)[0].contains("typed")); + assert!(matches!( + a.call("form_set_props", &json!({ "doc": doc, "field": "A", "appearance": { "style": "wavy" } })), + Err(ToolError::InvalidArgs(_)) + )); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 90b748c5..14ad7eb2 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -320,6 +320,8 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Field list", "list", "form.fields", Ready), item("Clear form", "eraser", "form.clear", Ready), item("Field properties", "settings-2", "form.field.properties", Ready), + item("Tab order by rows", "rows-3", "form.tab_order.row", Ready), + item("Tab order by columns", "columns-3", "form.tab_order.column", Ready), item("Flatten form fields", "layers", "form.flatten", Ready), ], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 04bf2912..86743f70 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -178,6 +178,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"), c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"), c("form.prepare", "Prepare a form", None, None, Modification, "text-cursor-input"), + c("form.tab_order.row", "Tab order: by rows", None, None, HasFields, "rows-3"), + c("form.tab_order.column", "Tab order: by columns", None, None, HasFields, "columns-3"), + c("form.tab_order.structure", "Tab order: by document structure", None, None, HasFields, "list"), c("edit.text", "Add text", None, None, Modification, "type"), c("edit.bates", "Add Bates numbering…", None, None, Modification, "hash"), c("edit.image", "Add image…", None, None, Modification, "image-plus"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5d9fbe5d..33acf8fe 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,7 +25,8 @@ pub use printcraft_edit::{ Added, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use printcraft_forms::{ - Field as FormField, FieldKind as FormFieldKind, FieldProps, FieldValue, NewField, Widget as FormWidget, af as form_scripts, flags as field_flags, + BorderStyle, Field as FormField, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, NewField, TabOrder, + Widget as FormWidget, af as form_scripts, flags as field_flags, }; /// Comment geometry helpers (text-box line breaking) for frontends. @@ -208,6 +209,13 @@ impl Document { self.editor.as_ref().and_then(|e| printcraft_annot::props(&e.cos, page, index)) } + /// A form field's Appearance-tab look (borders, colours, font). + pub fn field_look(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + Some(printcraft_forms::look(&e.cos, f)) + } + /// Remove Hidden Information: what each category would remove. pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { self.editor.as_ref().map(|e| printcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() @@ -539,6 +547,11 @@ pub enum Edit { DeleteField { name: String, }, + /// Page properties ▸ Tab order, for pages (0-based). + SetTabOrder { + pages: Vec, + order: TabOrder, + }, /// Add a header and footer (with `replace`, existing ones on those pages go first). AddHeaderFooter { pages: Vec, @@ -643,6 +656,7 @@ impl Edit { Edit::AddField { .. } => "Add field".into(), Edit::SetFieldProps { .. } => "Change field properties".into(), Edit::DeleteField { .. } => "Delete field".into(), + Edit::SetTabOrder { .. } => "Set tab order".into(), Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(), Edit::AddHeaderFooter { .. } => "Update header & footer".into(), Edit::AddWatermark { replace: false, .. } => "Add watermark".into(), @@ -770,6 +784,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::Sanitize | Edit::SetFieldProps { .. } | Edit::DeleteField { .. } + | Edit::SetTabOrder { .. } | Edit::Flatten { .. } => { if p.modify() { Ok(()) @@ -905,6 +920,7 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - printcraft_forms::set_props(doc, name, props)?; } Edit::DeleteField { name } => printcraft_forms::delete_field(doc, name)?, + Edit::SetTabOrder { pages, order } => printcraft_forms::set_tab_order(doc, pages, *order)?, Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; printcraft_edit::add_header_footer(doc, pages, settings, *replace, &printcraft_edit::Context { date })?; diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 67f5b52e..8544e4e2 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -981,3 +981,73 @@ mod tests { assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); } } + +// ── push buttons ──────────────────────────────────────────────────────────────────────────── + +/// What clicking a push button does (its mouse-up action), as far as PrintCraft can run it +/// without a JavaScript engine. +#[derive(Clone, Debug, PartialEq)] +pub enum ButtonAction { + /// Reset fields: the listed ones, or all but them (`exclude`), or all when empty. + Reset { + fields: Vec, + exclude: bool, + }, + /// A named action: Print, NextPage, PrevPage, FirstPage, LastPage, … + Named(String), + Uri(String), + /// Go to a page (0-based) in this document. + GoTo(usize), + /// `app.alert("…")`. + Alert(String), + /// Submit the form to a URL (not sent: PrintCraft never posts form data on its own). + Submit(String), + /// A script PrintCraft can't run yet. + Script(String), +} + +/// Recognise the common one-line button scripts. +pub fn button_script(js: &str) -> ButtonAction { + let t = js.trim(); + // Print takes an options object whose contents don't change what PrintCraft does. + if t.starts_with("this.print(") || t.starts_with("print(") || t.contains(";this.print(") || t.contains("; this.print(") { + return ButtonAction::Named("Print".into()); + } + if let Some(args) = call(t, "this.resetForm").or_else(|| call(t, "resetForm")) { + let fields = match args.first() { + Some(Arg::List(l)) => l.iter().filter_map(Arg::string).collect(), + Some(Arg::Str(s)) => vec![s.clone()], + _ => Vec::new(), + }; + return ButtonAction::Reset { fields, exclude: false }; + } + if let Some(m) = call(t, "app.alert").and_then(|args| args.first().and_then(Arg::string)) { + return ButtonAction::Alert(m); + } + if let Some(u) = call(t, "app.launchURL").and_then(|args| args.first().and_then(Arg::string)) { + return ButtonAction::Uri(u); + } + let compact: String = t.chars().filter(|c| !c.is_whitespace()).collect(); + match compact.trim_end_matches(';') { + "this.pageNum++" | "pageNum++" => ButtonAction::Named("NextPage".into()), + "this.pageNum--" | "pageNum--" => ButtonAction::Named("PrevPage".into()), + "this.pageNum=0" => ButtonAction::Named("FirstPage".into()), + _ => ButtonAction::Script(t.to_string()), + } +} + +#[cfg(test)] +mod button_tests { + use super::*; + + #[test] + fn common_button_scripts_are_recognised() { + assert_eq!(button_script("this.print({bUI: true});"), ButtonAction::Named("Print".into())); + assert_eq!(button_script("this.resetForm([\"a\", \"b\"]);"), ButtonAction::Reset { fields: vec!["a".into(), "b".into()], exclude: false }); + assert_eq!(button_script("this.resetForm();"), ButtonAction::Reset { fields: vec![], exclude: false }); + assert_eq!(button_script("app.alert('Thanks!');"), ButtonAction::Alert("Thanks!".into())); + assert_eq!(button_script("app.launchURL(\"https://example.org\", true);"), ButtonAction::Uri("https://example.org".into())); + assert_eq!(button_script("this.pageNum++;"), ButtonAction::Named("NextPage".into())); + assert!(matches!(button_script("var x = 1; doStuff(x);"), ButtonAction::Script(_))); + } +} diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index a8e59f26..201f5e61 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -113,7 +113,13 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { 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")); + let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default(); + if style == b"U" { + // Underline: only the bottom edge. + c.push_str(&format!("{}\n{} w\n0 {} m {} {} l S\n", stroke_op(&bc), n(bw), n(bw / 2.0), n(w), n(bw / 2.0))); + return (c, bw); + } + let dashed = style == b"D"; c.push_str(&format!( "{}\n{} w\n{}{} {} {} {} re S\n[] 0 d\n", stroke_op(&bc), @@ -124,6 +130,41 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { n(w - bw), n(h - bw) )); + if style == b"B" || style == b"I" { + // Beveled: a light top-left and a darker bottom-right inside the border; inset: + // grey top-left and light grey bottom-right. + let (tl, br) = if style == b"B" { ("1 g", "0.5 g") } else { ("0.5 g", "0.75 g") }; + let (x0, y0, x1, y1) = (bw, bw, w - bw, h - bw); + let k = bw; + c.push_str(&format!( + "{tl}\n{} {} m {} {} l {} {} l {} {} l {} {} l {} {} l f\n{br}\n{} {} m {} {} l {} {} l {} {} l {} {} l {} {} l f\n", + n(x0), + n(y0), + n(x0), + n(y1), + n(x1), + n(y1), + n(x1 - k), + n(y1 - k), + n(x0 + k), + n(y1 - k), + n(x0 + k), + n(y0 + k), + n(x1), + n(y1), + n(x1), + n(y0), + n(x0), + n(y0), + n(x0 + k), + n(y0 + k), + n(x1 - k), + n(y0 + k), + n(x1 - k), + n(y1 - k) + )); + return (c, 2.0 * bw); + } return (c, bw); } (c, 0.0) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index b5cd8b96..3b680a54 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -70,6 +70,8 @@ pub struct FieldProps { /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in /// user space. pub rect: Option<(usize, [f64; 4])>, + /// Appearance tab (border, fill, line, text colour, font). + pub look: Option, /// Format, Validate and Calculate tabs (written as Acrobat's AF scripts). pub format: Option, pub validate: Option, @@ -106,13 +108,21 @@ fn ensure_form(doc: &mut Document) -> Result { } let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - if !fonts.contains(b"Helv") { + // Acrobat's resource names for the standard fonts its Appearance tab offers. + let mut added = false; + for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { + if fonts.contains(name.as_bytes()) { + continue; + } 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"BaseFont".to_vec(), Object::name(base)); f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(b"Helv".to_vec(), Object::Dict(f)); + fonts.set(name.as_bytes().to_vec(), Object::Dict(f)); + added = true; + } + if added { dr.set(b"Font".to_vec(), Object::Dict(fonts)); af.set(b"DR".to_vec(), Object::Dict(dr)); } @@ -120,6 +130,128 @@ fn ensure_form(doc: &mut Document) -> Result { Ok(r) } +/// The Appearance tab: borders, colours and text. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub border: Option<[f64; 3]>, + pub fill: Option<[f64; 3]>, + /// Line thickness in points (Acrobat: thin 1, medium 2, thick 3). + pub width: f64, + pub style: BorderStyle, + pub text: [f64; 3], + pub font: FieldFont, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BorderStyle { + Solid, + Dashed, + Beveled, + Inset, + Underline, +} + +impl BorderStyle { + pub const ALL: [BorderStyle; 5] = [BorderStyle::Solid, BorderStyle::Dashed, BorderStyle::Beveled, BorderStyle::Inset, BorderStyle::Underline]; + + fn code(self) -> &'static str { + match self { + BorderStyle::Solid => "S", + BorderStyle::Dashed => "D", + BorderStyle::Beveled => "B", + BorderStyle::Inset => "I", + BorderStyle::Underline => "U", + } + } + + pub fn label(self) -> &'static str { + match self { + BorderStyle::Solid => "Solid", + BorderStyle::Dashed => "Dashed", + BorderStyle::Beveled => "Beveled", + BorderStyle::Inset => "Inset", + BorderStyle::Underline => "Underline", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldFont { + Helvetica, + Times, + Courier, +} + +impl FieldFont { + pub const ALL: [FieldFont; 3] = [FieldFont::Helvetica, FieldFont::Times, FieldFont::Courier]; + + fn resource(self) -> &'static str { + match self { + FieldFont::Helvetica => "Helv", + FieldFont::Times => "TiRo", + FieldFont::Courier => "Cour", + } + } + + pub fn label(self) -> &'static str { + match self { + FieldFont::Helvetica => "Helvetica", + FieldFont::Times => "Times Roman", + FieldFont::Courier => "Courier", + } + } +} + +fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { + let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); + match v.len() { + 1 => Some([v[0]; 3]), + 3 => Some([v[0], v[1], v[2]]), + 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), + _ => None, + } +} + +/// A field's current look (from its first widget and its default appearance). +pub fn look(doc: &Document, f: &Field) -> Look { + let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); + let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); + let bs = wd.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()).unwrap_or_default(); + let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_else(|| f.da.clone()); + let parsed = appearance::parse_da(&da); + let nums: Vec = parsed.color.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + let text = match nums.len() { + 1 => [nums[0]; 3], + 3 => [nums[0], nums[1], nums[2]], + 4 => [(1.0 - nums[0]) * (1.0 - nums[3]), (1.0 - nums[1]) * (1.0 - nums[3]), (1.0 - nums[2]) * (1.0 - nums[3])], + _ => [0.0; 3], + }; + Look { + border: rgb_of(doc, mk.get(b"BC")), + fill: rgb_of(doc, mk.get(b"BG")), + width: bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()).unwrap_or(1.0), + style: match bs.name(b"S") { + Some(b"D") => BorderStyle::Dashed, + Some(b"B") => BorderStyle::Beveled, + Some(b"I") => BorderStyle::Inset, + Some(b"U") => BorderStyle::Underline, + _ => BorderStyle::Solid, + }, + text, + font: match parsed.font.as_str() { + "TiRo" | "Times-Roman" => FieldFont::Times, + "Cour" | "Courier" => FieldFont::Courier, + _ => FieldFont::Helvetica, + }, + } +} + +/// A `/DA` string with the given font, size and colour. +fn da_string(font: FieldFont, size: f64, c: [f64; 3]) -> String { + let f = crate::appearance::fmt; + format!("/{} {} Tf {} {} {} rg", font.resource(), f(size.clamp(0.0, 100.0)), f(c[0]), f(c[1]), f(c[2])) +} + fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { @@ -293,7 +425,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel kids.push(Object::Ref(widget)); d.set(b"Kids".to_vec(), Object::Array(kids)); })?; - let w = Widget { obj: widget, page: Some(page), rect, on_state: Some(export.clone()), state: Some("Off".into()) }; + let w = Widget { obj: widget, page: Some(page), rect, on_state: Some(export.clone()), state: Some("Off".into()), tab: usize::MAX }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } @@ -423,6 +555,16 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result da_string(l.font, size, l.text), + None => da_string(current.font, size, current.text), + }; + if props.look.is_some() { + ensure_form(doc)?; + } let mut ff = f.flags; let mut set_flag = |flag: u32, on: Option| { if let Some(on) = on { @@ -461,10 +603,38 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result mk.set(b"BC".to_vec(), arr(c)), + None => { + mk.remove(b"BC"); + } + } + match l.fill { + Some(c) => mk.set(b"BG".to_vec(), arr(c)), + None => { + mk.remove(b"BG"); + } + } + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); + bs.set(b"S".to_vec(), Object::name(l.style.code())); + if l.style == BorderStyle::Dashed { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + })?; + } + } if let Some((wi, r)) = props.rect { let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; @@ -541,15 +711,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result, /// The current appearance state (`/AS`). pub state: Option, + /// Position in the document's tab order (pages in order; within a page by its `/Tabs`: + /// rows, columns, or annotation/structure order). `usize::MAX` when not on a page. + pub tab: usize, } /// A terminal form field. @@ -100,6 +103,8 @@ pub struct Field { pub widgets: Vec, /// Format, validate and calculate scripts (Acrobat's AF functions), see [`af`]. pub actions: af::Actions, + /// Push buttons: what a click does. + pub button: Option, } impl Field { @@ -147,6 +152,49 @@ fn text_of(o: &Object) -> Option { } } +/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). +fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { + let pick = |dict: &Dict| -> Option { + dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) + }; + let action = pick(d).or_else(|| widgets.iter().find_map(|w| doc.get(*w).as_dict().and_then(pick)))?; + let a = doc.resolve(&action); + let a = a.as_dict()?; + let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); + Some(match a.name(b"S")? { + b"ResetForm" => { + let fields = a + .get(b"Fields") + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|o| match o { + Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), + other => text_of(&doc.resolve(other)), + }) + .collect(); + let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; + af::ButtonAction::Reset { fields, exclude } + } + b"Named" => af::ButtonAction::Named(String::from_utf8_lossy(a.name(b"N")?).into_owned()), + b"URI" => af::ButtonAction::Uri(s(b"URI")?), + b"SubmitForm" => af::ButtonAction::Submit( + a.get(b"F") + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_dict().and_then(|fd| fd.get(b"F").and_then(|x| text_of(&doc.resolve(x)))).or_else(|| text_of(&f))) + .unwrap_or_default(), + ), + b"GoTo" => { + let dest = doc.resolve(a.get(b"D")?); + let target = dest.as_array()?.first()?.as_ref()?; + af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) + } + b"JavaScript" => af::button_script(&script(doc, &action)?), + _ => return None, + }) +} + /// The JavaScript of an action dictionary (`/JS` string or stream). fn script(doc: &Document, action: &Object) -> Option { let a = doc.resolve(action); @@ -268,11 +316,14 @@ struct Inherited { pub fn fields(doc: &Document) -> Vec { let Some(af) = acroform(doc) else { return Vec::new() }; let mut page_of = std::collections::HashMap::new(); - for (i, p) in page_refs(doc).iter().enumerate() { + let mut annot_index = std::collections::HashMap::new(); + let pages = page_refs(doc); + for (i, p) in pages.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() { + for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { if let Some(r) = e.as_ref() { page_of.entry(r).or_insert(i); + annot_index.entry(r).or_insert(k); } } } @@ -289,9 +340,113 @@ pub fn fields(doc: &Document) -> Vec { walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); } } + rank_tabs(doc, &pages, &annot_index, &mut out); out } +/// A page's tab order (`/Tabs`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TabOrder { + /// Rows, top to bottom, left to right within a row (`/R`). + Row, + /// Columns, left to right, top to bottom within a column (`/C`). + Column, + /// The document structure (`/S`; annotation order when the file has none). + Structure, + /// Unspecified: annotation order. + Annotations, +} + +impl TabOrder { + fn of(name: Option<&[u8]>) -> TabOrder { + match name { + Some(b"R") => TabOrder::Row, + Some(b"C") => TabOrder::Column, + Some(b"S") => TabOrder::Structure, + _ => TabOrder::Annotations, + } + } +} + +/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). +type TabItem = (usize, usize, usize, [f64; 4], usize); + +/// Every widget's place in the tab order. +fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::collections::HashMap, out: &mut [Field]) { + let mut items: Vec = Vec::new(); + for (fi, f) in out.iter().enumerate() { + for (wi, w) in f.widgets.iter().enumerate() { + if let Some(p) = w.page { + items.push((p, fi, wi, w.rect, annot_index.get(&w.obj).copied().unwrap_or(usize::MAX))); + } + } + } + let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); + for (pi, p) in pages.iter().enumerate() { + let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); + if on.is_empty() { + continue; + } + let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs"))); + match tabs { + TabOrder::Row | TabOrder::Column => { + let row = tabs == TabOrder::Row; + // Primary axis: rows by top edge (down the page), columns by left edge. + on.sort_by(|a, b| if row { b.3[3].total_cmp(&a.3[3]) } else { a.3[0].total_cmp(&b.3[0]) }); + let mut groups: Vec> = Vec::new(); + for it in on { + let fits = groups.last().is_some_and(|g| { + let first = g[0]; + if row { + (first.3[3] - it.3[3]).abs() < ((first.3[3] - first.3[1]) / 2.0).max(2.0) + } else { + (it.3[0] - first.3[0]).abs() < ((first.3[2] - first.3[0]) / 2.0).max(2.0) + } + }); + if fits { + groups.last_mut().expect("checked").push(it); + } else { + groups.push(vec![it]); + } + } + for mut g in groups { + g.sort_by(|a, b| if row { a.3[0].total_cmp(&b.3[0]) } else { b.3[3].total_cmp(&a.3[3]) }); + ordered.extend(g.iter().map(|i| (i.1, i.2))); + } + } + TabOrder::Structure | TabOrder::Annotations => { + on.sort_by_key(|i| i.4); + ordered.extend(on.iter().map(|i| (i.1, i.2))); + } + } + } + for f in out.iter_mut() { + for w in &mut f.widgets { + w.tab = usize::MAX; + } + } + for (rank, (fi, wi)) in ordered.into_iter().enumerate() { + out[fi].widgets[wi].tab = rank; + } +} + +/// Set the tab order of pages (0-based). +pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { + let refs = page_refs(doc); + for &p in pages { + let r = *refs.get(p).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", p + 1)))?; + doc.update_dict(r, |d| match order { + TabOrder::Row => d.set(b"Tabs".to_vec(), Object::name("R")), + TabOrder::Column => d.set(b"Tabs".to_vec(), Object::name("C")), + TabOrder::Structure => d.set(b"Tabs".to_vec(), Object::name("S")), + TabOrder::Annotations => { + d.remove(b"Tabs"); + } + })?; + } + Ok(()) +} + fn walk( doc: &Document, r: ObjRef, @@ -342,6 +497,8 @@ fn walk( } let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; let actions = actions_of(doc, d, &widget_refs); + let button = + (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten(); let ff = inh.ff.unwrap_or(0); let kind = match inh.ft.as_deref() { Some(b"Tx") => FieldKind::Text, @@ -371,6 +528,7 @@ fn walk( 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()), + tab: usize::MAX, }) }) .collect(); @@ -399,6 +557,7 @@ fn walk( tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), widgets, actions, + button, }); } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 21cc4947..7eed9279 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -420,3 +420,57 @@ fn formats_validation_and_calculations_run_like_acrobat() { set_props(&mut doc, "Price", &FieldProps { format: Some(Format::None), ..FieldProps::default() }).unwrap(); assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None); } + +#[test] +fn appearance_properties_restyle_every_widget() { + let mut doc = one_page(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let f = field(&fields(&doc), "name").clone(); + let before = look(&doc, &f); + assert_eq!((before.font, before.style, before.text), (FieldFont::Helvetica, BorderStyle::Solid, [0.0; 3])); + let new = Look { + border: Some([1.0, 0.0, 0.0]), + fill: Some([1.0, 1.0, 0.8]), + width: 2.0, + style: BorderStyle::Dashed, + text: [0.0, 0.0, 1.0], + font: FieldFont::Times, + }; + set_props(&mut doc, "name", &FieldProps { look: Some(new), font_size: Some(14.0), ..FieldProps::default() }).unwrap(); + let doc = reopen(&doc); + let f = field(&fields(&doc), "name").clone(); + assert_eq!(look(&doc, &f), new); + let a = ap(&doc, &f.widgets[0]); + assert!(a.contains("/TiRo 14 Tf") && a.contains("0 0 1 rg") && a.contains("[3] 0 d") && a.contains("(Ada) Tj"), "{a}"); + let mut doc = doc; + set_props(&mut doc, "name", &FieldProps { look: Some(Look { style: BorderStyle::Underline, fill: None, ..new }), ..FieldProps::default() }) + .unwrap(); + let a = ap(&doc, &field(&fields(&doc), "name").widgets[0]); + assert!(!a.contains(" re S") && a.contains(" l S"), "underline only: {a}"); +} + +#[test] +fn tab_order_follows_the_page_setting() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + // Added in a scrambled order: annotation order is C, A, D, B. + // Layout: A (50,700) B (300,700) + // C (50,600) D (300,600) + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let order = |doc: &Document| -> String { + let mut all: Vec<(usize, String)> = fields(doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + all.into_iter().map(|x| x.1).collect() + }; + assert_eq!(order(&doc), "CADB", "unspecified: annotation order"); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "ABCD"); + set_tab_order(&mut doc, &[0], TabOrder::Column).unwrap(); + assert_eq!(order(&doc), "ACBD"); + let doc = reopen(&doc); + assert_eq!(order(&doc), "ACBD", "saved as /Tabs"); + assert!(set_tab_order(&mut doc.clone(), &[3], TabOrder::Row).is_err()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1b0d890b..a695b849 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1276,9 +1276,47 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(n) = form_notice { app.notify(n); } + if let Some((name, action)) = app.views[index].forms.button.take() { + run_button(app, index, ui.ctx(), &name, action); + } quick_bar(app, avail, ui); } +/// Run a push button's action (the ones that need no JavaScript engine). +fn run_button(app: &mut PrintCraftApp, index: usize, ctx: &egui::Context, name: &str, action: printcraft_engine::form_scripts::ButtonAction) { + use printcraft_engine::form_scripts::ButtonAction as B; + let pages = app.session.get(app.views[index].id).map_or(0, |d| d.info.pages.len()); + let current = app.views[index].current; + match action { + B::Reset { fields, exclude } => { + let all: Vec = app.session.get(app.views[index].id).map(|d| d.form.iter().map(|f| f.name.clone()).collect()).unwrap_or_default(); + let listed = |n: &String| fields.iter().any(|f| n == f || n.starts_with(&format!("{f}."))); + let names = match (fields.is_empty(), exclude) { + (true, _) => None, + (false, false) => Some(all.iter().filter(|n| listed(n)).cloned().collect()), + (false, true) => Some(all.iter().filter(|n| !listed(n)).cloned().collect()), + }; + app.views[index].forms.focus = None; + app.views[index].pending_edit = Some(printcraft_engine::Edit::ResetForm { names }); + } + B::Named(n) => match n.as_str() { + "Print" => app.open_print(), + "NextPage" => app.views[index].go_to_page((current + 1).min(pages.saturating_sub(1))), + "PrevPage" => app.views[index].go_to_page(current.saturating_sub(1)), + "FirstPage" => app.views[index].go_to_page(0), + "LastPage" => app.views[index].go_to_page(pages.saturating_sub(1)), + other => app.notify(format!("{name}: the {other} action isn't supported yet")), + }, + B::Uri(u) => ctx.open_url(egui::OpenUrl::new_tab(u)), + B::GoTo(p) => app.views[index].go_to_page(p.min(pages.saturating_sub(1))), + B::Alert(m) => app.notify(m), + B::Submit(url) => { + app.notify(format!("{name} submits the form to {url}; PrintCraft doesn't send form data. Save the document to keep your entries.")) + } + B::Script(_) => app.notify(format!("{name} runs a script that needs the JavaScript engine (M6)")), + } +} + /// Acrobat-style floating find bar at the top-right of the document area. fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: &Tokens) { let Some(find) = view.find.as_mut() else { return }; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 75c71f07..f241a6d9 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -266,6 +266,15 @@ impl PrintCraftApp { "redact.clear" => { self.apply_edit(Edit::ClearRedactions); } + "form.tab_order.row" | "form.tab_order.column" | "form.tab_order.structure" => { + let order = match id { + "form.tab_order.row" => printcraft_engine::TabOrder::Row, + "form.tab_order.column" => printcraft_engine::TabOrder::Column, + _ => printcraft_engine::TabOrder::Structure, + }; + let n = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.info.pages.len()); + self.apply_edit(Edit::SetTabOrder { pages: (0..n).collect(), order }); + } "form.prepare" => { self.left = crate::LeftPanel::Tool("form"); self.left_open = true; diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8820425b..e8d16228 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -33,6 +33,8 @@ pub struct FormView { pub focus: Option, /// A message for the app to show (e.g. "buttons run JavaScript"). pub notice: Option, + /// A push button was clicked: (its field name, what it does). + pub button: Option<(String, printcraft_engine::form_scripts::ButtonAction)>, } fn widget_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { @@ -87,7 +89,10 @@ pub(crate) fn page_input( } 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::PushButton => match &f.button { + Some(a) => view.forms.button = Some((f.name.clone(), a.clone())), + None => view.forms.notice = Some(format!("{} has no action", f.name)), + }, FormFieldKind::Signature => view.forms.notice = Some("Signing arrives with digital signatures (M9)".into()), FormFieldKind::CheckBox => { view.forms.focus = None; @@ -258,20 +263,21 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f 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 + // The next widget that takes typing or a choice, in the document's tab order (each + // page's /Tabs: rows, columns or structure), wrapping. + let mut order: Vec<(usize, &FormField, usize)> = 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()) - }) + .filter(|x| fillable(x) && matches!(x.kind, FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List)) + .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some()).map(move |(wi, w)| (w.tab, x, wi))) .collect(); - if let Some(pos) = order.iter().position(|(_, x)| x.name == f.name) { + order.sort_by_key(|o| o.0); + if let Some(pos) = order + .iter() + .position(|(_, x, wi)| x.name == f.name && *wi == focus.widget) + .or_else(|| 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); + let (_, target, wi) = order[k]; 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() diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 23e0d3f4..88f96c1c 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -42,6 +42,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("cloud-upload", include_bytes!("../../../assets/icons/cloud-upload.svg")), ("code-xml", include_bytes!("../../../assets/icons/code-xml.svg")), ("columns-2", include_bytes!("../../../assets/icons/columns-2.svg")), + ("columns-3", include_bytes!("../../../assets/icons/columns-3.svg")), ("compass", include_bytes!("../../../assets/icons/compass.svg")), ("contrast", include_bytes!("../../../assets/icons/contrast.svg")), ("copy", include_bytes!("../../../assets/icons/copy.svg")), @@ -133,6 +134,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("reply", include_bytes!("../../../assets/icons/reply.svg")), ("rotate-ccw", include_bytes!("../../../assets/icons/rotate-ccw.svg")), ("rotate-cw", include_bytes!("../../../assets/icons/rotate-cw.svg")), + ("rows-3", include_bytes!("../../../assets/icons/rows-3.svg")), ("ruler", include_bytes!("../../../assets/icons/ruler.svg")), ("save", include_bytes!("../../../assets/icons/save.svg")), ("scan", include_bytes!("../../../assets/icons/scan.svg")), diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 8639a97a..f5c8fa11 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -7,7 +7,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; use printcraft_engine::form_scripts::{CalcOp, Calculate, DATE_PRESETS, Format, TIME_PRESETS, Validate, format_value}; -use printcraft_engine::{Edit, FieldProps, FormField, FormFieldKind, NewField}; +use printcraft_engine::{BorderStyle, Edit, FieldFont, FieldLook, FieldProps, FormField, FormFieldKind, NewField}; use printcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; @@ -399,6 +399,10 @@ impl crate::PrintCraftApp { let Some((_, id)) = self.active_ids() else { return }; let Some(f) = self.session.get(id).and_then(|d| d.form.iter().find(|f| f.name == name).cloned()) else { return }; let mut d = FieldDraft::new(&f, widget); + d.look = self.session.get(id).and_then(|doc| doc.field_look(name)); + if let Some(o) = d.original.as_mut() { + o.look = d.look; + } let others: Vec = self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default(); d.others = others.clone(); @@ -441,6 +445,7 @@ pub struct FieldDraft { pub font_size: f64, /// Left, bottom, width, height in points. pub position: [f64; 4], + pub look: Option, pub format: Format, pub validate: Validate, pub calculate: Calculate, @@ -473,6 +478,7 @@ impl FieldDraft { new_option: String::new(), font_size: da_size(&f.da), position: [r[0], r[1], r[2] - r[0], r[3] - r[1]], + look: None, format: f.actions.format.clone(), validate: f.actions.validate.clone(), calculate: f.actions.calculate.clone(), @@ -523,6 +529,7 @@ impl FieldDraft { let [x, y, w, h] = self.position; (self.widget, [x, y, x + w.max(4.0), y + h.max(4.0)]) }), + look: (self.look != o.look).then_some(self.look).flatten(), format: (self.format != o.format).then(|| self.format.clone()), validate: (self.validate != o.validate).then(|| self.validate.clone()), calculate: (self.calculate != o.calculate).then(|| self.calculate.clone()), @@ -574,10 +581,62 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.add_enabled(!auto, egui::DragValue::new(&mut d.font_size).range(2.0..=100.0).speed(0.25).suffix(" pt")); }); ui.end_row(); - ui.label("Font:"); - ui.label("Helvetica"); - ui.end_row(); + if let Some(l) = d.look.as_mut() { + ui.label("Font:"); + egui::ComboBox::from_id_salt("field-font").selected_text(l.font.label()).show_ui(ui, |ui| { + for f in FieldFont::ALL { + ui.selectable_value(&mut l.font, f, f.label()); + } + }); + ui.end_row(); + ui.label("Text Color:"); + if let Some(c) = crate::comments::swatch_grid(ui, Some(l.text)) { + l.text = c; + } + ui.end_row(); + } }); + if let Some(l) = d.look.as_mut() { + widgets::section_title(ui, "Borders and Colors"); + egui::Grid::new("field-borders").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for (label, slot) in [("Border Color:", &mut l.border), ("Fill Color:", &mut l.fill)] { + ui.label(label); + ui.horizontal(|ui| { + let mut none = slot.is_none(); + if ui.checkbox(&mut none, "No color").changed() { + *slot = if none { None } else { Some([0.0; 3]) }; + } + if let Some(c) = crate::comments::swatch_grid(ui, *slot) { + *slot = Some(c); + } + }); + ui.end_row(); + } + ui.label("Line Thickness:"); + let thick = |w: f64| { + if w <= 1.0 { + "Thin" + } else if w <= 2.0 { + "Medium" + } else { + "Thick" + } + }; + egui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { + for (w, label) in [(1.0, "Thin"), (2.0, "Medium"), (3.0, "Thick")] { + ui.selectable_value(&mut l.width, w, label); + } + }); + ui.end_row(); + ui.label("Line Style:"); + egui::ComboBox::from_id_salt("field-style").selected_text(l.style.label()).show_ui(ui, |ui| { + for s in BorderStyle::ALL { + ui.selectable_value(&mut l.style, s, s.label()); + } + }); + ui.end_row(); + }); + } } FieldTab::Position => { egui::Grid::new("field-position").num_columns(4).spacing([12.0, 10.0]).show(ui, |ui| { diff --git a/crates/ui-egui/tests/buttons.rs b/crates/ui-egui/tests/buttons.rs new file mode 100644 index 00000000..a2df9a06 --- /dev/null +++ b/crates/ui-egui/tests/buttons.rs @@ -0,0 +1,71 @@ +//! Push buttons run their actions (Reset form, Named, …) without a JavaScript engine. + +use egui_kittest::Harness; +use printcraft_ui_egui::{Dialog, PrintCraftApp}; + +/// Two pages; page 1 has a text field "name" (filled), a Reset button, a Next page button and a +/// Print button (a JavaScript one-liner). +fn fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) >> >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [20 340 280 360] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (reset) /Rect [20 300 100 320] /P 3 0 R /A << /S /ResetForm >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (next) /Rect [120 300 200 320] /P 3 0 R /A << /S /Named /N /NextPage >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (print) /Rect [220 300 280 320] /P 3 0 R /AA << /U << /S /JavaScript /JS (this.print\\({bUI: true}\\);) >> >> >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn click_field(h: &mut Harness<'static, PrintCraftApp>, name: &str) { + 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, 0).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 buttons_reset_navigate_and_print() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("buttons.pdf", None, fixture()).unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(6); + click_field(&mut h, "reset"); + { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert!(doc.form.iter().find(|f| f.name == "name").unwrap().value.is_empty(), "Reset form cleared the field"); + } + click_field(&mut h, "print"); + assert_eq!(h.state().dialog, Some(Dialog::Print), "this.print() opens the Print dialog"); + h.state_mut().dialog = None; + h.run_steps(2); + click_field(&mut h, "next"); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1, "the NextPage action"); +} diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index aee7a818..87761c09 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -144,3 +144,28 @@ fn format_validate_and_calculate_tabs() { assert_eq!(f.actions.validate, Validate::Range { min: Some(0.0), max: None }); assert_eq!(f.actions.calculate, Calculate::Notation("name".into())); } + +#[test] +fn appearance_tab_restyles_the_field() { + use printcraft_engine::{BorderStyle, FieldFont}; + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.state_mut().open_field_props("city", 0); + h.run_steps(2); + h.get_by_label("Appearance").click(); + h.run_steps(2); + h.get_by_label("Line Style:"); + { + let d = h.state_mut().field_props.as_mut().unwrap(); + let l = d.look.as_mut().expect("a look"); + l.style = BorderStyle::Dashed; + l.font = FieldFont::Courier; + l.fill = Some([1.0, 1.0, 0.8]); + } + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let l = s.session.get(s.views[0].id).unwrap().field_look("city").unwrap(); + assert_eq!((l.style, l.font, l.fill), (BorderStyle::Dashed, FieldFont::Courier, Some([1.0, 1.0, 0.8]))); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7b9ef37a..cdd02272 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4335,11 +4335,11 @@ title = "Push buttons run their actions" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] +status = "partial" +commands = ["form.fields"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/buttons.rs::buttons_reset_navigate_and_print", "crates/forms/src/af.rs::common_button_scripts_are_recognised"] +notes = "Reset form, Named (print, page navigation), URI, Go to, app.alert, this.print/resetForm/pageNum scripts; submit explained, not sent; other scripts need the JS engine" [[feature]] id = "form.field-navigation" @@ -4767,11 +4767,11 @@ title = "Field properties: appearance (border, fill, font)" area = "F" tier = "P0" milestone = "M6" -status = "partial" +status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete"] -notes = "font size (incl. Auto); border/fill colours and fonts pending" +evidence = ["crates/forms/src/tests.rs::appearance_properties_restyle_every_widget", "crates/ui-egui/tests/prepare.rs::appearance_tab_restyles_the_field", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools"] +notes = "border/fill colour, thin/medium/thick, solid/dashed/beveled/inset/underline, text colour, Helvetica/Times/Courier, font size" [[feature]] id = "form.props-position" @@ -4923,11 +4923,11 @@ title = "Tab order (row, column, structure, manual)" area = "F" tier = "P0" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["form.tab_order.row", "form.tab_order.column", "form.tab_order.structure"] +tools = ["form_tab_order"] +evidence = ["crates/forms/src/tests.rs::tab_order_follows_the_page_setting", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools"] +notes = "rows, columns, structure/annotation order (page /Tabs), honoured by Tab; manual reordering pending" [[feature]] id = "form.show-tab-numbers"