//! 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, } /// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link /// lists as [`LinkAction::Other`]. pub(crate) const MAX_URI_BYTES: usize = 16 << 20; 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], uri_budget: &mut usize) -> 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") => { let uri = a.get(b"URI").map(|u| doc.resolve(u)); let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); // The raw length first, so an address already over the budget is never converted; then the // converted text, which is up to three times longer where the bytes are not UTF-8. let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes)); match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) { Some((left, text)) => { *uri_budget = left; LinkAction::Uri(text.into_owned()) } None => LinkAction::Other("web address too long".into()), } } 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(); let mut seen = std::collections::HashSet::new(); let mut uri_budget = MAX_URI_BYTES; for (pi, p) in pages.iter().enumerate() { for (k, a) in annots(doc, *p).iter().enumerate() { // One annotation object listed under several entries or pages is one link. if let Some(r) = a.as_ref() && !seen.insert(r) { continue; } 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, &mut uri_budget), 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())); } /// Bytes of `u` as a 7-bit ASCII URI string (§12.6.4.8). fn uri_bytes(u: &str) -> Vec { let mut out = Vec::with_capacity(u.len()); for &b in u.as_bytes() { if (0x21..=0x7e).contains(&b) { out.push(b); } else { out.extend_from_slice(format!("%{b:02X}").as_bytes()); } } out } 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(uri_bytes(u))); d.set(b"A".to_vec(), Object::Dict(act)); } LinkAction::Other(_) => {} } Ok(()) } /// Check link geometry before writing PDF objects: a user or automation call can supply /// non-finite coordinates or a finite pair whose difference overflows to infinity. fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> { if !rect.iter().all(|v| v.is_finite()) { return Err(AnnotError::Invalid("the link area must have finite coordinates".into())); } let r = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; let (width, height) = (r[2] - r[0], r[3] - r[1]); if !width.is_finite() || !height.is_finite() || width < 2.0 || height < 2.0 { return Err(AnnotError::Invalid("the link area is too small or too large".into())); } Ok(r) } /// 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 obj = new_link(doc, p, rect, action, style)?; let mut list = annots(doc, p); list.push(Object::Ref(obj)); let index = list.len() - 1; set_annots(doc, p, list)?; Ok(index) } /// A link annotation object for page `p`, not yet on the page's `/Annots`. fn new_link(doc: &mut Document, p: ObjRef, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let r = checked_rect(rect)?; 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)?; Ok(doc.add(Object::Dict(d))) } 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 = checked_rect(rect)?; 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) } /// Recognise common ASCII mailbox addresses without mistaking domains, partial words or /// invalid labels for e-mail links. The returned end is in source-character indices. fn email_at(chars: &[char], start: usize) -> Option<(usize, String)> { let local_char = |c: char| c.is_ascii_alphanumeric() || "._+%-".contains(c); let domain_char = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '.'; // Don't create a link starting in the middle of an address. if start > 0 && chars.get(start - 1).is_some_and(|c| local_char(*c) || *c == '@') { return None; } let mut at = start; while chars.get(at).is_some_and(|c| local_char(*c)) { at += 1; if at - start > 64 { return None; } } let local = chars.get(start..at)?; if local.is_empty() || local.first() == Some(&'.') || local.last() == Some(&'.') || local.windows(2).any(|w| w == ['.', '.']) { return None; } if chars.get(at) != Some(&'@') { return None; } let mut end = at + 1; while chars.get(end).is_some_and(|c| domain_char(*c)) { end += 1; if end - at - 1 > 253 { return None; } } while end > at + 1 && chars.get(end - 1) == Some(&'.') { end -= 1; } if chars.get(end).is_some_and(|c| c.is_ascii_alphanumeric() || *c == '@' || *c == '_') { return None; } let domain = chars.get(at + 1..end)?; let mut labels = 0; let mut tld_length = 0; for label in domain.split(|c| *c == '.') { if label.is_empty() || label.len() > 63 || !label.first()?.is_ascii_alphanumeric() || !label.last()?.is_ascii_alphanumeric() { return None; } labels += 1; tld_length = label.len(); } if labels < 2 || tld_length < 2 { return None; } let address: String = chars.get(start..end)?.iter().collect(); Some((end, format!("mailto:{address}"))) } /// 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 url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c); // Case-insensitive prefix test that never steps past the end. Comparing against the original // characters keeps every offset in `chars`'s own index space: case-folding a copy can change // its length (U+0130 becomes "i" plus a combining dot), so a folded copy must never supply // offsets into the source (that mismatch panicked; #816). let starts_with = |start: usize, pat: &str| pat.chars().enumerate().all(|(k, p)| chars.get(start + k).is_some_and(|c| c.eq_ignore_ascii_case(&p))); let mut out = Vec::new(); let mut i = 0; while i < chars.len() { let boundary = i == 0 || chars.get(i - 1).is_some_and(|c| !c.is_alphanumeric()); if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || starts_with(i, "www.")) { let mut j = i; while chars.get(j).is_some_and(|c| url_char(*c)) { j += 1; } // Trailing punctuation belongs to the sentence. while j > i && chars.get(j - 1).is_some_and(|c| ".,;:!?".contains(*c)) { j -= 1; } let s: String = chars.get(i..j).map(|c| c.iter().collect()).unwrap_or_default(); if s.len() > 6 && s.contains('.') { let uri = if s.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("www.")) { format!("http://{s}") } else { s }; out.push((i..j, uri)); i = j; continue; } } if boundary && let Some((end, uri)) = email_at(chars, i) { out.push((i..end, uri)); i = end; 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 pages = page_refs(doc)?; // Check the whole batch first, so a bad entry fails it before any link object is created. for (page, rects, uri) in links { pages.get(*page).ok_or(AnnotError::NoSuchPage(*page))?; if uri.trim().is_empty() { return Err(AnnotError::Invalid("type the web address first".into())); } for r in rects { checked_rect(*r)?; } } // Each page's `/Annots` is extended once, with its new links in the order given. let mut added: Vec<(ObjRef, Vec)> = Vec::new(); let mut slot: std::collections::HashMap = std::collections::HashMap::new(); let mut n = 0; for (page, rects, uri) in links { let p = pages.get(*page).copied().ok_or(AnnotError::NoSuchPage(*page))?; let action = LinkAction::Uri(uri.clone()); for r in rects { let obj = new_link(doc, p, *r, &action, style)?; let k = *slot.entry(p).or_insert_with(|| { added.push((p, Vec::new())); added.len() - 1 }); if let Some((_, list)) = added.get_mut(k) { list.push(Object::Ref(obj)); } n += 1; } } for (p, new) in added { let mut list = annots(doc, p); list.extend(new); set_annots(doc, p, list)?; } Ok(n) }