diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index e36bf3a534..85eab4a487 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -33,21 +33,28 @@ pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; - apply_appearance(doc, r, &d, stream) + let direct = with_direct_values(doc, &d); + let Some(stream) = build_appearance(doc, &d, &direct) else { return Err(AnnotError::Unsupported(subtype)) }; + let built = Built::new(stream, &direct); + apply_appearance(doc, r, &d, built) +} + +/// Whether [`set_appearance`] can draw the annotation `d`: read as the display reads it, with +/// references followed and the same fallback. Edits check this before changing anything. +pub(crate) fn can_draw(doc: &Document, d: &Dict) -> bool { + build_appearance(doc, d, &with_direct_values(doc, d)).is_some() } /// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. -/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. -fn build_appearance(doc: &Document, d: &Dict) -> Option { +/// `direct` is `d` with references followed ([`with_direct_values`]). Pure from the dictionary and +/// the objects it points at, so [`AppearanceCache`] can reuse it. +fn build_appearance(doc: &Document, d: &Dict, direct: &Dict) -> Option { // References followed first. Where that turns up something the builder doesn't draw (a // cloudy `/BE` on a square), the drawing made without them stays, rather than none. - let direct = with_direct_values(doc, d); - let built = appearance::build(&direct); - let mut stream = match (built, direct) { - (Some(stream), _) => stream, - (None, std::borrow::Cow::Owned(_)) => appearance::build(d)?, - (None, std::borrow::Cow::Borrowed(_)) => return None, + let mut stream = match appearance::build(direct) { + Some(stream) => stream, + None if !std::ptr::eq(direct, d) => appearance::build(d)?, + None => return None, }; // Picture-stamp restyling must retain the placement's page-axis correction. if d.get(b"PCPicture").is_some() { @@ -76,6 +83,11 @@ const INLINE_DEPTH: usize = 4; /// annotation, so a file can't have a large object copied for every entry that points at it. const INLINE_BUDGET: usize = 1 << 16; +/// Values copied for all the annotations one display pass draws +/// ([`with_missing_appearances_cached`]), so a large object that many annotations point at is +/// not copied for each of them without bound. Past it, references are left as they are. +pub(crate) const PASS_BUDGET: usize = 1 << 22; + /// `d` with the references among the values [`appearance::build`] reads followed. Any value may be /// given by reference (ISO 32000-2 §7.3.10): a border style dictionary (`/BS 5 0 R`, Table 168), a /// colour, `/RD`, `/CL`, a `/W` inside `/BS`. The builder reads only direct values, so it draws @@ -83,7 +95,14 @@ const INLINE_BUDGET: usize = 1 << 16; /// [`INLINE_DEPTH`] levels or [`INLINE_BUDGET`] copied values a reference is left as it is, which /// the builder treats as unreadable. `d` itself when nothing is a reference. pub(crate) fn with_direct_values<'d>(doc: &Document, d: &'d Dict) -> std::borrow::Cow<'d, Dict> { - let mut budget = INLINE_BUDGET; + with_direct_values_within(doc, d, &mut INLINE_BUDGET.clone()) +} + +/// [`with_direct_values`], copying at most [`INLINE_BUDGET`] values and no more than `pass` has +/// left, which is reduced by what was copied. +pub(crate) fn with_direct_values_within<'d>(doc: &Document, d: &'d Dict, pass: &mut usize) -> std::borrow::Cow<'d, Dict> { + let allowed = INLINE_BUDGET.min(*pass); + let mut budget = allowed; let mut out: Option = None; for (key, value) in d.iter() { if LINK_KEYS.contains(&key.as_slice()) { @@ -93,9 +112,36 @@ pub(crate) fn with_direct_values<'d>(doc: &Document, d: &'d Dict) -> std::borrow out.get_or_insert_with(|| d.clone()).set(key.clone(), direct); } } + *pass = pass.saturating_sub(allowed.saturating_sub(budget)); out.map_or(std::borrow::Cow::Borrowed(d), std::borrow::Cow::Owned) } +/// The number of values in `o`, counting into its arrays and dictionaries (not through +/// references, which are charged when followed), or `None` past `limit`. Walks at most `limit` +/// values and holds no more than `limit` waiting. +fn weight(o: &Object, limit: usize) -> Option { + let mut count = 0usize; + let mut stack = vec![o]; + while let Some(o) = stack.pop() { + count = count.checked_add(1).filter(|c| *c <= limit)?; + let inner = match o { + Object::Array(items) => items.len(), + Object::Dict(entries) => entries.len(), + _ => continue, + }; + // Every value waiting is counted later, so more than `limit` in all is over it already. + if count.saturating_add(stack.len()).saturating_add(inner) > limit { + return None; + } + match o { + Object::Array(items) => stack.extend(items.iter()), + Object::Dict(entries) => stack.extend(entries.iter().map(|(_, v)| v)), + _ => {} + } + } + Some(count) +} + /// `o` with references followed `depth` levels into arrays and dictionaries, or `None` when there /// is nothing to follow (or it may not be: a stream, an over-budget object, a chain of references). fn direct_value(doc: &Document, o: &Object, depth: usize, budget: &mut usize) -> Option { @@ -103,13 +149,11 @@ fn direct_value(doc: &Document, o: &Object, depth: usize, budget: &mut usize) -> let (base, followed) = match o { Object::Ref(_) => { held = doc.resolve(o); - let cost = match &*held { - Object::Stream(_) | Object::Ref(_) => return None, - Object::Array(a) => a.len().max(1), - Object::Dict(d) => d.len().max(1), - _ => 1, - }; - *budget = budget.checked_sub(cost)?; + if matches!(&*held, Object::Stream(_) | Object::Ref(_)) { + return None; + } + // Everything copied counts, inner arrays and dictionaries too. + *budget = budget.checked_sub(weight(&held, *budget)?)?; (&*held, true) } other => (other, false), @@ -141,12 +185,32 @@ fn direct_value(doc: &Document, o: &Object, depth: usize, budget: &mut usize) -> } } +/// A built appearance and what storing it needs from the dictionary it was drawn from, read +/// with references followed, so a cached one is stored without following them again. +#[derive(Clone, Debug)] +struct Built { + stream: Stream, + /// The first character the standard fonts can't draw (it came out as `?`). + missing: Option, + /// A note's or attachment's `/Rect`, as written. + rect: Option>, +} + +impl Built { + fn new(stream: Stream, direct: &Dict) -> Self { + let missing = appearance::undrawable_text(direct).map(|(_, c)| c); + let rect = matches!(direct.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) + .then(|| direct.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) + .flatten(); + Built { stream, missing, rect } + } +} + /// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. -fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { - // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. - // Read like the builder read it, references followed. - let direct = with_direct_values(doc, d); - let missing = appearance::undrawable_text(&direct).map(|(_, c)| c); +fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, built: Built) -> Result<(), AnnotError> { + // `missing`: characters the standard fonts can't draw came out as `?`; the annotation is + // marked with the first. + let Built { stream, missing, rect } = built; let ap = doc.add(Object::Stream(stream)); // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover // appearances would show the previous look on press or hover, so they go with the old /N. @@ -157,14 +221,10 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> // A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`. // The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with // no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing. - let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) - .then(|| direct.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) - .flatten() - .filter(|q| q.len() == 4 && finite(q)) - .map(|q| { - let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); - [x, top - NOTE_SIZE, x + NOTE_SIZE, top] - }); + let icon_rect = rect.filter(|q| q.len() == 4 && finite(q)).map(|q| { + let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); + [x, top - NOTE_SIZE, x + NOTE_SIZE, top] + }); doc.update_dict(r, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); if let Some(rect) = icon_rect { @@ -188,7 +248,7 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> /// refreshes) and keep it for content-only ones. #[derive(Clone, Debug, Default)] pub struct AppearanceCache { - entries: std::collections::HashMap, + entries: std::collections::HashMap, bytes: usize, hits: u64, builds: u64, @@ -203,20 +263,21 @@ impl AppearanceCache { *self = Self::default(); } - fn get(&mut self, r: ObjRef) -> Option { - let stream = self.entries.get(&r)?.clone(); + fn get(&mut self, r: ObjRef) -> Option { + let built = self.entries.get(&r)?.clone(); self.hits += 1; - Some(stream) + Some(built) } - fn insert(&mut self, r: ObjRef, stream: &Stream) { + fn insert(&mut self, r: ObjRef, built: &Built) { + let stream = &built.stream; self.builds += 1; if self.entries.len() >= 1_000_000 { self.clear(); } if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES { self.bytes += stream.raw.len(); - self.entries.insert(r, stream.clone()); + self.entries.insert(r, built.clone()); } // Else over budget: keep serving the entries already cached instead of growing. } @@ -250,6 +311,7 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac // reads `doc`, which never gets the appearance, so every repeat used to add another stream to // the copy, and the display bytes grew with the number of listings. let mut seen = std::collections::HashSet::new(); + let mut budget = PASS_BUDGET; for page in page_refs(doc).ok()? { for r in annots(doc, page).iter().filter_map(Object::as_ref) { if !seen.insert(r) { @@ -263,9 +325,9 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac if has_normal { continue; } - if let Some(stream) = cache.get(r) { + if let Some(built) = cache.get(r) { // Fails only when `r` isn't a dictionary, which the cached build ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, built).ok(); continue; } // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its @@ -275,10 +337,12 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac bare.remove(b"AP"); appearance::display_stamp(&bare) }; - let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; - cache.insert(r, &stream); + let direct = with_direct_values_within(doc, &d, &mut budget); + let Some(stream) = build_appearance(doc, &d, &direct).or_else(other_stamp) else { continue }; + let built = Built::new(stream, &direct); + cache.insert(r, &built); // Fails only when `r` isn't a dictionary, which the build just ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, built).ok(); } } copy diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index e451e3009a..3d54140abf 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -233,14 +233,14 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let p = page_ref(doc, page).ok()?; let entry = annots(doc, p).get(index).cloned()?; let obj = doc.resolve(&entry); - let d = obj.as_dict()?; + // Values given by reference (`/C 7 0 R`, `/BS 5 0 R`, ISO 32000-2 §7.3.10) read as the display draws them. + let raw = obj.as_dict()?; + let direct = crate::draw::with_direct_values(doc, raw); + let d = direct.as_ref(); let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { - // A border style given by reference (`/BS 5 0 R`) reads as the display draws it. - let direct = crate::draw::with_direct_values(doc, d); - let d = direct.as_ref(); if subtype == "FreeText" { if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } } else { @@ -255,7 +255,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { width, icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), modified: text_value(doc, d, b"M"), - restylable: appearance::build(d).is_some(), + restylable: crate::draw::can_draw(doc, raw), locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, fillable: fillable(&subtype), fill: fill_of(d), diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 54636d1841..4b454239c2 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -710,7 +710,7 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); // Check before changing anything: a stale appearance would contradict the new style. - if appearance::build(&d).is_none() { + if !crate::draw::can_draw(doc, &d) { return Err(AnnotError::Unsupported(subtype.clone())); } if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { @@ -875,7 +875,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into())); } // Check before changing anything: a stale appearance would contradict the new fill. - if appearance::build(&d).is_none() { + if !crate::draw::can_draw(doc, &d) { return Err(AnnotError::Unsupported(subtype)); } if fill.is_some_and(|c| !finite(&c)) { @@ -916,7 +916,7 @@ pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Opt return Err(AnnotError::Invalid("invalid opacity".into())); } // Check before changing anything: a stale appearance would contradict the new opacity. - if appearance::build(&d).is_none() { + if !crate::draw::can_draw(doc, &d) { return Err(AnnotError::Unsupported(subtype)); } refuse_foreign_text(doc, &d)?; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 28ab7e8fa7..132a69bb02 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1550,6 +1550,49 @@ fn new_width_keeps_an_indirect_border_style() { assert_eq!(shared.as_dict().and_then(|b| b.get(b"W")).and_then(Object::as_f64), Some(6.0), "the shared object is unchanged"); } +/// A colour given by reference (`/C 6 0 R`, §7.3.10) is read by the properties and the edits as +/// the display draws it: the properties show it, the appearance can be restyled and filled. +#[test] +fn an_indirect_colour_reads_and_restyles_as_drawn() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [20 20 180 180] /C 5 0 R /IC 6 0 R >>", + b"[0 0 1]", + b"[1 1 0]", + ]); + let p = props(&doc, 0, 0).expect("props"); + assert_eq!((p.color, p.fill, p.restylable), (Some([0.0, 0.0, 1.0]), Some([1.0, 1.0, 0.0]), true)); + set_style(&mut doc, 0, 0, StyleChange::new(None, None, Some(3.0), None), &meta("")).expect("restylable"); + set_fill(&mut doc, 0, 0, Some([0.0, 1.0, 0.0]), &meta("")).expect("fillable"); + let d = list(&doc, 0).remove(0); + let content = ap_content(&doc, &d); + assert!(content.contains("0 0 1 RG") && content.contains("0 1 0 rg") && content.contains("3 w"), "{content}"); +} + +/// References are followed into everything they point at, and all of it is counted: a referenced +/// array of two arrays of 40,000 numbers is over the per-annotation budget. One display pass +/// copies at most [`crate::draw::PASS_BUDGET`] values in all, so an array many annotations point +/// at is not copied again for each of them. +#[test] +fn following_references_is_bounded_per_value_and_per_pass() { + let mut doc = fixture(); + let inner = Object::Array(vec![Object::Int(0); 40_000]); + let nested = doc.add(Object::Array(vec![inner.clone(), inner])); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Square")); + d.set(b"Junk".to_vec(), Object::Ref(nested)); + let direct = crate::draw::with_direct_values(&doc, &d); + assert!(direct.get(b"Junk") == Some(&Object::Ref(nested)), "80,003 values: left as a reference"); + + let shared = doc.add(Object::Array(vec![Object::Int(0); 60_000])); + d.set(b"Junk".to_vec(), Object::Ref(shared)); + let mut pass = crate::draw::PASS_BUDGET; + let copies = (0..100).filter(|_| matches!(crate::draw::with_direct_values_within(&doc, &d, &mut pass), std::borrow::Cow::Owned(_))).count(); + assert_eq!(copies, crate::draw::PASS_BUDGET / 60_001, "copies stop when the pass budget is spent"); +} + /// `/RD` is optional (ISO 32000-2 Table 177): a callout without it has its text box at `/Rect` /// and is drawn, box, text and leader line. A callout line and `/RD` given by reference are read too. #[test]