//! Links (ISO 32000-2 §12.5.6.5): create, edit and remove link annotations with Acrobat's Link //! Properties (appearance and a go-to-page or open-a-web-page action). use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; use crate::{AnnotError, annots, page_ref, page_refs, set_annots}; /// Where a link goes. #[derive(Clone, Debug, PartialEq)] pub enum LinkAction { /// A page of this document (0-based), shown fitting the window (`/Fit`). Page(usize), Uri(String), /// Something PdfCraft doesn't edit yet (named destination, JavaScript…): kept as is. Other(String), } /// Acrobat's Link Properties ▸ Appearance. #[derive(Clone, Copy, Debug, PartialEq)] pub struct LinkStyle { /// A visible rectangle (`/Border` width > 0) or invisible. pub visible: bool, pub color: [f64; 3], /// 1 thin, 2 medium, 3 thick. pub width: f64, pub dashed: bool, pub underline: bool, /// Highlight style when clicked: None, Invert, Outline, Inset (`/H` N, I, O, P). pub highlight: Highlight, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Highlight { None, Invert, Outline, Inset, } impl Highlight { pub const ALL: [Highlight; 4] = [Highlight::None, Highlight::Invert, Highlight::Outline, Highlight::Inset]; fn code(self) -> &'static str { match self { Highlight::None => "N", Highlight::Invert => "I", Highlight::Outline => "O", Highlight::Inset => "P", } } pub fn label(self) -> &'static str { match self { Highlight::None => "None", Highlight::Invert => "Invert", Highlight::Outline => "Outline", Highlight::Inset => "Inset", } } } impl Default for LinkStyle { fn default() -> Self { // Acrobat's defaults for a new link: invisible rectangle, invert highlight. LinkStyle { visible: false, color: [0.0, 0.0, 1.0], width: 1.0, dashed: false, underline: false, highlight: Highlight::Invert } } } /// A link on a page. #[derive(Clone, Debug, PartialEq)] pub struct LinkItem { pub page: usize, /// Index in the page's `/Annots`. pub index: usize, pub obj: Option, pub rect: [f64; 4], pub action: LinkAction, pub style: LinkStyle, } fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { let dest_page = |dest: &Object| -> Option { let a = doc.resolve(dest); let first = a.as_array()?.first()?.clone(); match first { Object::Ref(r) => pages.iter().position(|p| *p == r), Object::Int(i) => usize::try_from(i).ok(), _ => None, } }; if let Some(dest) = d.get(b"Dest") { return dest_page(dest).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page); } let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) }; match a.name(b"S") { Some(b"URI") => LinkAction::Uri( a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(), ), Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page), Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()), None => LinkAction::Other("no action".into()), } } fn style_of(doc: &Document, d: &Dict) -> LinkStyle { let bs = d.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()); let width = match &bs { Some(b) => b.get(b"W").and_then(Object::as_f64).unwrap_or(1.0), None => nums(doc, d.get(b"Border")).get(2).copied().unwrap_or(1.0), }; let s = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default(); let c = nums(doc, d.get(b"C")); LinkStyle { visible: width > 0.0, color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0, 0.0, 1.0] }, width: if width > 0.0 { width } else { 1.0 }, dashed: s == b"D", underline: s == b"U", highlight: match d.name(b"H") { Some(b"N") => Highlight::None, Some(b"O") => Highlight::Outline, Some(b"P") => Highlight::Inset, _ => Highlight::Invert, }, } } /// Every link in the document. pub fn list(doc: &Document) -> Vec { let Ok(pages) = page_refs(doc) else { return Vec::new() }; let mut out = Vec::new(); for (pi, p) in pages.iter().enumerate() { for (k, a) in annots(doc, *p).iter().enumerate() { let Some(d) = doc.resolve(a).as_dict().cloned() else { continue }; if d.name(b"Subtype") != Some(b"Link") { continue; } let r = nums(doc, d.get(b"Rect")); if r.len() != 4 { continue; } out.push(LinkItem { page: pi, index: k, obj: a.as_ref(), rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])], action: action_of(doc, &d, &pages), style: style_of(doc, &d), }); } } out } fn write_style(d: &mut Dict, s: &LinkStyle) { let mut bs = Dict::new(); bs.set(b"W".to_vec(), Object::Real(if s.visible { s.width.clamp(0.5, 12.0) } else { 0.0 })); bs.set( b"S".to_vec(), Object::name(if s.dashed { "D" } else if s.underline { "U" } else { "S" }), ); if s.dashed { bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); } d.set(b"BS".to_vec(), Object::Dict(bs)); d.remove(b"Border"); d.set(b"C".to_vec(), Object::Array(s.color.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect())); d.set(b"H".to_vec(), Object::name(s.highlight.code())); } fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> { match a { LinkAction::Page(p) => { let target = page_ref(doc, *p)?; d.remove(b"A"); d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")])); } LinkAction::Uri(u) => { let u = u.trim(); if u.is_empty() { return Err(AnnotError::Invalid("type the web address first".into())); } d.remove(b"Dest"); let mut act = Dict::new(); act.set(b"S".to_vec(), Object::name("URI")); // URIs are 7-bit ASCII strings (§12.6.4.8). act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec())); d.set(b"A".to_vec(), Object::Dict(act)); } LinkAction::Other(_) => {} } Ok(()) } /// Add a link over `rect` (user space). Returns its index in the page's `/Annots`. pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let p = page_ref(doc, page)?; let r = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < 2.0 || r[3] - r[1] < 2.0 { return Err(AnnotError::Invalid("the link area is too small".into())); } if matches!(action, LinkAction::Other(_)) { return Err(AnnotError::Invalid("choose a page or a web address".into())); } let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); d.set(b"Subtype".to_vec(), Object::name("Link")); d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())); d.set(b"P".to_vec(), Object::Ref(p)); d.set(b"F".to_vec(), Object::Int(4)); write_style(&mut d, style); write_action(doc, &mut d, action)?; let obj = doc.add(Object::Dict(d)); let mut list = annots(doc, p); list.push(Object::Ref(obj)); let index = list.len() - 1; set_annots(doc, p, list)?; Ok(index) } fn link_at(doc: &Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { let p = page_ref(doc, page)?; let a = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; let r = a.as_ref().ok_or_else(|| AnnotError::Invalid("an inline link can't be edited".into()))?; if doc.get(r).as_dict().and_then(|d| d.name(b"Subtype").map(<[u8]>::to_vec)).as_deref() != Some(b"Link") { return Err(AnnotError::Invalid("that annotation isn't a link".into())); } Ok((p, r)) } /// Link Properties: change a link's area, action and/or appearance. pub fn set( doc: &mut Document, page: usize, index: usize, rect: Option<[f64; 4]>, action: Option<&LinkAction>, style: Option<&LinkStyle>, ) -> Result<(), AnnotError> { let (_, r) = link_at(doc, page, index)?; let mut d = doc.get(r).as_dict().cloned().unwrap_or_default(); if let Some(rect) = rect { let rr = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; if rr[2] - rr[0] < 2.0 || rr[3] - rr[1] < 2.0 { return Err(AnnotError::Invalid("the link area is too small".into())); } d.set(b"Rect".to_vec(), Object::Array(rr.iter().map(|v| Object::Real(*v)).collect())); } if let Some(s) = style { write_style(&mut d, s); } if let Some(a) = action { write_action(doc, &mut d, a)?; } doc.set(r, Object::Dict(d)); Ok(()) } /// Delete one link. pub fn delete(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { let (p, _) = link_at(doc, page, index)?; let mut list = annots(doc, p); list.remove(index); set_annots(doc, p, list) } /// Remove every link on `pages` (all when `None`). Returns how many went. pub fn remove_all(doc: &mut Document, pages: Option<&[usize]>) -> Result { let refs = page_refs(doc)?; let mut n = 0; for (pi, p) in refs.iter().enumerate() { if pages.is_some_and(|ps| !ps.contains(&pi)) { continue; } let list = annots(doc, *p); let kept: Vec = list .iter() .filter(|a| doc.resolve(a).as_dict().and_then(|d| d.name(b"Subtype").map(<[u8]>::to_vec)).as_deref() != Some(b"Link")) .cloned() .collect(); if kept.len() != list.len() { n += list.len() - kept.len(); set_annots(doc, *p, kept)?; } } Ok(n) } /// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become /// `mailto:`. Returns character ranges and the URI for each. pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { let text: String = chars.iter().collect(); let lower = text.to_lowercase(); let lc: Vec = lower.chars().collect(); let mut out = Vec::new(); let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c); let mut i = 0; while i < lc.len() { let at = |s: &str| lc[i..].iter().take(s.chars().count()).copied().eq(s.chars()); let boundary = i == 0 || !lc[i - 1].is_alphanumeric(); if boundary && (at("http://") || at("https://") || at("www.")) { let mut j = i; while j < chars.len() && url_char(chars[j]) { j += 1; } // Trailing punctuation belongs to the sentence. while j > i && ".,;:!?".contains(chars[j - 1]) { j -= 1; } let s: String = chars[i..j].iter().collect(); if s.len() > 6 && s.contains('.') { let uri = if s.to_lowercase().starts_with("www.") { format!("http://{s}") } else { s }; out.push((i..j, uri)); i = j; continue; } } i += 1; } out } /// Create links for found URLs: `(page, quads, uri)` with quads in user space (one link per /// line rectangle). Returns how many links were added. pub fn add_many(doc: &mut Document, links: &[(usize, Vec<[f64; 4]>, String)], style: &LinkStyle) -> Result { let mut n = 0; for (page, rects, uri) in links { for r in rects { add(doc, *page, *r, &LinkAction::Uri(uri.clone()), style)?; n += 1; } } Ok(n) }