mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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:
1 parent
3fc38549d4
commit
eec8bd3d9e
9 files changed
+224
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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\"}",
|
||||
"<",
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user