mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-11 16:30:16 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d45743f311
commit
cefb9009fe
@@ -135,6 +135,8 @@ impl Document {
|
||||
include_prefix: false,
|
||||
}],
|
||||
assets: BTreeMap::new(),
|
||||
hyperlinks: vec![],
|
||||
bookmarks: vec![],
|
||||
next_id: 0,
|
||||
};
|
||||
let lid = LayerId(d.alloc());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:?}")))?;
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod export;
|
||||
mod links;
|
||||
mod marks;
|
||||
mod text;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user