M5: properties and edits read referenced colours as drawn; following references is bounded per display pass (view.render-annotation-appearances)

props() reads the whole annotation with references followed, so an
indirect /C or /IC (ISO 32000-2 7.3.10) shows its colour, and
"restylable" and the set_style / set_fill / set_part_opacity checks use
the same build and fallback as the display (draw::can_draw), so such a
comment can be restyled.

Following references now counts every value copied, inner arrays and
dictionaries included, and one display pass copies at most 4M values in
all, so an object many annotations point at is not copied for each of
them without bound. A cached appearance keeps what storing it needs
(the undrawable character and a note's /Rect), so a cache hit follows
no references at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-07 12:51:22 +08:00
1 parent 4bbd3a2626
commit b5e3de67fb
4 files changed
+157 -50

No files matched your search

+106 -42
View File
@@ -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<Stream> {
/// `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<Stream> {
// 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<Dict> = 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<usize> {
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<Object> {
@@ -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<char>,
/// A note's or attachment's `/Rect`, as written.
rect: Option<Vec<f64>>,
}
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::<Option<Vec<f64>>>())
.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::<Option<Vec<f64>>>())
.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<ObjRef, Stream>,
entries: std::collections::HashMap<ObjRef, Built>,
bytes: usize,
hits: u64,
builds: u64,
@@ -203,20 +263,21 @@ impl AppearanceCache {
*self = Self::default();
}
fn get(&mut self, r: ObjRef) -> Option<Stream> {
let stream = self.entries.get(&r)?.clone();
fn get(&mut self, r: ObjRef) -> Option<Built> {
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
+5 -5
View File
@@ -233,14 +233,14 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
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<f64> = 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<Props> {
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),
+3 -3
View File
@@ -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<Rgb>
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)?;
+43
View File
@@ -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]