M7/M12: hyperlinks (frames and text → URL/e-mail/page) and bookmarks; PDF link annotations and outline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 12:16:48 -04:00
co-authored by Claude Opus 5.5
parent d45743f311
commit cefb9009fe
9 changed files with 274 additions and 1 deletions
+2
View File
@@ -135,6 +135,8 @@ impl Document {
include_prefix: false,
}],
assets: BTreeMap::new(),
hyperlinks: vec![],
bookmarks: vec![],
next_id: 0,
};
let lid = LayerId(d.alloc());
+43
View File
@@ -226,9 +226,52 @@ pub struct Document {
pub sections: Vec<Section>,
#[serde(default)]
pub assets: BTreeMap<AssetId, Arc<Asset>>,
/// Hyperlinks (Window → Interactive → Hyperlinks).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hyperlinks: Vec<Hyperlink>,
/// PDF bookmarks (Window → Interactive → Bookmarks).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bookmarks: Vec<Bookmark>,
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<Bookmark>,
}
impl Document {
/// Allocate a fresh id.
pub fn alloc(&mut self) -> u64 {
+90
View File
@@ -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<CommandSpec> {
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<Value> {
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<HyperlinkSource> = 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}))
})
}
+2
View File
@@ -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
})
+23
View File
@@ -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<String> {
use hayro_interpret::font::Glyph;
+2
View File
@@ -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)
+7 -1
View File
@@ -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:?}")))?;
+1
View File
@@ -26,6 +26,7 @@
#![forbid(unsafe_code)]
mod export;
mod links;
mod marks;
mod text;
+104
View File
@@ -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<usize> {
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<Rect> {
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<Annotation> {
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<Outline> {
fn node(doc: &Document, sheets: &[Sheet], b: &Bookmark) -> Option<OutlineNode> {
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)
}