From eec8bd3d9ef4612dc2aabc82d7c44a0a89ce42b7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:17:25 -0400 Subject: [PATCH] =?UTF-8?q?I:=20buttons=20=E2=80=94=20page=20and=20URL=20a?= =?UTF-8?q?ctions=20as=20PDF=20links=20(+=20Buttons=20and=20Forms=20panel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 16 +++++ crates/engine/src/cmd/buttons.rs | 89 ++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/pdf/src/links.rs | 41 +++++++++++ crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/interactive.rs | 69 ++++++++++++++++++ docs/parity.md | 6 +- 9 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/buttons.rs diff --git a/ROADMAP.md b/ROADMAP.md index f98051d..78cc939 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -85,6 +85,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; dragging on the canvas isn't there yet). - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. +- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields need AcroForm support the PDF writer lacks. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 65352bd..8cd082f 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -450,6 +450,18 @@ fn center_ref() -> u8 { 4 } +/// A button's On Release action. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ButtonAction { + GoToPage { page: usize }, + GoToFirstPage, + GoToLastPage, + GoToNextPage, + GoToPreviousPage, + GoToUrl { url: String }, +} + fn is_zero_usize(v: &usize) -> bool { *v == 0 } @@ -716,6 +728,9 @@ pub struct Item { /// Alternative text (Object Export Options) for tagged PDF and EPUB. #[serde(default, skip_serializing_if = "String::is_empty")] pub alt_text: String, + /// Buttons and Forms: what clicking this object does in an interactive PDF. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub button: Option, /// Object States: a group whose children are states (named here); only `active_state` shows. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub states: Vec, @@ -764,6 +779,7 @@ impl Item { label: String::new(), alt_text: String::new(), xml_tag: String::new(), + button: None, states: Vec::new(), active_state: 0, isolate: false, diff --git a/crates/engine/src/cmd/buttons.rs b/crates/engine/src/cmd/buttons.rs new file mode 100644 index 0000000..5c23cc5 --- /dev/null +++ b/crates/engine/src/cmd/buttons.rs @@ -0,0 +1,89 @@ +//! Window › Interactive › Buttons and Forms: objects that act when clicked in an interactive PDF +//! (go to a page, the next / previous / first / last page, or a URL). + +use designcraft_doc::ButtonAction; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_selection, targets}; +use crate::Result; + +fn parse(p: &Value) -> Result> { + let a = p.get("action").and_then(Value::as_str).unwrap_or("nextPage"); + Ok(Some(match a { + "none" => return Ok(None), + "page" => ButtonAction::GoToPage { + page: p.get("page").and_then(Value::as_u64).ok_or_else(|| bad("button.set", "`page` (0-based) required"))? as usize, + }, + "firstPage" => ButtonAction::GoToFirstPage, + "lastPage" => ButtonAction::GoToLastPage, + "nextPage" => ButtonAction::GoToNextPage, + "previousPage" => ButtonAction::GoToPreviousPage, + "url" => ButtonAction::GoToUrl { url: p.get("url").and_then(Value::as_str).ok_or_else(|| bad("button.set", "`url` required"))?.to_string() }, + o => return Err(bad("button.set", format!("unknown action `{o}`"))), + })) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "button.set", + "Convert to Button", + ["Object", "Interactive"], + None, + "{ids?, action: page|firstPage|lastPage|nextPage|previousPage|url|none, page?, url?} — the selected objects act on release in interactive PDF", + has_selection, + |s, p| { + let ids = targets(s, p)?; + let action = parse(p)?; + s.edit(|d, _| { + for id in &ids { + if let Some(it) = d.item_mut(*id) { + it.button = action.clone(); + } + } + Ok(json!({"buttons": ids.len()})) + }) + } + ), + cmd!("button.clear", "Convert to Object", ["Object", "Interactive"], None, "{ids?}", has_selection, |s, p| { + let mut q = p.clone(); + q["action"] = json!("none"); + s.execute("button.set", &q) + }), + cmd!(query "button.list", "Buttons", [], None, "{} → [{id, name, action}]", super::has_doc, |s, _| { + let d = &s.doc()?.doc; + let mut out = Vec::new(); + for it in d.all_items().into_iter().filter_map(|id| d.item(id)) { + if let Some(b) = &it.button { + out.push(json!({"id": it.id.0, "name": it.name, "action": b})); + } + } + Ok(Value::Array(out)) + }), + ] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn buttons_become_pdf_links() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [72, 72, 200, 120]})).unwrap()["id"].clone(); + s.execute("button.set", &json!({"ids": [id], "action": "lastPage"})).unwrap(); + assert_eq!(s.execute("button.list", &json!({})).unwrap()[0]["action"]["kind"], "goToLastPage"); + let links = |s: &mut Session| { + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + String::from_utf8_lossy(&bytes).matches("/Link").count() + }; + let with = links(&mut s); + s.execute("button.clear", &json!({"ids": [id]})).unwrap(); + assert!(with > links(&mut s), "the button exports a link annotation"); + assert!(s.execute("button.list", &json!({})).unwrap().as_array().unwrap().is_empty()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c5a6ff8..3729a72 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,6 +4,7 @@ mod anchored; pub mod anchors; mod articles; pub mod book; +mod buttons; mod captions; mod changes; mod conditions; @@ -150,6 +151,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(articles::specs()); v.extend(paths::specs()); v.extend(book::specs()); + v.extend(buttons::specs()); v.extend(captions::specs()); v.extend(changes::specs()); v.extend(conditions::specs()); diff --git a/crates/pdf/src/links.rs b/crates/pdf/src/links.rs index e63d708..9fbf275 100644 --- a/crates/pdf/src/links.rs +++ b/crates/pdf/src/links.rs @@ -76,6 +76,47 @@ pub(crate) fn annotations(doc: &Document, cache: &designcraft_compose::Cache, sh out.push(Annotation::new_link(LinkAnnotation::new(rect, target), Some(h.name.clone()))); } } + // Buttons: their bounds act on release. + let here = doc.spreads.get(sh.spread).and_then(|sp| { + let first = doc.spreads[..sh.spread].iter().map(|s| s.pages.len()).sum::(); + sp.pages.iter().position(|p| p.bounds().intersect(sh.trim).area() > 0.0).map(|i| first + i) + }); + let mut stack: Vec<(Affine, &std::sync::Arc)> = + doc.spreads.get(sh.spread).map(|sp| sp.items.iter().map(|it| (Affine::IDENTITY, it)).collect()).unwrap_or_default(); + while let Some((xf, it)) = stack.pop() { + stack.extend(it.shown_children().map(|c| (xf * it.xf, c))); + let Some(action) = &it.button else { continue }; + if it.hidden { + continue; + } + let r = xf.transform_rect_bbox(it.bounds()).intersect(sh.bleed); + let rect = krilla::geom::Rect::from_ltrb( + (r.x0 - sh.media.x0) as f32, + (r.y0 - sh.media.y0) as f32, + (r.x1 - sh.media.x0) as f32, + (r.y1 - sh.media.y0) as f32, + ); + let Some(rect) = rect.filter(|_| r.width() > 0.0 && r.height() > 0.0) else { continue }; + use designcraft_doc::ButtonAction as B; + let total = doc.page_count(); + let page = match action { + B::GoToUrl { url } => { + out.push(Annotation::new_link( + LinkAnnotation::new(rect, Target::Action(Action::Link(LinkAction::new(url.clone())))), + Some(it.name.clone()), + )); + continue; + } + B::GoToPage { page } => Some(page.to_owned()), + B::GoToFirstPage => Some(0), + B::GoToLastPage => total.checked_sub(1), + B::GoToNextPage => here.map(|h| h + 1).filter(|p| *p < total), + B::GoToPreviousPage => here.and_then(|h| h.checked_sub(1)), + }; + let Some(i) = page.and_then(|p| sheet_of_page(doc, sheets, p)) else { continue }; + let target = Target::Destination(XyzDestination::new(i, krilla::geom::Point::from_xy(0.0, 0.0)).into()); + out.push(Annotation::new_link(LinkAnnotation::new(rect, target), Some(it.name.clone()))); + } // Cross-references link to their destination's page. if doc.stories.values().any(|st| !st.xrefs.is_empty()) { let index = cache.xref_index(doc); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 5550c6b..ebfdea7 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -39,6 +39,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("tags", "Tags", "panel-tags"), ("book", "Book", "panel-library"), ("states", "Object States", "panel-articles"), + ("buttons", "Buttons and Forms", "panel-articles"), ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -183,6 +184,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "tags" => panels::interactive::tags(app, ui), "book" => panels::library::book(app, ui), "states" => panels::interactive::states(app, ui), + "buttons" => panels::interactive::buttons(app, ui), "table" => panels::table::show(app, ui), _ => panels::properties::info_panel(app, ui), } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a4a0bcc..cd25b02 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -519,6 +519,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}", "ui:window.panel|Articles|{\"panel\": \"articles\"}", "ui:window.panel|Object States|{\"panel\": \"states\"}", + "ui:window.panel|Buttons and Forms|{\"panel\": \"buttons\"}", "ui:window.panel|Tags|{\"panel\": \"tags\"}", "ui:window.panel|Book|{\"panel\": \"book\"}", "<", diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 2f5aef4..306c959 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -204,3 +204,72 @@ pub fn states(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("states.release", json!({})); } } + +/// Buttons and Forms panel: the selected objects' On Release action. +pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let Some(id) = app.session.active().and_then(|d| d.selection.items.first().copied()) else { + ui.label(egui::RichText::new("Select an object to make it a button.").size(11.0).color(t.text_dim)); + return; + }; + let cur = app.session.active().and_then(|d| d.doc.item(id)).and_then(|it| it.button.clone()); + use designcraft_doc::ButtonAction as B; + let (kind, page, url) = match &cur { + None => ("none", 0, String::new()), + Some(B::GoToPage { page }) => ("page", *page, String::new()), + Some(B::GoToFirstPage) => ("firstPage", 0, String::new()), + Some(B::GoToLastPage) => ("lastPage", 0, String::new()), + Some(B::GoToNextPage) => ("nextPage", 0, String::new()), + Some(B::GoToPreviousPage) => ("previousPage", 0, String::new()), + Some(B::GoToUrl { url }) => ("url", 0, url.clone()), + }; + let labels = [ + ("none", "Not a button"), + ("nextPage", "Go To Next Page"), + ("previousPage", "Go To Previous Page"), + ("firstPage", "Go To First Page"), + ("lastPage", "Go To Last Page"), + ("page", "Go To Page"), + ("url", "Go To URL"), + ]; + let mut pick = None; + ui.horizontal(|ui| { + ui.label("On Release"); + egui::ComboBox::from_id_salt("button_action").selected_text(labels.iter().find(|l| l.0 == kind).map_or("", |l| l.1)).show_ui(ui, |ui| { + for (k, l) in labels { + if ui.selectable_label(k == kind, l).clicked() { + pick = Some(k); + } + } + }); + }); + let mut params = None; + if let Some(k) = pick { + params = Some(json!({"action": k, "page": page, "url": if url.is_empty() { "https://" } else { &url }})); + } + if kind == "page" { + let total = app.session.active().map_or(1, |d| d.doc.page_count()); + let mut n = page + 1; + ui.horizontal(|ui| { + ui.label("Page"); + if ui.add(egui::DragValue::new(&mut n).range(1..=total)).changed() { + params = Some(json!({"action": "page", "page": n - 1})); + } + }); + } + if kind == "url" { + let key = egui::Id::new(("button_url", id.0)); + let mut u: String = ui.data(|d| d.get_temp(key)).unwrap_or(url.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut u).desired_width(f32::INFINITY)); + if r.lost_focus() && u != url { + params = Some(json!({"action": "url", "url": u})); + } + ui.data_mut(|d| d.insert_temp(key, u)); + } + if let Some(p) = params + && let Err(e) = app.run("button.set", p) + { + app.status(format!("Buttons: {e}")); + } + ui.label(egui::RichText::new("Buttons act in interactive PDF export.").size(10.5).color(t.text_dim)); +} diff --git a/docs/parity.md b/docs/parity.md index 5fa0c11..269671c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 92 | 4 | 1 | 97% | -| P2 | 76 | 35 | 22 | 19 | 61% | +| P2 | 76 | 35 | 23 | 18 | 61% | | Area | Parity | |---|---| @@ -28,7 +28,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Styles | 97% | | Tables | 94% | | Long documents | 96% | -| Interactivity & digital | 85% | +| Interactivity & digital | 90% | | Output & production | 80% | | XML & automation | 83% | | View & navigation | 88% | @@ -270,7 +270,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Long documents | Captions | P2 | P | | Interactivity & digital | Hyperlinks panel | P1 | D | | Interactivity & digital | Bookmarks | P1 | D | -| Interactivity & digital | Buttons and Forms | P2 | M | +| Interactivity & digital | Buttons and Forms | P2 | P | | Interactivity & digital | Object States | P2 | D | | Interactivity & digital | Articles panel | P2 | D | | Interactivity & digital | Object Export Options | P2 | P |