Files
pdfcraft/crates/annot/src/links.rs
T

631 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 at `zoom`.
Page {
page: usize,
zoom: LinkZoom,
},
Uri(String),
/// Something PdfCraft doesn't edit yet (named destination, JavaScript…): kept as is.
Other(String),
}
/// How a go-to-page link shows its page: Acrobat's Go to a page view zoom, written as an
/// explicit destination (ISO 32000-2 §12.3.2.2, Table 149). Positions are written `null`, so the
/// reader keeps its position in that direction, as a new bookmark does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LinkZoom {
/// `/Fit`: the whole page in the window.
FitPage,
/// `/FitH null`: the page's width across the window.
FitWidth,
/// `/FitV null`: the page's height down the window.
FitHeight,
/// `/FitB`: the page's content (its bounding box) in the window.
FitVisible,
/// `/XYZ null null null`: the reader's zoom is kept.
Inherit,
/// `/XYZ null null z` with z = percent / 100; 100 is Actual Size. A new zoom is written
/// within 1–6400%; a link rewritten with the zoom it has keeps its own factor.
Percent(f64),
}
impl LinkZoom {
/// The zoom an explicit destination array (`[page /XYZ …]`, `[page /Fit]`, …) asks for. The
/// boxed variants read as their unboxed ones, and a `/FitR` rectangle or a missing or unknown
/// type as Fit Page, the view links had before they carried a zoom. A link whose page and zoom
/// are unchanged is not rewritten, so its position is kept; one given a new page keeps how its
/// zoom is written (`write_action`) but not its position.
fn of_dest(doc: &Document, dest: &[Object]) -> Self {
let kind = dest.get(1).map(|k| doc.resolve(k)).and_then(|k| k.as_name().map(<[u8]>::to_vec));
match kind.as_deref() {
Some(b"FitB") => LinkZoom::FitVisible,
Some(b"FitH" | b"FitBH") => LinkZoom::FitWidth,
Some(b"FitV" | b"FitBV") => LinkZoom::FitHeight,
Some(b"XYZ") => match dest.get(4).and_then(|z| doc.resolve(z).as_f64()) {
// Finite even for a factor near f64::MAX. A link given a new page with this zoom
// keeps the factor itself, however far outside 1–6400% (`write_action`).
Some(z) if z.is_finite() && z > 0.0 => LinkZoom::Percent((z * 100.0).min(f64::MAX)),
// A zoom of 0 or null keeps the reader's (Table 149).
_ => LinkZoom::Inherit,
},
_ => LinkZoom::FitPage,
}
}
/// The destination's elements after the page reference.
fn operands(self) -> Result<Vec<Object>, AnnotError> {
Ok(match self {
LinkZoom::FitPage => vec![Object::name("Fit")],
LinkZoom::FitWidth => vec![Object::name("FitH"), Object::Null],
LinkZoom::FitHeight => vec![Object::name("FitV"), Object::Null],
LinkZoom::FitVisible => vec![Object::name("FitB")],
LinkZoom::Inherit => vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Null],
LinkZoom::Percent(p) => {
if !(p.is_finite() && p > 0.0) {
return Err(AnnotError::Invalid("the link's zoom must be a positive percentage".into()));
}
vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Real((p / 100.0).clamp(0.01, 64.0))]
}
})
}
}
/// 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<ObjRef>,
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<f64> {
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 page_view = |dest: &Object| -> Option<LinkAction> {
let a = doc.resolve(dest);
let a = a.as_array()?;
let page = match a.first()? {
Object::Ref(r) => pages.iter().position(|p| p == r),
Object::Int(i) => usize::try_from(*i).ok(),
_ => None,
}?;
Some(LinkAction::Page { page, zoom: LinkZoom::of_dest(doc, a) })
};
if let Some(dest) = d.get(b"Dest") {
return page_view(dest).unwrap_or_else(|| LinkAction::Other("named destination".into()));
}
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(page_view).unwrap_or_else(|| LinkAction::Other("named destination".into())),
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<LinkItem> {
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<u8> {
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
}
/// The explicit destination array link `d` goes to now: its `/Dest`, or a GoTo action's `/D`.
fn current_dest(doc: &Document, d: &Dict) -> Option<Vec<Object>> {
let dest = match d.get(b"Dest") {
Some(dest) => doc.resolve(dest),
None => {
let action = doc.resolve(d.get(b"A")?);
let go_to = action.as_dict().filter(|a| a.name(b"S") == Some(b"GoTo"))?;
doc.resolve(go_to.get(b"D")?)
}
};
dest.as_array().cloned()
}
fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> {
match a {
LinkAction::Page { page, zoom } => {
let target = page_ref(doc, *page)?;
let mut operands = zoom.operands()?;
let current = current_dest(doc, d).filter(|c| LinkZoom::of_dest(doc, c) == *zoom);
if let Some(c) = current {
// The page and zoom the link has (`link_edit` naming its own page): the
// destination is left as it is, position on the page and GoTo form included.
let same_page = match c.first() {
Some(Object::Ref(r)) => *r == target,
Some(Object::Int(i)) => usize::try_from(*i).ok() == Some(*page),
_ => false,
};
if same_page {
return Ok(());
}
// A new page with the zoom the link has keeps how that zoom is written: a boxed
// fit (`/FitBH`, `/FitBV`) and its own `/XYZ` factor, even one outside the 1–6400%
// a new zoom is written within. Positions belong to the old page and are dropped.
let (slot, own) = match zoom {
LinkZoom::Percent(_) => (operands.get_mut(3), c.get(4)),
LinkZoom::FitWidth | LinkZoom::FitHeight => (operands.get_mut(0), c.get(1)),
_ => (None, None),
};
if let (Some(slot), Some(own)) = (slot, own) {
*slot = doc.resolve(own).as_ref().clone();
}
}
let mut dest = vec![Object::Ref(target)];
dest.extend(operands);
d.remove(b"A");
d.set(b"Dest".to_vec(), Object::Array(dest));
}
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<usize, AnnotError> {
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<ObjRef, AnnotError> {
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);
Ok(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<usize, AnnotError> {
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<Object> = 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}")))
}
/// The longest web address [`find_urls`] reads, in characters (browsers and servers commonly
/// stop near 2,000). Each address it might be is read this far at most, so text whose brackets
/// never close (`https://a/(https://a/(…`, without a space) costs this much per address in it,
/// not a pass over the rest of the page each.
const MAX_URL_CHARS: usize = 2048;
/// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become
/// `mailto:`. Returns character ranges and the URI for each.
///
/// No other scheme is ever found, and a `www.` or e-mail address that is part of one written with
/// another scheme (`ftp://www.example.com`, `smb://admin@example.org`) is not taken out of it:
/// it would open with a scheme the text doesn't say. Brackets inside a web address belong to it
/// when they pair up (`…/wiki/Mercury_(planet)`); an unpaired one ends it.
pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c);
// Characters found inside an address but not between words: a `www.` after one is part of
// a longer address, and so is an e-mail address after a slash.
let inside_address = |i: usize, set: &str| i > 0 && chars.get(i - 1).is_some_and(|c| set.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());
let www = starts_with(i, "www.") && !inside_address(i, "/\\:@.-_~%+=&#");
if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || www) {
let mut j = i;
// Brackets pair up inside the address: `depth` are open, the outermost at `opened`.
let (mut depth, mut opened) = (0usize, i);
let mut too_long = false;
loop {
if j.saturating_sub(i) >= MAX_URL_CHARS {
too_long = true;
break;
}
match chars.get(j) {
Some('(') => {
if depth == 0 {
opened = j;
}
depth = depth.saturating_add(1);
}
Some(')') if depth > 0 => depth -= 1,
Some(c) if url_char(*c) => {}
_ => break,
}
j += 1;
}
// A bracket left open isn't the address's: it ends before it. A run of address
// characters longer than any address is none, and the search goes on after it.
if depth > 0 {
j = opened;
} else if too_long {
i = j;
continue;
}
// 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
&& !inside_address(i, "/\\")
&& 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<usize, AnnotError> {
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<Object>)> = Vec::new();
let mut slot: std::collections::HashMap<ObjRef, usize> = 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)
}