M6.6/M6.8: Field Properties Appearance tab (border/fill colour, thickness, solid/dashed/beveled/inset/underline, text colour, Helvetica/Times/Courier); tab order by rows/columns/structure (page /Tabs) honoured by Tab; push buttons run Reset form, Named (print, page navigation), URI, Go to and common one-line scripts (this.print, this.resetForm, app.alert, pageNum) without a JS engine; form_tab_order tool and appearance in form_set_props (67 tools)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
24f5768be4
commit
92d37ca696
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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\""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-columns-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
<path d="M15 3v18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
|
|
@ -0,0 +1,17 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-rows-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M21 9H3" />
|
||||
<path d="M21 15H3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
|
|
@ -41,6 +41,52 @@ fn format_arg(v: &Value) -> Result<Format> {
|
|||
})
|
||||
}
|
||||
|
||||
/// `{"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<printcraft_engine::FieldLook> {
|
||||
use printcraft_engine::{BorderStyle, FieldFont};
|
||||
let o = v.as_object().ok_or_else(|| bad("appearance must be an object"))?;
|
||||
let colour = |k: &str| -> Result<Option<Option<[f64; 3]>>> {
|
||||
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<Validate> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result<Value> {
|
||||
let name = a.str("field")?.to_owned();
|
||||
if !self.doc(a)?.form.iter().any(|f| f.name == name) {
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -251,12 +251,15 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
"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"])),
|
||||
|
|
|
|||
|
|
@ -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(_))
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<printcraft_forms::Look> {
|
||||
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<usize>,
|
||||
order: TabOrder,
|
||||
},
|
||||
/// Add a header and footer (with `replace`, existing ones on those pages go first).
|
||||
AddHeaderFooter {
|
||||
pages: Vec<usize>,
|
||||
|
|
@ -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 })?;
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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(_)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Look>,
|
||||
/// Format, Validate and Calculate tabs (written as Acrobat's AF scripts).
|
||||
pub format: Option<crate::af::Format>,
|
||||
pub validate: Option<crate::af::Validate>,
|
||||
|
|
@ -106,13 +108,21 @@ fn ensure_form(doc: &mut Document) -> Result<ObjRef, FormError> {
|
|||
}
|
||||
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<ObjRef, FormError> {
|
|||
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<f64> = 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<f64> = 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<S
|
|||
new_name = format!("{prefix}{n}");
|
||||
doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?;
|
||||
}
|
||||
// The new default appearance when the size or the look changes.
|
||||
let current = look(doc, &f);
|
||||
let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size);
|
||||
let new_da = match &props.look {
|
||||
Some(l) => 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<bool>| {
|
||||
if let Some(on) = on {
|
||||
|
|
@ -461,10 +603,38 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
|||
if let Some(opts) = &props.options {
|
||||
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
|
||||
}
|
||||
if let Some(size) = props.font_size {
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("/Helv {} Tf 0 g", crate::appearance::fmt(size.clamp(0.0, 100.0))).into_bytes()));
|
||||
if props.font_size.is_some() || props.look.is_some() {
|
||||
d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes()));
|
||||
}
|
||||
})?;
|
||||
if let Some(l) = &props.look {
|
||||
let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect());
|
||||
for w in &f.widgets {
|
||||
doc.update_dict(w.obj, |d| {
|
||||
let mut mk = d.get(b"MK").and_then(|m| m.as_dict().cloned()).unwrap_or_default();
|
||||
match l.border {
|
||||
Some(c) => 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<S
|
|||
})?;
|
||||
}
|
||||
// Widgets may carry their own /DA; keep them in step with the field.
|
||||
if let Some(size) = props.font_size {
|
||||
if props.font_size.is_some() || props.look.is_some() {
|
||||
for w in &f.widgets {
|
||||
if w.obj != f.obj {
|
||||
doc.update_dict(w.obj, |d| {
|
||||
if d.contains(b"DA") {
|
||||
d.set(
|
||||
b"DA".to_vec(),
|
||||
PdfString::literal(format!("/Helv {} Tf 0 g", crate::appearance::fmt(size.clamp(0.0, 100.0))).into_bytes()),
|
||||
);
|
||||
d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes()));
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
|
|||
pub mod af;
|
||||
pub mod appearance;
|
||||
mod author;
|
||||
pub use author::{FieldProps, NewField, add_field, delete_field, redraw_field, set_props};
|
||||
pub use author::{BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, look, redraw_field, set_props};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -75,6 +75,9 @@ pub struct Widget {
|
|||
pub on_state: Option<String>,
|
||||
/// The current appearance state (`/AS`).
|
||||
pub state: Option<String>,
|
||||
/// 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<Widget>,
|
||||
/// Format, validate and calculate scripts (Acrobat's AF functions), see [`af`].
|
||||
pub actions: af::Actions,
|
||||
/// Push buttons: what a click does.
|
||||
pub button: Option<af::ButtonAction>,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
|
|
@ -147,6 +152,49 @@ fn text_of(o: &Object) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<af::ButtonAction> {
|
||||
let pick = |dict: &Dict| -> Option<Object> {
|
||||
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<String> {
|
||||
let a = doc.resolve(action);
|
||||
|
|
@ -268,11 +316,14 @@ struct Inherited {
|
|||
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() {
|
||||
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<Field> {
|
|||
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<ObjRef, usize>, out: &mut [Field]) {
|
||||
let mut items: Vec<TabItem> = 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<&TabItem>> = 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<ObjRef> = 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = 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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ pub struct FormView {
|
|||
pub focus: Option<Focus>,
|
||||
/// A message for the app to show (e.g. "buttons run JavaScript").
|
||||
pub notice: Option<String>,
|
||||
/// 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()
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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<String> =
|
||||
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<FieldLook>,
|
||||
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| {
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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");
|
||||
}
|
||||
|
|
@ -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])));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue