diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 91988eb32..d20305b6e 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -135,6 +135,8 @@ impl Document { include_prefix: false, }], assets: BTreeMap::new(), + hyperlinks: vec![], + bookmarks: vec![], next_id: 0, }; let lid = LayerId(d.alloc()); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 337e5cb08..18fa788fe 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -226,9 +226,52 @@ pub struct Document { pub sections: Vec
, #[serde(default)] pub assets: BTreeMap>, + /// Hyperlinks (Window → Interactive → Hyperlinks). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hyperlinks: Vec, + /// PDF bookmarks (Window → Interactive → Bookmarks). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bookmarks: Vec, pub next_id: u64, } +/// What a hyperlink is attached to. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum HyperlinkSource { + Item { id: ItemId }, + Text { story: StoryId, start: usize, end: usize }, +} + +/// Where a hyperlink goes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum HyperlinkDest { + Url(String), + Email(String), + /// Absolute page index. + Page(usize), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Hyperlink { + pub id: u64, + pub name: String, + pub source: HyperlinkSource, + pub dest: HyperlinkDest, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Bookmark { + pub name: String, + /// Absolute page index. + pub page: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + impl Document { /// Allocate a fresh id. pub fn alloc(&mut self) -> u64 { diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs new file mode 100644 index 000000000..21e6e6776 --- /dev/null +++ b/crates/engine/src/cmd/interactive.rs @@ -0,0 +1,90 @@ +//! Hyperlinks and bookmarks (Window → Interactive), exported to PDF as link annotations and the outline. + +use designcraft_doc::{Bookmark, Hyperlink, HyperlinkDest, HyperlinkSource}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "hyperlink.create", + "New Hyperlink…", + ["Type", "Hyperlinks & Cross-References"], + None, + "{url? | email? | page? (1-based), name?, ids?} — from the selected text, or the selected frames", + has_doc, + create + ), + cmd!("hyperlink.delete", "Delete Hyperlink", [], None, "{id}", has_doc, |s, p| { + let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("hyperlink.delete", "missing id"))?; + s.edit(|d, _| { + d.hyperlinks.retain(|h| h.id != id); + Ok(Value::Null) + }) + }), + cmd!(query "hyperlink.list", "Hyperlinks", [], None, "{}", has_doc, |s, _| Ok(serde_json::to_value(&s.doc()?.doc.hyperlinks).unwrap_or_default())), + cmd!("bookmark.add", "New Bookmark", [], None, "{name?, page? (1-based; default: current selection's page or 1)}", has_doc, |s, p| { + let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1)).unwrap_or(0); + let n = s.doc()?.doc.page_count(); + if page >= n { + return Err(bad("bookmark.add", format!("no page {}", page + 1))); + } + let name = str_param(p, "name").map(str::to_string); + s.edit(|d, _| { + let name = name.clone().unwrap_or_else(|| format!("Bookmark {}", d.bookmarks.len() + 1)); + d.bookmarks.push(Bookmark { name, page, children: vec![] }); + Ok(json!({"index": d.bookmarks.len() - 1})) + }) + }), + cmd!("bookmark.delete", "Delete Bookmark", [], None, "{index}", has_doc, |s, p| { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("bookmark.delete", "missing index"))? as usize; + s.edit(|d, _| { + if i < d.bookmarks.len() { + d.bookmarks.remove(i); + } + Ok(Value::Null) + }) + }), + cmd!(query "bookmark.list", "Bookmarks", [], None, "{}", has_doc, |s, _| Ok(serde_json::to_value(&s.doc()?.doc.bookmarks).unwrap_or_default())), + ] +} + +fn create(s: &mut Session, p: &Value) -> Result { + let dest = if let Some(u) = str_param(p, "url") { + HyperlinkDest::Url(u.to_string()) + } else if let Some(e) = str_param(p, "email") { + HyperlinkDest::Email(e.to_string()) + } else if let Some(pg) = p.get("page").and_then(Value::as_u64) { + HyperlinkDest::Page((pg as usize).saturating_sub(1)) + } else { + return Err(bad("hyperlink.create", "give `url`, `email` or `page`")); + }; + let st = s.doc()?; + let sources: Vec = if let Some(ids) = super::ids_param(p, "ids") { + ids.into_iter().map(|id| HyperlinkSource::Item { id }).collect() + } else if let Some(t) = st.selection.text.filter(|t| !t.is_caret()) { + vec![HyperlinkSource::Text { story: t.story, start: t.range().start, end: t.range().end }] + } else { + st.selection.items.iter().map(|id| HyperlinkSource::Item { id: *id }).collect() + }; + if sources.is_empty() { + return Err(bad("hyperlink.create", "select text or frames first")); + } + let name = str_param(p, "name").map(str::to_string); + s.edit(|d, _| { + let mut ids = vec![]; + for src in sources { + let id = d.alloc(); + let name = name.clone().unwrap_or_else(|| match &dest { + HyperlinkDest::Url(u) => u.clone(), + HyperlinkDest::Email(e) => e.clone(), + HyperlinkDest::Page(p) => format!("Page {}", p + 1), + }); + d.hyperlinks.push(Hyperlink { id, name, source: src, dest: dest.clone() }); + ids.push(id); + } + Ok(json!({"ids": ids})) + }) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bd98f417d..a7faf88f5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ mod export; mod file; mod find; mod inspect; +mod interactive; pub mod interchange; mod layout; mod object; @@ -113,6 +114,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(layout::specs()); v.extend(inspect::specs()); v.extend(find::specs()); + v.extend(interactive::specs()); v.extend(preflight::specs()); v }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 46587f546..069f7610b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -218,6 +218,29 @@ fn preflight_reports_overset_and_missing_fonts() { assert_eq!(r["errors"], 2); } +#[test] +fn hyperlinks_and_bookmarks_reach_the_pdf() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + s.execute("selection.set", &json!({"ids": [39]})).unwrap(); + s.execute("hyperlink.create", &json!({"url": "https://example.com/designcraft"})).unwrap(); + let d = s.execute("document.inspect", &json!({})).unwrap(); + let sid = d["stories"][0]["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + s.execute("hyperlink.create", &json!({"page": 3})).unwrap(); + s.execute("bookmark.add", &json!({"name": "Cover", "page": 1})).unwrap(); + s.execute("bookmark.add", &json!({"name": "Feature", "page": 2})).unwrap(); + assert_eq!(s.execute("hyperlink.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = cmd::base64_decode(r["base64"].as_str().unwrap()); + let pdf = hayro_syntax::Pdf::new(bytes).expect("valid pdf"); + assert_eq!(pdf.pages().len(), 4); + // Round trip through the native format keeps them. + let back = cmd::file_from(&cmd::file_bytes(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(back.hyperlinks.len(), 2); + assert_eq!(back.bookmarks.len(), 2); +} + /// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText). fn pdf_text(bytes: &[u8]) -> Vec { use hayro_interpret::font::Glyph; diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index f976f507c..142844283 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1351,6 +1351,8 @@ impl<'r> Importer<'r> { swatches: std::mem::take(&mut self.swatches), sections: std::mem::take(&mut self.sections), assets: std::mem::take(&mut self.assets), + hyperlinks: vec![], + bookmarks: vec![], next_id: self.next_id, }; Ok(d) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 1ad75b508..8fd563eca 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -153,7 +153,10 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) if ex.rgb_only { ex.warn("PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)"); } - for sh in &sheets { + if let Some(o) = crate::links::outline(doc, &sheets) { + pdf.set_outline(o); + } + for (sheet_idx, sh) in sheets.iter().enumerate() { let size = Size::from_wh(sh.media.width().max(1.0) as f32, sh.media.height().max(1.0) as f32).ok_or(PdfError::NoPages)?; let local = |r: Rect| { krilla::geom::Rect::from_ltrb( @@ -176,6 +179,9 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) ex.marks(&mut s, sh, &title, created); s.pop(); s.finish(); + for a in crate::links::annotations(doc, cache, &sheets, sheet_idx) { + page.add_annotation(a); + } page.finish(); } let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 0c645c107..5b9566b92 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -26,6 +26,7 @@ #![forbid(unsafe_code)] mod export; +mod links; mod marks; mod text; diff --git a/crates/pdf/src/links.rs b/crates/pdf/src/links.rs new file mode 100644 index 000000000..d025e49f4 --- /dev/null +++ b/crates/pdf/src/links.rs @@ -0,0 +1,104 @@ +//! Interactive PDF: hyperlink annotations (frames and text ranges → URLs, e-mail, pages) and the +//! bookmark outline. + +use designcraft_doc::{Bookmark, Document, HyperlinkDest, HyperlinkSource, SpreadRef}; +use designcraft_geom::{Affine, Rect}; +use krilla::action::{Action, LinkAction}; +use krilla::annotation::{Annotation, LinkAnnotation, Target}; +use krilla::destination::XyzDestination; +use krilla::outline::{Outline, OutlineNode}; + +use crate::export::Sheet; + +/// Index of the output sheet showing absolute page `abs`. +pub(crate) fn sheet_of_page(doc: &Document, sheets: &[Sheet], abs: usize) -> Option { + let (si, pi) = doc.page_loc(abs)?; + let page = doc.spreads.get(si)?.pages.get(pi)?.bounds(); + sheets.iter().position(|s| s.spread == si && s.trim.x0 <= page.center().x && page.center().x <= s.trim.x1) +} + +/// Spread-space rectangles covered by a hyperlink source on spread `si`. +fn source_rects(doc: &Document, cache: &designcraft_compose::Cache, src: &HyperlinkSource, si: usize) -> Vec { + match src { + HyperlinkSource::Item { id } => match (doc.find(*id), doc.item(*id)) { + (Some(loc), Some(it)) if loc.spread == SpreadRef::Doc(si) => vec![doc.parent_xf(&loc).transform_rect_bbox(it.bounds())], + _ => vec![], + }, + HyperlinkSource::Text { story, start, end } => { + let cs = cache.get(doc, *story, None); + let mut out = Vec::new(); + for ft in &cs.frames { + let (Some(loc), Some(it)) = (doc.find(ft.frame), doc.item(ft.frame)) else { continue }; + if loc.spread != SpreadRef::Doc(si) { + continue; + } + let m: Affine = doc.parent_xf(&loc) * it.xf; + for l in &ft.lines { + if l.range.end <= *start || l.range.start >= *end { + continue; + } + let gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= *start && g.byte < *end).collect(); + let (Some(a), Some(b)) = (gs.first(), gs.last()) else { continue }; + let r = Rect::new(a.x, l.baseline - l.ascent, b.x + b.adv, l.baseline + l.descent); + out.push(m.transform_rect_bbox(r)); + } + } + out + } + } +} + +/// Link annotations for the sheet at `idx`. +pub(crate) fn annotations(doc: &Document, cache: &designcraft_compose::Cache, sheets: &[Sheet], idx: usize) -> Vec { + let sh = &sheets[idx]; + let mut out = Vec::new(); + for h in &doc.hyperlinks { + for r in source_rects(doc, cache, &h.source, sh.spread) { + let r = r.intersect(sh.bleed); + if r.width() <= 0.0 || r.height() <= 0.0 { + continue; + } + 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 else { continue }; + let target = match &h.dest { + HyperlinkDest::Url(u) => Target::Action(Action::Link(LinkAction::new(u.clone()))), + HyperlinkDest::Email(e) => Target::Action(Action::Link(LinkAction::new(format!("mailto:{e}")))), + HyperlinkDest::Page(p) => match sheet_of_page(doc, sheets, *p) { + Some(i) => Target::Destination(XyzDestination::new(i, krilla::geom::Point::from_xy(0.0, 0.0)).into()), + None => continue, + }, + }; + out.push(Annotation::new_link(LinkAnnotation::new(rect, target), Some(h.name.clone()))); + } + } + out +} + +/// The document outline from the bookmarks (bookmarks to pages that weren't exported are skipped). +pub(crate) fn outline(doc: &Document, sheets: &[Sheet]) -> Option { + fn node(doc: &Document, sheets: &[Sheet], b: &Bookmark) -> Option { + let i = sheet_of_page(doc, sheets, b.page)?; + let mut n = OutlineNode::new(b.name.clone(), XyzDestination::new(i, krilla::geom::Point::from_xy(0.0, 0.0))); + for c in &b.children { + if let Some(cn) = node(doc, sheets, c) { + n.push_child(cn); + } + } + Some(n) + } + if doc.bookmarks.is_empty() { + return None; + } + let mut o = Outline::new(); + for b in &doc.bookmarks { + if let Some(n) = node(doc, sheets, b) { + o.push_child(n); + } + } + Some(o) +}