I: buttons — page and URL actions as PDF links (+ Buttons and Forms panel)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 04:17:25 -04:00
1 parent 3fc38549d4
commit eec8bd3d9e
9 files changed
+224 -3

No files matched your search

+1
View File
@@ -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.
+16
View File
@@ -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<ButtonAction>,
/// 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<String>,
@@ -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,
+89
View File
@@ -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<Option<ButtonAction>> {
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<CommandSpec> {
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());
}
}
+2
View File
@@ -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());
+41
View File
@@ -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::<usize>();
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<designcraft_doc::Item>)> =
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);
+2
View File
@@ -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),
}
+1
View File
@@ -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\"}",
"<",
+69
View File
@@ -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));
}
+3 -3
View File
@@ -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 |