From 712345d7a1ed2fb794d9dab5b6fa59d8bc632d30 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 07:47:29 +0100 Subject: [PATCH 01/27] M2.6: an edit never rewrites a content stream from the part before its damage A Flate stream damaged partway decodes to what came before the damage, and `DecodeBudget::decode` gave that back as the stream's data: the decoder's partial flag was dropped in `Stream::decoded_within_counted`. Editing a text line, a block, an image or an added item in such a stream wrote it again from that part, so everything after the damage was lost on save, though other readers may still read it. - `DecodeBudget::decode_with_damage` decodes as `decode_within` does and says whether the stream is damaged partway. A missing checksum or end-of-data marker is no damage, as before. - Edit reads the page's streams with it (`ContentStream`), and `rewritten_contents` refuses, before adding anything, a change to a damaged stream. Changes in the page's other streams go on, and the damaged one stays as stored. - Selecting objects (`editable_objects`, moving added items) refuses a page with a damaged stream, as it refuses one that doesn't decode: what follows the damage could change every later item's graphics state. - Marking a tag as an artifact or a figure as decorative refuses when the tag's content is in a damaged stream (`ArtifactError::Damaged`, `AltError::Damaged`), and changes nothing, the structure tree included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/alt.rs | 65 ++++++++++++++++++------- crates/a11y/src/tests.rs | 42 ++++++++++++++++ crates/cos/src/budget.rs | 63 +++++++++++++++++++----- crates/cos/src/object.rs | 15 ++++-- crates/edit/src/background.rs | 4 +- crates/edit/src/images.rs | 45 +++++++---------- crates/edit/src/objects.rs | 23 +++++---- crates/edit/src/tests.rs | 60 +++++++++++++++++++++++ crates/edit/src/text.rs | 4 +- crates/edit/src/text/blocks.rs | 8 +-- crates/edit/src/text/lines.rs | 67 +++++++++++++++++--------- crates/edit/src/text/rewrite.rs | 20 ++++---- crates/edit/src/text_regions/styled.rs | 4 +- 13 files changed, 308 insertions(+), 112 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index fde1048e47..973dc29e33 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -25,6 +25,8 @@ pub enum AltError { NotAFigure(u32), #[error("the page has more content than can be edited")] TooLarge, + #[error("the figure's content is in a content stream damaged partway, which can't be rewritten without losing what follows the damage")] + Damaged, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -52,6 +54,8 @@ pub enum ArtifactError { ParentTree(String), #[error("the page has more content than can be edited")] TooLarge, + #[error("the tag's marked content is in a content stream damaged partway, which can't be rewritten without losing what follows the damage")] + Damaged, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), } @@ -372,7 +376,9 @@ fn content_entries(doc: &Document, page: &Dict) -> Vec { } } -type DecodedStream = (Object, Option<(Stream, Vec)>); +/// A `/Contents` entry and, for a stream, the stream, its decoded data and whether it is damaged +/// partway (the data is then what decoded before the damage). +type DecodedStream = (Object, Option<(Stream, Vec, bool)>); /// Decode the page's content streams within the page decode budget. `None` when a stream is /// rejected because the budget is exhausted. @@ -383,14 +389,14 @@ fn decode_page_streams(doc: &Document, page: &Dict) -> Option for entry in entries { let decoded = match &*doc.resolve(&entry) { Object::Stream(s) => { - let data = match budget.decode(s) { - Ok(data) => data, + let (data, damaged) = match budget.decode_with_damage(s, usize::MAX) { + Ok(read) => read, // Content past the budget may hold more of the tag: editing the rest // would leave that part marked as a tag the tree no longer has. Err(_) if budget.exhausted() => return None, - Err(_) => Vec::new(), + Err(_) => (Vec::new(), false), }; - Some((s.clone(), data)) + Some((s.clone(), data, damaged)) } _ => None, }; @@ -403,6 +409,18 @@ fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &Hash (op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n)) } +/// What [`artifact_marked_content`] did. +#[derive(Debug, PartialEq)] +enum Marked { + /// The streams holding the content were rewritten. + Done, + /// None of the content is on the page, or with `require_all` not all of it: nothing changed. + NotFound, + /// Some of it is in a stream damaged partway, which can't be rewritten without losing what + /// follows the damage: nothing changed. + Damaged, +} + /// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that /// contain them. Other content streams, and unknown entries on a rewritten stream, stay. /// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten. @@ -412,9 +430,9 @@ fn artifact_marked_content( streams: Vec, ids: &HashSet, require_all: bool, -) -> Result { +) -> Result { let props = page_properties(doc, &page.dict); - let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect(); + let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes, _)| bytes.as_slice())).collect(); let pieces = pdfcraft_content::Pieces::join(&decoded); let mut found = HashSet::new(); let mut edits = Vec::new(); @@ -425,12 +443,17 @@ fn artifact_marked_content( } } if if require_all { found != *ids } else { found.is_empty() } { - return Ok(false); + return Ok(Marked::NotFound); } - let mut spliced = pieces.splice(edits).into_iter(); + let spliced = pieces.splice(edits); + // The pieces are the streams that decoded, in order. + if streams.iter().filter_map(|(_, s)| s.as_ref()).zip(&spliced).any(|((_, old, damaged), new)| *damaged && old != new) { + return Ok(Marked::Damaged); + } + let mut spliced = spliced.into_iter(); let mut next = Vec::with_capacity(streams.len()); for (entry, stream) in streams { - let Some((stream, old)) = stream else { + let Some((stream, old, _)) = stream else { next.push(entry); continue; }; @@ -456,7 +479,7 @@ fn artifact_marked_content( _ => Object::Array(next), }; doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?; - Ok(true) + Ok(Marked::Done) } /// Decorative figure: its marked content on the page becomes an artifact, and the element @@ -468,11 +491,15 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page)); if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) { let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?; - // The parent tree no longer points those ids at the figure. - if artifact_marked_content(doc, page, streams, &ids, false)? - && let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) - { - clear_parent_tree(doc, key, obj)?; + match artifact_marked_content(doc, page, streams, &ids, false)? { + // The parent tree no longer points those ids at the figure. + Marked::Done => { + if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { + clear_parent_tree(doc, key, obj)?; + } + } + Marked::NotFound => {} + Marked::Damaged => return Err(AltError::Damaged), } } // Out of its parent's kids. @@ -513,8 +540,10 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?; let replacement = replacement_kids(doc, parent, obj)?; let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?; - if !artifact_marked_content(doc, page, streams, &ids, true)? { - return Err(ArtifactError::ContentNotFound); + match artifact_marked_content(doc, page, streams, &ids, true)? { + Marked::Done => {} + Marked::NotFound => return Err(ArtifactError::ContentNotFound), + Marked::Damaged => return Err(ArtifactError::Damaged), } if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 3024319ec3..b551c2cb42 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1087,6 +1087,48 @@ fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() { assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt); } +/// zlib data that reads as `readable` and is then damaged, with more of the stream after the +/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`. +fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream { + let len = u16::try_from(readable.len()).unwrap(); + let mut raw = vec![0x78, 0x01, 0x00]; + raw.extend_from_slice(&len.to_le_bytes()); + raw.extend_from_slice(&(!len).to_le_bytes()); + raw.extend_from_slice(readable); + raw.push(0x07); + raw.extend_from_slice(rest); + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), pdfcraft_cos::Object::name("FlateDecode")); + pdfcraft_cos::Stream::from_raw(dict, raw) +} + +/// A tag's content in a stream damaged partway stays tagged: rewriting the stream from what +/// decoded would lose what follows the damage. Nothing changes, the structure tree included. +/// Content in a whole stream beside a damaged one is marked, and the damaged one kept as stored. +#[test] +fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() { + use pdfcraft_cos::{ObjRef, Object}; + let content = b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n"; + let damaged = damaged_flate(content, b"0 0 1 rg 0 0 10 10 re f"); + let mut doc = figures_doc(); + doc.set(ObjRef::new(4, 0), damaged.clone()); + let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(); + let (before_contents, before_pt, before_kids) = (contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))); + assert_eq!(figures(&doc).len(), 2, "read up to the damage"); + assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged)); + assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged)); + assert_eq!((contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))), (before_contents, before_pt, before_kids)); + + let mut doc = figures_doc(); + let other = doc.add(damaged_flate(b"0 g\n", b"0 0 1 rg 0 0 10 10 re f")); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))]))) + .unwrap(); + mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap(); + let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) }; + assert_eq!(list.first(), Some(&Object::Ref(other)), "kept as stored"); + assert_ne!(list.get(1), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten"); +} + fn ref_(n: u32) -> Option { Some(pdfcraft_cos::ObjRef::new(n, 0)) } diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 65b750e7b3..3211efb273 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -38,9 +38,9 @@ const JPEG_WORK: u64 = 2; /// and images can share one, so reading it again for each has to count. const JPEG_READ_WORK: u64 = 4; -/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or -/// the error, each with that count. -type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usize)>; +/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data and +/// whether it stopped at damage, or the error, each with that count. +type Decoder = fn(&Stream, usize) -> Result<(Vec, bool, usize), (FilterError, usize)>; /// What one operation over a document's images may still spend decoding them: exporting them, /// optimizing them, and storing LZW streams with Flate as it does, clearing them for redaction. @@ -263,45 +263,56 @@ impl DecodeBudget { } /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without - /// decoding, once the allowance is spent. + /// decoding, once the allowance is spent. A stream damaged partway gives what decoded before + /// the damage, so code that writes the stream back reads it with + /// [`DecodeBudget::decode_with_damage`] instead. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { - self.charge(stream, usize::MAX, Stream::decoded_within_counted) + self.charge(stream, usize::MAX, Stream::decoded_within_counted).map(|(data, _)| data) } /// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding /// past `max` bytes is an error that charges `max`, the work it took to find out. pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result, CosError> { + self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data) + } + + /// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), also saying + /// whether the stream is damaged: `true` when a filter stopped at corrupt data, so the bytes + /// are only what decoded before it. A stream written back from them loses everything after + /// the damage, which other readers may still read. + pub fn decode_with_damage(&mut self, stream: &Stream, max: usize) -> Result<(Vec, bool), CosError> { self.charge(stream, max, Stream::decoded_within_counted) } /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to /// the allowance. pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { - self.charge(stream, usize::MAX, Stream::decoded_strict_within_counted) + let strict: Decoder = |s, max| s.decoded_strict_within_counted(max).map(|(data, spent)| (data, false, spent)); + self.charge(stream, usize::MAX, strict).map(|(data, _)| data) } /// Decodes `stream` within `max` and the allowance, charging what every filter stage /// decoded: a stage's output counts even when a later stage shrinks it, and a corrupt stream /// pays for the bytes decoded before the error, so referencing a stream again and again /// spends the allowance by the work each decode does. - fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result, CosError> { + fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec, bool), CosError> { if self.bytes == 0 && !stream.raw.is_empty() { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } if max < self.bytes { let result = decode(stream, max); let spent = match &result { - Ok((_, spent)) => *spent, + Ok((_, _, spent)) => *spent, Err((FilterError::LimitExceeded(_), _)) => max, Err((_, spent)) => *spent, }; self.bytes = self.bytes.saturating_sub(spent); - return result.map(|(data, _)| data).map_err(|(e, _)| CosError::Filter(e.to_string())); + return result.map(|(data, damaged, _)| (data, damaged)).map_err(|(e, _)| CosError::Filter(e.to_string())); } match decode(stream, self.bytes) { - Ok((data, spent)) => { + Ok((data, damaged, spent)) => { self.bytes = self.bytes.saturating_sub(spent); - Ok(data) + Ok((data, damaged)) } Err((FilterError::LimitExceeded(_), _)) => { self.bytes = 0; @@ -801,6 +812,36 @@ mod tests { assert_eq!(budget.remaining(), (10 << 20) - partial); } + /// A stream damaged partway decodes to what came before the damage, and says so, so that code + /// writing it back can leave it as it is. What every reader takes is no damage: a missing + /// checksum, no filter at all. + #[test] + fn a_stream_damaged_partway_says_so() { + let read = |budget: &mut DecodeBudget, s: &Stream, max| budget.decode_with_damage(s, max).map(|(d, damaged)| (d.len(), damaged)).ok(); + let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]); + let cut = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() / 2).unwrap_or_default().to_vec()); + let partial = cut.decoded().map(|d| d.len()).unwrap_or(0); + assert!(partial > 0, "a cut flate stream decodes in part"); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert_eq!(read(&mut budget, &cut, usize::MAX), Some((partial, true))); + assert_eq!(budget.remaining(), (10 << 20) - partial); + // Within a bound of its own, under what is left. + assert_eq!(read(&mut budget, &cut, 4 << 20), Some((partial, true))); + assert_eq!(budget.decode(&cut).map(|d| d.len()).ok(), Some(partial)); + + let mut budget = DecodeBudget::new(10 << 20, 1); + assert_eq!(read(&mut budget, &whole, usize::MAX), Some((1 << 20, false))); + assert_eq!(read(&mut budget, &whole, 4 << 20), Some((1 << 20, false))); + let unchecked = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() - 4).unwrap_or_default().to_vec()); + assert_eq!(read(&mut budget, &unchecked, usize::MAX), Some((1 << 20, false))); + assert_eq!(read(&mut budget, &Stream::from_raw(Dict::new(), b"0 0 m".to_vec()), usize::MAX), Some((5, false))); + // A later stage's damage is the stream's. + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let hex = Stream::from_raw(dict, Stream::flate(Dict::new(), b"4142zz4344>").raw.to_vec()); + assert_eq!(budget.decode_with_damage(&hex, usize::MAX).ok(), Some((b"AB".to_vec(), true))); + } + /// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it, /// gives nothing back, and each reference still pays for what it decoded. #[test] diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 5d437faa82..bcb188afb1 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -297,13 +297,18 @@ impl Stream { /// [`Stream::decoded_within`] for a caller charging an allowance: the filter error is kept /// typed, so a caller can tell a spent limit from corrupt data, `max` bounds all filter - /// stages together, and the result gives the bytes they decoded (the work done, on success - /// and on failure). - pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> { + /// stages together, and the result gives whether the data stopped at damage (it is then what + /// decoded before it) and the bytes the stages decoded (the work done, on success and on + /// failure). + pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec, bool, usize), (pdfcraft_filters::FilterError, usize)> { + let mut damaged = false; let decode = |chain: &[(Filter, Params)], data: &[u8], max| { - pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, _, spent)| (v, spent)) + pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, partial, spent)| { + damaged = partial; + (v, spent) + }) }; - self.decode_chain(max, decode) + self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent)) } /// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index 376cb5a165..27073ddc09 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -1,7 +1,7 @@ //! A page's background colour: the colour the page is painted with before anything else, as //! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992). -use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_content::{Matrix, Op}; use pdfcraft_cos::{Dict, Document}; use crate::{EditError, Rgb}; @@ -51,7 +51,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); let streams = crate::text::content_streams(doc, &p.dict)?; - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let pieces = crate::text::joined(&streams); let ops = pieces.parse().ops; let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) }; diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 72656fa1bb..201612e5b2 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -4,12 +4,12 @@ //! or delete. Only the stream that draws it is rewritten, as a new object. use kurbo::{BezPath, Point, Shape}; -use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; +use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; -use crate::text::content_streams; +use crate::text::{ContentStream, content_streams, joined}; /// The identity of a soft mask (`/SMask`) in the PDF graphics state. #[derive(Clone, Debug)] @@ -47,7 +47,7 @@ pub struct PageImage { pub width: u32, pub height: u32, /// The content stream its `Do` (its first token) is in, and the `Do`'s index among the - /// operators of the page's streams joined ([`Pieces`]). + /// operators of the page's streams joined ([`pdfcraft_content::Pieces`]). pub stream: usize, op: usize, /// CTM before `Do`, excluding a Form's own Matrix. @@ -103,11 +103,6 @@ fn too_many_images() -> EditError { EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images")) } -/// The page's streams joined into the one stream they are: an operator may span two of them. -fn joined(streams: &[(Object, Vec)]) -> Pieces { - Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()) -} - fn page_of(doc: &Document, page: usize) -> Result { pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page)) } @@ -1191,13 +1186,7 @@ fn build_relocated_unit_ops( } /// Change the stacking order of `img`, drawn on page `p` by its content streams `all`. -fn arrange_image( - doc: &mut Document, - p: &pdfcraft_model::Page, - all: &[(Object, Vec)], - img: &PageImage, - order: ArrangeOrder, -) -> Result<(), EditError> { +fn arrange_image(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStream], img: &PageImage, order: ArrangeOrder) -> Result<(), EditError> { let pieces = joined(all); let ops = pieces.parse().ops; let target = img.op; @@ -1264,7 +1253,7 @@ fn arrange_image( ops.get(dest_unit.start).map(|op| pieces.piece_of(op.span.start)) }; let owned_destination = destination_piece - .filter(|piece| all.get(*piece).is_some_and(|(obj, _)| doc.resolve(obj).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some()))); + .filter(|piece| all.get(*piece).is_some_and(|s| doc.resolve(&s.entry).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some()))); if let Some(piece) = owned_destination { let whole_unit = ops.iter().enumerate().all(|(index, op)| { let (first, last) = pieces.pieces_of(op); @@ -1440,7 +1429,7 @@ fn arrange_image( if let Some(piece) = owned_destination { let changed = pieces.splice([(target_span, removal_replacement)]); - let contents = crate::text::rewritten_contents(doc, all, changed); + let contents = crate::text::rewritten_contents(doc, all, changed)?; let mut list = match contents { Object::Array(list) => list, one => vec![one], @@ -1467,7 +1456,7 @@ fn arrange_image( } let changed = pieces.splice(edits); - let contents = crate::text::rewritten_contents(doc, all, changed); + let contents = crate::text::rewritten_contents(doc, all, changed)?; doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } @@ -1606,7 +1595,7 @@ fn app_crop_ranges(ops: &[Op], start: usize, target: usize) -> Vec)], img: &PageImage) -> Result<(), EditError> { +fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStream], img: &PageImage) -> Result<(), EditError> { let pieces = joined(all); let ops = pieces.parse().ops; let target = img.op; @@ -1619,7 +1608,7 @@ fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[(Objec return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into())); } let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index)))); - let contents = crate::text::rewritten_contents(doc, all, changed); + let contents = crate::text::rewritten_contents(doc, all, changed)?; doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } @@ -1666,7 +1655,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag } }; let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) }); - let contents = crate::text::rewritten_contents(doc, &all, changed); + let contents = crate::text::rewritten_contents(doc, &all, changed)?; doc.update_dict(p.obj, |d| { d.set(b"Contents".to_vec(), contents); if let Some(res) = resources { @@ -1687,7 +1676,7 @@ pub(crate) fn selectable_images( Ok(page_images(doc, page)? .into_iter() .enumerate() - .filter(|(_, image)| !all.get(image.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r))) + .filter(|(_, image)| !all.get(image.stream).and_then(|s| s.entry.as_ref()).is_some_and(|r| excluded.contains(&r))) .collect()) } @@ -1700,7 +1689,7 @@ pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: us fn transform_image( doc: &mut Document, page: &pdfcraft_model::Page, - streams: &[(Object, Vec)], + streams: &[ContentStream], image: &PageImage, transform: [f64; 6], ) -> Result<(), EditError> { @@ -1733,7 +1722,7 @@ fn transform_image( let changed = crate::text::splice(&pieces, &ops, |index| { if index == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], false) } else { (Vec::new(), true) } }); - let contents = crate::text::rewritten_contents(doc, streams, changed); + let contents = crate::text::rewritten_contents(doc, streams, changed)?; doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?; return Ok(()); } @@ -1754,7 +1743,7 @@ fn transform_image( (Vec::new(), true) } }); - let contents = crate::text::rewritten_contents(doc, streams, changed); + let contents = crate::text::rewritten_contents(doc, streams, changed)?; doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } @@ -1766,7 +1755,7 @@ fn transform_image( fn crop_image_impl( doc: &mut Document, page: &pdfcraft_model::Page, - streams: &[(Object, Vec)], + streams: &[ContentStream], image: &PageImage, keep: [f64; 4], ) -> Result<(), EditError> { @@ -1844,7 +1833,7 @@ fn crop_image_impl( (Vec::new(), true) } }); - let contents = crate::text::rewritten_contents(doc, streams, changed); + let contents = crate::text::rewritten_contents(doc, streams, changed)?; doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } @@ -1977,7 +1966,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize out.push(Op::new("Q", vec![])); (out, false) }); - let contents = crate::text::rewritten_contents(doc, &all, data); + let contents = crate::text::rewritten_contents(doc, &all, data)?; doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } diff --git a/crates/edit/src/objects.rs b/crates/edit/src/objects.rs index cdd289457d..dbdbf495ae 100644 --- a/crates/edit/src/objects.rs +++ b/crates/edit/src/objects.rs @@ -4,8 +4,9 @@ use std::collections::{BTreeMap, BTreeSet}; use pdfcraft_content::{Matrix, Op, Pieces}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; +use crate::text::ContentStream; use crate::{Content, EditError}; pub const MAX_MOVE_OBJECTS: usize = 1_000; @@ -152,7 +153,7 @@ fn added_geometry(doc: &Document, page: usize) -> Result<(Pieces, Vec>()); + let pieces = crate::text::joined(&streams); let ops = pieces.parse().ops; let owned: BTreeSet<_> = added.iter().map(|a| a.obj).collect(); let mut geometry = BTreeMap::new(); @@ -161,7 +162,7 @@ fn added_geometry(doc: &Document, page: usize) -> Result<(Pieces, Vec, EditError>>()?; let data = pieces.splice(edits); - let contents = crate::text::rewritten_contents(doc, &streams, data); + let contents = crate::text::rewritten_contents(doc, &streams, data)?; let refs = match &contents { Object::Array(a) => a.clone(), one => vec![one.clone()], @@ -288,9 +289,9 @@ fn translate_added(doc: &mut Document, page: usize, indexes: &[usize], offset: [ Ok(()) } -/// A missing or undecodable preceding stream can hide graphics state that affects every +/// A missing, undecodable or damaged preceding stream can hide graphics state that affects every /// later item. Refuse group editing rather than inventing an inventory from partial data. -fn checked_streams(doc: &Document, page: usize) -> Result)>, EditError> { +fn checked_streams(doc: &Document, page: usize) -> Result, EditError> { let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?; let contents = match p.dict.get(b"Contents") { None => Vec::new(), @@ -305,6 +306,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result)> }, }; let mut remaining = MAX_CONTENT_BYTES; + let mut decode = DecodeBudget::new(usize::MAX, 0); let mut depth = 0usize; let mut operations = 0usize; let mut streams = Vec::with_capacity(contents.len()); @@ -313,9 +315,12 @@ fn checked_streams(doc: &Document, page: usize) -> Result)> let Object::Stream(stream) = &*resolved else { return Err(EditError::Invalid("a page content stream is missing".into())); }; - let bytes = stream - .decoded_within(remaining.min(64 * 1024 * 1024)) + let (bytes, damaged) = decode + .decode_with_damage(stream, remaining.min(64 * 1024 * 1024)) .map_err(|_| EditError::Invalid("the page content cannot be decoded within its size limit".into()))?; + if damaged { + return Err(EditError::Invalid("a page content stream is damaged partway".into())); + } remaining = remaining.checked_sub(bytes.len()).ok_or_else(|| EditError::Invalid("the page content is too large for group selection".into()))?; let parsed = pdfcraft_content::parse(&bytes); @@ -333,7 +338,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result)> return Err(EditError::Invalid("the page graphics state is nested too deeply".into())); } } - streams.push((object, bytes)); + streams.push(ContentStream { entry: object, data: bytes, damaged: false }); } Ok(streams) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 22fbd6e8ae..2f7a310648 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5148,6 +5148,66 @@ fn the_graphics_state_carries_over_between_content_streams() { assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}"); } +/// zlib data that reads as `readable` and is then damaged, with more of the stream after the +/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`. +fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream { + let len = u16::try_from(readable.len()).unwrap(); + let mut raw = vec![0x78, 0x01, 0x00]; + raw.extend_from_slice(&len.to_le_bytes()); + raw.extend_from_slice(&(!len).to_le_bytes()); + raw.extend_from_slice(readable); + raw.push(0x07); + raw.extend_from_slice(rest); + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + pdfcraft_cos::Stream::from_raw(dict, raw) +} + +/// A page whose first content stream is damaged after its text, and whose second is whole. +fn damaged_page() -> (Document, pdfcraft_cos::Stream) { + let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"); + let damaged = damaged_flate(b"BT /F1 12 Tf 72 700 Td (Damaged) Tj ET\n", b"BT /F1 12 Tf 72 500 Td (After the damage) Tj ET"); + doc.set(pdfcraft_cos::ObjRef::new(4, 0), damaged.clone()); + (doc, damaged) +} + +/// An edit that would rewrite a content stream damaged partway is refused: written from what +/// decoded, it would lose what follows the damage, which other readers may still read. An edit +/// in the page's other stream goes on, and leaves the damaged one as it is stored. +#[test] +fn an_edit_never_rewrites_a_content_stream_damaged_partway() { + let (mut doc, damaged) = damaged_page(); + let texts = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + assert_eq!(texts(&doc), ["Damaged", "Whole"], "read up to the damage"); + let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned(); + let listed = contents(&doc); + + let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err(); + assert!(refused.to_string().contains("damaged partway"), "{refused}"); + assert_eq!(contents(&doc), listed); + let refused = text::rewrite_block(&mut doc, 0, 0, Some("Edited"), &text::BlockStyle::default()).unwrap_err(); + assert!(refused.to_string().contains("damaged partway"), "{refused}"); + assert_eq!(contents(&doc), listed); + + text::replace_line(&mut doc, 0, 1, "Edited").unwrap(); + let doc = reopen(&doc); + assert_eq!(texts(&doc), ["Damaged", "Edited"]); + let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) }; + assert_eq!(list.first(), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)))); + let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(4, 0)) else { panic!("not a stream") }; + assert_eq!(kept.raw, damaged.raw, "stored as it was"); +} + +/// Objects are selected only from content read whole: what a damaged stream hides past the +/// damage could change the graphics state of every later item. +#[test] +fn objects_are_not_listed_from_a_content_stream_damaged_partway() { + let (doc, _) = damaged_page(); + let refused = editable_objects(&doc, 0).unwrap_err(); + assert!(refused.to_string().contains("damaged partway"), "{refused}"); + assert!(editable_objects(&split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"), 0).is_ok()); +} + fn two_stream_image_page(first: &str, second: &str) -> Document { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 3d1626a576..01c0acdb70 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -55,7 +55,9 @@ use fallback::{add_type3_font, is_type3_fallback}; pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name}; use interp::{Origin, Paint, union}; use lines::page_dict; -pub(crate) use lines::{FormBudget, content_streams, content_streams_within, dict_in_place, form_call, resource_dict, rewritten_contents, splice}; +pub(crate) use lines::{ + ContentStream, FormBudget, content_streams, content_streams_within, dict_in_place, form_call, joined, resource_dict, rewritten_contents, splice, +}; pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; pub use rewrite::{LineEdit, replace_line, rewrite_block}; use rewrite::{coincident_ops, rewrite_block_from}; diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 48fe956f4f..fd2ca0ed18 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -1,7 +1,7 @@ use super::interp::{Carry, interpret}; -use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines}; +use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, splice, text_lines}; use super::rewrite::{LineEdit, coincident_ops, rewrite_block}; -use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions}; +use super::{Document, EditError, HashMap, Object, Op, TextLine, group_blocks, regions}; /// An editable text region: adjacent runs on visual rows, followed by aligned rows. /// A region may contain several source fonts, sizes, colours and weights. @@ -270,7 +270,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let streams = content_streams(doc, &p.dict)?; - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let pieces = joined(&streams); let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; let mut patches = HashMap::new(); @@ -297,7 +297,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), patches.insert(s.op, replacement); } let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false))); - let contents = rewritten_contents(doc, &streams, data); + let contents = rewritten_contents(doc, &streams, data)?; doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 85209a5472..a28806078b 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -61,16 +61,27 @@ impl TextLine { } } -/// The page's content streams as listed, each with its decoded data (empty when it doesn't -/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading -/// or rewriting a page from part of its content would lose the rest. -pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { +/// One entry of a page's `/Contents`, and what it decoded to. +pub(crate) struct ContentStream { + /// The entry as listed. + pub(crate) entry: Object, + /// Its decoded data, empty when it doesn't decode. + pub(crate) data: Vec, + /// The stream is damaged partway: `data` is what decoded before the damage, so the stream + /// can't be written again from it without losing the rest ([`rewritten_contents`]). + pub(crate) damaged: bool, +} + +/// The page's content streams as listed, each with its decoded data, within one page's +/// [`DecodeBudget`]. An error when the content runs past it: reading or rewriting a page from +/// part of its content would lose the rest. +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result, EditError> { content_streams_within(doc, page, &mut DecodeBudget::page()) } /// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on /// spending. -pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result)>, EditError> { +pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result, EditError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -79,20 +90,25 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D }, }; let mut out = Vec::with_capacity(list.len()); - for o in list { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => match budget.decode(s) { - Ok(data) => data, + for entry in list { + let (data, damaged) = match &*doc.resolve(&entry) { + Object::Stream(s) => match budget.decode_with_damage(s, usize::MAX) { + Ok(read) => read, Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))), - Err(_) => Vec::new(), + Err(_) => (Vec::new(), false), }, - _ => Vec::new(), + _ => (Vec::new(), false), }; - out.push((o, data)); + out.push(ContentStream { entry, data, damaged }); } Ok(out) } +/// The page's streams joined into the one stream they are: an operator may span two of them. +pub(crate) fn joined(streams: &[ContentStream]) -> Pieces { + Pieces::join(&streams.iter().map(|s| s.data.as_slice()).collect::>()) +} + /// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators /// to insert before operator `i` of `ops` (parsed from the joined pieces) and whether to keep it. /// Everything else is copied byte for byte. A page's streams are one stream in pieces, split @@ -121,25 +137,32 @@ pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> } /// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams -/// (keeping their dictionaries), the others stay as they were. -pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec)], new: Vec>) -> Object { +/// (keeping their dictionaries), the others stay as they were. An error, before anything is +/// added, when a piece that changed is a damaged stream: written from what decoded, it would +/// lose everything after the damage. +pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[ContentStream], new: Vec>) -> Result { + if streams.iter().zip(&new).any(|(s, data)| s.damaged && s.data != *data) { + return Err(EditError::Invalid( + "this change is in a content stream that is damaged partway, and rewriting it would lose what follows the damage".into(), + )); + } let mut contents: Vec = Vec::with_capacity(streams.len()); - for ((obj, old), data) in streams.iter().zip(new) { - if *old == data { - contents.push(obj.clone()); + for (s, data) in streams.iter().zip(new) { + if s.data == data { + contents.push(s.entry.clone()); continue; } - let mut dict = match &*doc.resolve(obj) { + let mut dict = match &*doc.resolve(&s.entry) { Object::Stream(s) => s.dict.clone(), _ => Dict::new(), }; dict.remove(b"Length"); contents.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &data))))); } - match <[Object; 1]>::try_from(contents) { + Ok(match <[Object; 1]>::try_from(contents) { Ok([one]) => one, Err(many) => Object::Array(many), - } + }) } pub(crate) fn page_dict(doc: &Document, page: usize) -> Result { @@ -201,7 +224,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> let mut carry = Carry::new(); // The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them // joined, so an operator whose operands end one piece still has them. - let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::>()); + let pieces = joined(&content_streams(doc, &p.dict)?); let ops = pieces.parse().ops; let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, actual_text.then_some(&properties))?; @@ -608,7 +631,7 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec>()); + let pieces = joined(&streams); let ops = pieces.parse().ops; let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0); walk(&mut w, &ops, &res, &mut carry, &stream_of)?; diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index d27d23e0b5..e7fde8545a 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -4,10 +4,10 @@ use super::fallback::{ source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; -use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines}; +use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; use super::{ - Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, alignment_frame, group_blocks, - is_centred, parse, rewrite_styled_block, + Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred, + parse, rewrite_styled_block, }; /// What replacing a line did. @@ -56,7 +56,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let streams = content_streams(doc, &p.dict)?; - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let pieces = joined(&streams); let ops = pieces.parse().ops; let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; // The line's own font, when it can show every character. @@ -127,7 +127,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } // Rebuild: the line's first operator becomes the replacement, its others go. let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); - let contents = rewritten_contents(doc, &streams, new_data); + let contents = rewritten_contents(doc, &streams, new_data)?; let page_ref = p.obj; if substituted.is_some() { res.set(b"Font".to_vec(), Object::Dict(fonts_res)); @@ -834,7 +834,7 @@ pub(crate) fn rewrite_block_from( } let upright = dispatch.upright; let streams = content_streams(doc, &p.dict)?; - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let pieces = joined(&streams); let ops = pieces.parse().ops; let text = paragraph_text(text.unwrap_or(&b.text)); // The characters to show, for choosing fonts: the breaks are moves, not glyphs. @@ -970,7 +970,7 @@ pub(crate) fn rewrite_block_from( let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !dropped.contains(&i)) }); - let contents = rewritten_contents(doc, &streams, new_data); + let contents = rewritten_contents(doc, &streams, new_data)?; let new_font = !reuse; if new_font { res.set(b"Font".to_vec(), Object::Dict(fonts_res)); @@ -1040,7 +1040,7 @@ pub(crate) fn selectable_blocks( !b.lines .iter() .filter_map(|i| lines.get(*i)) - .any(|l| streams.get(l.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r))) + .any(|l| streams.get(l.stream).and_then(|s| s.entry.as_ref()).is_some_and(|r| excluded.contains(&r))) }) .collect()) } @@ -1134,7 +1134,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize } let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let pieces = joined(&streams); let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; let mut patches = HashMap::new(); @@ -1205,7 +1205,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize return Err(EditError::Invalid("a selected text operation cannot be moved".into())); } let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false))); - let contents = rewritten_contents(doc, &streams, data); + let contents = rewritten_contents(doc, &streams, data)?; doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 7a9204a8c0..442913b61b 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -962,7 +962,7 @@ pub(crate) fn rewrite_styled_block( }; let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?; let streams = content_streams(doc, &p.dict)?; - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let pieces = crate::text::joined(&streams); let ops = pieces.parse().ops; // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and // glyph widths stay exactly as they were instead of being rebuilt from the text state. A row @@ -1206,7 +1206,7 @@ pub(crate) fn rewrite_styled_block( // when the rewrite splits one, and a kept row's operators come later in the stream. let marker = if marked { None } else { paragraph_marker(block, lines, &ops) }; let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop); - let contents = rewritten_contents(doc, &streams, data); + let contents = rewritten_contents(doc, &streams, data)?; res.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(p.obj, |d| { d.set(b"Contents".to_vec(), contents); From 480fdb114abcb039aea93828085574b27045e0a5 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 07:55:04 +0100 Subject: [PATCH 02/27] M2.6: Optimize leaves a page damaged partway as it is stored Optimize read a page's content up to any damage in its streams as the whole of it. Merging a page's image strips then wrote the page's content again as one stream, without what followed the damage, and the image pass measured each image by its uses before the damage only, so one drawn larger past it could be resampled below that use. - `DecodeBudget::decode_contents_with_damage` reads a page's `/Contents` as `decode_contents` does, each stream also saying whether it is damaged partway; `decode_contents` is it without the flag. - `content_bytes` gives `None` for a page with a damaged stream, as for one that doesn't decode: the content is not all known. Its strips aren't merged (`merge_page` already leaves such a page alone), and no image is resampled (`effective_resolutions` gives every image `INFINITY`). - A form damaged partway leaves the measuring incomplete in the same way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/cos/src/budget.rs | 28 +++++++++++++- crates/optimize/README.md | 5 ++- crates/optimize/src/images.rs | 13 ++++--- crates/optimize/tests/optimize.rs | 63 +++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 9 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 3211efb273..41dff04ed4 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -348,12 +348,18 @@ impl DecodeBudget { /// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its /// place, so the caller chooses between skipping it and giving up. pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec, CosError>> { + self.decode_contents_with_damage(doc, contents).into_iter().map(|read| read.map(|(data, _)| data)).collect() + } + + /// [`DecodeBudget::decode_contents`], each stream also saying whether it is damaged partway + /// ([`DecodeBudget::decode_with_damage`]). + pub fn decode_contents_with_damage(&mut self, doc: &Document, contents: &Object) -> Vec, bool), CosError>> { match &*doc.resolve(contents) { - Object::Stream(s) => vec![self.decode(s)], + Object::Stream(s) => vec![self.decode_with_damage(s, usize::MAX)], Object::Array(list) => list .iter() .filter_map(|entry| match &*doc.resolve(entry) { - Object::Stream(s) => Some(self.decode(s)), + Object::Stream(s) => Some(self.decode_with_damage(s, usize::MAX)), _ => None, }) .collect(), @@ -842,6 +848,24 @@ mod tests { assert_eq!(budget.decode_with_damage(&hex, usize::MAX).ok(), Some((b"AB".to_vec(), true))); } + /// A page's content streams each say whether they are damaged, in their places. + #[test] + fn a_pages_streams_each_say_whether_they_are_damaged() { + let mut doc = Document::new_empty(); + let whole = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 m 1 1 l S"))); + let Object::Stream(s) = &*doc.get(whole) else { panic!("not a stream") }; + let cut = Stream::from_raw(s.dict.clone(), s.raw.get(..s.raw.len() - 6).unwrap_or_default().to_vec()); + let cut = doc.add(Object::Stream(cut)); + let contents = Object::Array(vec![Object::Ref(whole), Object::Null, Object::Ref(cut)]); + let mut budget = DecodeBudget::page(); + let read: Vec<_> = budget.decode_contents_with_damage(&doc, &contents).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect(); + assert_eq!(read, [Some(false), Some(true)]); + let one: Vec<_> = + budget.decode_contents_with_damage(&doc, &Object::Ref(cut)).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect(); + assert_eq!(one, [Some(true)]); + assert_eq!(budget.decode_contents(&doc, &contents).iter().filter(|r| r.is_ok()).count(), 2, "read up to the damage"); + } + /// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it, /// gives nothing back, and each reference still pays for what it decoded. #[test] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 9acb076908..fd7c60ae85 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -22,11 +22,14 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet: side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary policy, so trailing comments, split operands and graphics state keep their meaning after merging. + A page whose content doesn't decode, or is damaged partway, is left as it is: written again from + what decoded, it would lose the rest. - **Images:** the effective resolution of each image is measured where pages draw it (the CTM through form XObjects, up to 12 deep; the smallest over all uses). Page-local `/UserUnit` scaling is included, so large-format pages are measured in physical inches. If a use can't be - measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a + measured (forms nested deeper or drawn too many times, or content that doesn't decode or is + damaged partway, which may draw an image larger past the damage), no image is resampled. Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A stencil `/Mask` has its own resolution, so the picture changes under it and it stays as it is. diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 159ba04e8c..21f6953a8d 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -32,9 +32,10 @@ fn held_page_resources(doc: &Document, page: ObjRef) -> Option<(Dict, ObjRef)> { } /// The page content `contents` names, decoded against `budget` and joined. `None` when a stream -/// doesn't decode or the budget runs out: the content is not all known. +/// doesn't decode, is damaged partway or the budget runs out: the content is not all known. pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option> { - let pieces = budget.decode_contents(doc, contents).into_iter().collect::, _>>().ok()?; + let reads = budget.decode_contents_with_damage(doc, contents); + let pieces = reads.into_iter().map(|read| read.ok().filter(|(_, damaged)| !damaged).map(|(data, _)| data)).collect::>>()?; Some(Pieces::join(&pieces).data().to_vec()) } @@ -214,7 +215,7 @@ impl Walk<'_> { self.complete = false; return; } - let Ok(data) = self.budget.decode(s) else { + let Ok((data, false)) = self.budget.decode_with_damage(s, usize::MAX) else { self.complete = false; return; }; @@ -245,9 +246,9 @@ fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool { /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. /// -/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within -/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be -/// among those missed. +/// If forms nest too deeply, are drawn too many times, or a page's or form's content doesn't +/// decode within its budget or is damaged partway, every image is given `INFINITY` (never +/// resampled), since its largest use may be among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { walk_pages(doc, pages).0 } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 1387cae528..7250e9a0a0 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3157,6 +3157,69 @@ fn images_that_do_not_tile_a_picture_are_left_alone() { unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() }); } +/// zlib data that reads as `readable` and is then damaged, with more of the stream after the +/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`. +fn damaged_flate(readable: &[u8], rest: &[u8]) -> Stream { + let len = u16::try_from(readable.len()).unwrap(); + let mut raw = vec![0x78, 0x01, 0x00]; + raw.extend_from_slice(&len.to_le_bytes()); + raw.extend_from_slice(&(!len).to_le_bytes()); + raw.extend_from_slice(readable); + raw.push(0x07); + raw.extend_from_slice(rest); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + Stream::from_raw(dict, raw) +} + +/// A page whose content is damaged partway isn't all known: its strips aren't merged, which would +/// write its content again without what follows the damage. +#[test] +fn strips_on_a_page_damaged_partway_are_left_as_they_are() { + let (mut doc, _) = printed_page(40, |_| false, |_, s| s); + let p = pdfcraft_annot::page_refs(&doc).unwrap()[0]; + let bad = damaged_flate(b"0 g\n", b"q 1000 0 0 1 0 0 cm /R0 Do Q"); + let damaged = doc.add(Object::Stream(bad.clone())); + let first = doc.get(p).as_dict().unwrap().get(b"Contents").cloned().unwrap(); + let contents = Object::Array(vec![first, Object::Ref(damaged)]); + doc.update_dict(p, |d| d.set(b"Contents".to_vec(), contents.clone())).unwrap(); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.merged_images, r.fragments_merged), (0, 0), "{r:?}"); + assert_eq!(doc.get(p).as_dict().unwrap().get(b"Contents"), Some(&contents)); + assert_eq!(stream(&doc, damaged), bad); +} + +/// An image may be drawn larger past the damage in a page's or a form's content, so no image is +/// resampled for the uses before it. +#[test] +fn content_damaged_partway_leaves_every_image_at_its_resolution() { + for in_form in [false, true] { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 1, false, None); + let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q"); + assert_eq!(effective_resolutions(&doc, &[p])[&img], 720.0); + let mut bad = damaged_flate(b"q 72 0 0 72 0 0 cm /Im Do Q\n", b"q 720 0 0 720 0 0 cm /Im Do Q"); + let added = if in_form { + bad.dict.set(b"Subtype".to_vec(), Object::name("Form")); + bad.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + bad.dict.set(b"Resources".to_vec(), Object::Dict(resources(xobjects(&[("Im", img)])))); + let f = doc.add(Object::Stream(bad)); + let res = Object::Dict(resources(xobjects(&[("Im", img), ("F", f)]))); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), res)).unwrap(); + content(&mut doc, "/F Do") + } else { + doc.add(Object::Stream(bad)) + }; + let first = doc.get(p).as_dict().unwrap().get(b"Contents").cloned().unwrap(); + doc.update_dict(p, |d| d.set(b"Contents".to_vec(), Object::Array(vec![first, Object::Ref(added)]))).unwrap(); + // Not measured, or measured as never resampled: the image pass reads only what was measured. + let ppi = effective_resolutions(&doc, &[p]).get(&img).copied().unwrap_or(f64::INFINITY); + assert_eq!(ppi, f64::INFINITY, "in a form: {in_form}"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, 0, "{r:?}"); + } +} + /// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square. fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef { let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do"); From 50d0fcc403ff57bba98bf0a3285de692a72bf356 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 07:55:04 +0100 Subject: [PATCH 03/27] M2.6: pruning keeps what a page or form damaged partway may draw Extracting, splitting and combining pages drops the `/XObject` entries no page draws. A page or form damaged partway was read up to the damage as the whole of it, so an image named only after the damage was dropped, though the stream still names it and other readers may still read that far. - `content_bytes` says whether a page's stream is damaged, and pruning keeps everything such a page could reach, as it does for content that doesn't decode. A form is read the same way. - A page made a form XObject (stamps, watermarks, backgrounds from a PDF) still draws what decoded before the damage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/organize/src/import.rs | 5 +++-- crates/organize/src/prune.rs | 29 +++++++++++++++---------- crates/organize/src/tests.rs | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 9f86d6455a..b52c9175b3 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -499,9 +499,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<( let bbox = rect(b"CropBox").or_else(|| rect(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]); let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]); let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360); - // Content: the page's streams, decoded and joined. + // Content: the page's streams, decoded and joined. Of a stream damaged partway, the copy draws + // what decoded before the damage. let content = match own.get(b"Contents") { - Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?, + Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?.0, None => Vec::new(), }; let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index 5a2a319fb4..8683b4d810 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -219,10 +219,10 @@ impl Pruner<'_> { } #[cfg(test)] crate::work(1); - match s.decoded() { - Ok(data) => self.content(&data, &inner, inner_owner, depth + 1), - // What the form draws is unknown: keep everything it could reach. - Err(_) => { + match DecodeBudget::new(usize::MAX, 0).decode_with_damage(s, usize::MAX) { + Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1), + // What the form draws is unknown, past damage too: keep everything it could reach. + Ok((_, true)) | Err(_) => { self.protect_type3_resources(&inner, inner_owner); self.distrust(&inner, inner_owner); } @@ -232,16 +232,20 @@ impl Pruner<'_> { } /// The page's content, concatenated (`/Contents` may be one stream or an array) with a space -/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`]. An +/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`], and +/// whether a stream is damaged partway (its part is then what decoded before the damage). An /// error when a stream can't be decoded or the budget runs out: the page may draw names that /// can't be seen. -pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result, CosError> { +pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<(Vec, bool), CosError> { let mut out = Vec::new(); - for data in DecodeBudget::page().decode_contents(doc, contents) { - out.extend(data?); + let mut damaged = false; + for read in DecodeBudget::page().decode_contents_with_damage(doc, contents) { + let (data, partial) = read?; + damaged |= partial; + out.extend(data); out.push(b' '); } - Ok(out) + Ok((out, damaged)) } /// The resources of a page (as `walk` reports them) with their owner. @@ -324,9 +328,10 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result {} - Ok(data) => pruner.content(&data, &res_dict, owner, 0), - Err(_) => { + Ok((data, false)) if data.is_empty() => {} + Ok((data, false)) => pruner.content(&data, &res_dict, owner, 0), + // What the page draws past damage is unknown too. + Ok((_, true)) | Err(_) => { pruner.protect_type3_resources(&res_dict, owner); pruner.distrust(&res_dict, owner); } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d5ff2116ff..40bb4210d4 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2628,6 +2628,47 @@ fn extract_keeps_images_an_undecodable_page_might_draw() { assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "an image the unreadable page may draw must stay"); } +/// zlib data that reads as `readable` and is then damaged, with more of the stream after the +/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`. +fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream { + let len = u16::try_from(readable.len()).unwrap(); + let mut raw = vec![0x78, 0x01, 0x00]; + raw.extend_from_slice(&len.to_le_bytes()); + raw.extend_from_slice(&(!len).to_le_bytes()); + raw.extend_from_slice(readable); + raw.push(0x07); + raw.extend_from_slice(rest); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + pdfcraft_cos::Stream::from_raw(dict, raw) +} + +/// A page or form damaged partway may draw images past the damage, which other readers may still +/// read: pruning keeps them, as it does for content that doesn't decode. +#[test] +fn extract_keeps_images_content_damaged_partway_might_draw() { + use pdfcraft_cos::ObjRef; + let mut page = doc_images_with(&raw("/ImA Do"), ""); + page.set(ObjRef::new(6, 0), damaged_flate(b"/ImA Do\n", b"/ImB Do")); + + let mut form = doc_images_with(&raw("/Fm Do"), ""); + let mut fm = damaged_flate(b"/ImA Do\n", b"/ImB Do"); + fm.dict.set(b"Subtype".to_vec(), Object::name("Form")); + fm.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + let fm = form.add(fm); + let xobjects: Dict = + [(b"ImA".to_vec(), Object::Ref(ObjRef::new(8, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"Fm".to_vec(), Object::Ref(fm))] + .into_iter() + .collect(); + form.update_dict(ObjRef::new(7, 0), |d| d.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap(); + + for (src, what) in [(page, "page"), (form, "form")] { + let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()); + let names = xobject_names(&part, 0); + assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{what}: an image drawn past the damage must stay: {names:?}"); + } +} + #[test] fn extract_keeps_images_type3_glyphs_may_draw() { // A Type 3 font without its own /Resources draws through the page's: its glyphs could `Do`. From 3fec35e1ea1f910219b9c2f429643d400c73a3e4 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 08:14:47 +0100 Subject: [PATCH 04/27] M2.6: tests pin that content which doesn't decode is kept and edited around A stream that doesn't decode at all is not damage to write over: edit and accessibility keep it as stored and go on with the page's other streams, and only running past the page's decode allowance refuses. Nothing pinned either, nor that selecting objects reads a stream of a few MB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/tests.rs | 17 +++++++++++++++++ crates/edit/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index b551c2cb42..a129f207cd 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1129,6 +1129,23 @@ fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() { assert_ne!(list.get(1), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten"); } +/// A content stream that doesn't decode at all is kept as stored, and a tag in the page's other +/// streams is still marked: only running past the decode budget refuses. +#[test] +fn a_tag_beside_a_stream_that_does_not_decode_is_marked() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut doc = figures_doc(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + let other = doc.add(Object::Stream(Stream::from_raw(dict, b"0 g".to_vec()))); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))]))) + .unwrap(); + mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap(); + let contents = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(); + let Some(Object::Array(list)) = contents else { panic!("contents {contents:?}") }; + assert_eq!(list.first(), Some(&Object::Ref(other)), "kept as stored"); +} + fn ref_(n: u32) -> Option { Some(pdfcraft_cos::ObjRef::new(n, 0)) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 2f7a310648..dc80cb6a29 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5198,6 +5198,26 @@ fn an_edit_never_rewrites_a_content_stream_damaged_partway() { assert_eq!(kept.raw, damaged.raw, "stored as it was"); } +/// A content stream that doesn't decode at all is no damage to write over: it is kept as stored, +/// and the page's other streams are read and edited around it. A stream of a few MB is read for +/// selecting objects. +#[test] +fn a_content_stream_that_does_not_decode_is_kept_and_edited_around() { + let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"); + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + let unknown = pdfcraft_cos::Stream::from_raw(dict, b"BT /F1 12 Tf 72 700 Td (Unread) Tj ET".to_vec()); + doc.set(pdfcraft_cos::ObjRef::new(4, 0), unknown.clone()); + text::replace_line(&mut doc, 0, 0, "Edited").unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(), ["Edited"]); + let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(4, 0)) else { panic!("not a stream") }; + assert_eq!(kept.raw, unknown.raw, "stored as it was"); + + let large = format!("{}0 0 m 10 10 l S", " ".repeat(2 << 20)); + assert!(editable_objects(&split_page(&large, "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"), 0).is_ok()); +} + /// Objects are selected only from content read whole: what a damaged stream hides past the /// damage could change the graphics state of every later item. #[test] From 69e26167e4035d385e905f2563d6b6156266fbf8 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 08:44:05 +0100 Subject: [PATCH 05/27] M2.6: a page's content is read with its /DecodeParms given by reference `decode_with_damage` and `decode_contents_with_damage` read a stream's `/DecodeParms` only when direct, as `Stream::decoded` does, so content behind a predictor whose parameters, or a number in them, are an indirect object decoded with the predictor's tag bytes left in, and was taken as whole. Edit then listed and wrote back the wrong bytes, and pruning dropped an image such a form draws. ISO 32000-2 lets those values be indirect. - `decode_with_damage` takes the document and reads the parameters through it, and so does `decode_contents_with_damage`, and `decode_contents` with it: printing, accessibility's reading of a page and a page copied as a form read them too. Code that calls `decode` on a stream itself doesn't yet. - It reads through `Document::decoding_view`: `with_direct_parms`, but a copy of the stream holds only its `/Filter` and the parameters, its data shared, so a stream a page's `/Contents` lists again and again isn't copied whole for each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/alt.rs | 2 +- crates/cos/src/budget.rs | 68 +++++++++++++++++++++++------- crates/cos/src/document/objects.rs | 33 +++++++++++---- crates/edit/src/objects.rs | 2 +- crates/edit/src/tests.rs | 32 ++++++++++++++ crates/edit/src/text/lines.rs | 2 +- crates/optimize/src/images.rs | 2 +- crates/organize/src/prune.rs | 2 +- crates/organize/src/tests.rs | 31 ++++++++++++++ 9 files changed, 146 insertions(+), 28 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 973dc29e33..dd4b43a641 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -389,7 +389,7 @@ fn decode_page_streams(doc: &Document, page: &Dict) -> Option for entry in entries { let decoded = match &*doc.resolve(&entry) { Object::Stream(s) => { - let (data, damaged) = match budget.decode_with_damage(s, usize::MAX) { + let (data, damaged) = match budget.decode_with_damage(doc, s, usize::MAX) { Ok(read) => read, // Content past the budget may hold more of the tag: editing the rest // would leave that part marked as a tag the tree no longer has. diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 41dff04ed4..d5d67503ee 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -276,12 +276,13 @@ impl DecodeBudget { self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data) } - /// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), also saying - /// whether the stream is damaged: `true` when a filter stopped at corrupt data, so the bytes - /// are only what decoded before it. A stream written back from them loses everything after - /// the damage, which other readers may still read. - pub fn decode_with_damage(&mut self, stream: &Stream, max: usize) -> Result<(Vec, bool), CosError> { - self.charge(stream, max, Stream::decoded_within_counted) + /// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's + /// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged: + /// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it. + /// A stream written back from them loses everything after the damage, which other readers may + /// still read. + pub fn decode_with_damage(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result<(Vec, bool), CosError> { + self.charge(&doc.decoding_view(stream), max, Stream::decoded_within_counted) } /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to @@ -343,23 +344,24 @@ impl DecodeBudget { self.seen.insert(r) } - /// Each stream `contents` names, decoded against the allowance, in order. `contents` is a - /// page's `/Contents`: one stream, or an array whose non-stream entries are left out. - /// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its - /// place, so the caller chooses between skipping it and giving up. + /// Each stream `contents` names, decoded against the allowance, in order, its `/DecodeParms` + /// given by reference read in `doc`. `contents` is a page's `/Contents`: one stream, or an + /// array whose non-stream entries are left out. A stream that fails to decode, or arrives + /// after the allowance is spent, is an `Err` in its place, so the caller chooses between + /// skipping it and giving up. pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec, CosError>> { self.decode_contents_with_damage(doc, contents).into_iter().map(|read| read.map(|(data, _)| data)).collect() } /// [`DecodeBudget::decode_contents`], each stream also saying whether it is damaged partway - /// ([`DecodeBudget::decode_with_damage`]). + /// ([`DecodeBudget::decode_with_damage`], which reads `/DecodeParms` given by reference). pub fn decode_contents_with_damage(&mut self, doc: &Document, contents: &Object) -> Vec, bool), CosError>> { match &*doc.resolve(contents) { - Object::Stream(s) => vec![self.decode_with_damage(s, usize::MAX)], + Object::Stream(s) => vec![self.decode_with_damage(doc, s, usize::MAX)], Object::Array(list) => list .iter() .filter_map(|entry| match &*doc.resolve(entry) { - Object::Stream(s) => Some(self.decode_with_damage(s, usize::MAX)), + Object::Stream(s) => Some(self.decode_with_damage(doc, s, usize::MAX)), _ => None, }) .collect(), @@ -823,7 +825,8 @@ mod tests { /// checksum, no filter at all. #[test] fn a_stream_damaged_partway_says_so() { - let read = |budget: &mut DecodeBudget, s: &Stream, max| budget.decode_with_damage(s, max).map(|(d, damaged)| (d.len(), damaged)).ok(); + let doc = Document::new_empty(); + let read = |budget: &mut DecodeBudget, s: &Stream, max| budget.decode_with_damage(&doc, s, max).map(|(d, damaged)| (d.len(), damaged)).ok(); let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]); let cut = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() / 2).unwrap_or_default().to_vec()); let partial = cut.decoded().map(|d| d.len()).unwrap_or(0); @@ -845,7 +848,42 @@ mod tests { let mut dict = Dict::new(); dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); let hex = Stream::from_raw(dict, Stream::flate(Dict::new(), b"4142zz4344>").raw.to_vec()); - assert_eq!(budget.decode_with_damage(&hex, usize::MAX).ok(), Some((b"AB".to_vec(), true))); + assert_eq!(budget.decode_with_damage(&doc, &hex, usize::MAX).ok(), Some((b"AB".to_vec(), true))); + } + + /// `/DecodeParms` given by reference, or a parameter given by reference in them, is read: a + /// predicted stream decodes as with them direct. Of the stream, decoding copies no more than + /// its filters and their parameters. + #[test] + fn parameters_given_by_reference_are_read() { + let content = b"q 10 0 0 10 0 0 cm /Im Do Q"; + // PNG Up, one column: each byte a row of its own, tagged 2. + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in content { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let raw = Stream::flate(Dict::new(), &rows).raw; + let mut doc = Document::new_empty(); + let direct: Dict = [(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect(); + let by_reference = Object::Ref(doc.add(Object::Dict(direct.clone()))); + let predictor = doc.add(Object::Int(12)); + let inner = Object::Dict([(b"Predictor".to_vec(), Object::Ref(predictor))].into_iter().collect()); + for parms in [Object::Dict(direct), by_reference, inner] { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + dict.set(b"DecodeParms".to_vec(), parms.clone()); + dict.set(b"Other".to_vec(), Object::Array(vec![Object::Int(0); 1000])); + let s = Stream::from_raw(dict, raw.to_vec()); + assert_eq!(DecodeBudget::page().decode_with_damage(&doc, &s, usize::MAX).ok(), Some((content.to_vec(), false)), "{parms:?}"); + let r = doc.add(Object::Stream(s.clone())); + let read = DecodeBudget::page().decode_contents_with_damage(&doc, &Object::Array(vec![Object::Ref(r)])); + assert_eq!(read, [Ok((content.to_vec(), false))], "{parms:?}"); + assert_eq!(DecodeBudget::page().decode_contents(&doc, &Object::Ref(r)), [Ok(content.to_vec())], "{parms:?}"); + let view = doc.decoding_view(&s); + assert!(matches!(view, std::borrow::Cow::Borrowed(_)) || view.dict.len() == 2, "{:?}", view.dict); + } } /// A page's content streams each say whether they are damaged, in their places. diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 458840de3a..7ce9474018 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -288,7 +288,29 @@ impl Document { /// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a /// large object copied once for each key and filter. pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> { - let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) }; + let Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) }; + let mut s = s.clone(); + s.dict.set(b"DecodeParms".to_vec(), made); + Cow::Owned(s) + } + + /// [`Document::with_direct_parms`] for decoding alone: where that copies `s`, this makes a + /// stream of `s`'s data, shared, and a dictionary of its `/Filter` and the parameters made + /// direct, all its filters read. A stream named again and again, as a page's `/Contents` may + /// list one, isn't copied whole each time. + pub(crate) fn decoding_view<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> { + let Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) }; + let mut dict = Dict::new(); + if let Some(f) = s.dict.get(b"Filter") { + dict.set(b"Filter".to_vec(), f.clone()); + } + dict.set(b"DecodeParms".to_vec(), made); + Cow::Owned(Stream { dict, raw: s.raw.clone() }) + } + + /// The `/DecodeParms` [`Document::with_direct_parms`] gives `s`, or `None` when it keeps `s`. + fn direct_parms(&self, s: &Stream) -> Option { + let p = s.dict.get(b"DecodeParms")?; let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt")); let filters: Vec = match s.dict.get(b"Filter") { Some(Object::Array(a)) => a.iter().map(crypt).collect(), @@ -296,7 +318,7 @@ impl Document { None => Vec::new(), }; if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN { - return Cow::Borrowed(s); + return None; } let mut changed = matches!(p, Object::Ref(_)); // Its numbers and flags kept, resolved; anything else is no filter's parameters (null). @@ -323,12 +345,7 @@ impl Document { } _ => one(p, &mut changed), }; - if !changed { - return Cow::Borrowed(s); - } - let mut s = s.clone(); - s.dict.set(b"DecodeParms".to_vec(), made); - Cow::Owned(s) + changed.then_some(made) } /// `o`, or the object it refers to, without copying either. diff --git a/crates/edit/src/objects.rs b/crates/edit/src/objects.rs index dbdbf495ae..c0a57c358a 100644 --- a/crates/edit/src/objects.rs +++ b/crates/edit/src/objects.rs @@ -316,7 +316,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result, Ed return Err(EditError::Invalid("a page content stream is missing".into())); }; let (bytes, damaged) = decode - .decode_with_damage(stream, remaining.min(64 * 1024 * 1024)) + .decode_with_damage(doc, stream, remaining.min(64 * 1024 * 1024)) .map_err(|_| EditError::Invalid("the page content cannot be decoded within its size limit".into()))?; if damaged { return Err(EditError::Invalid("a page content stream is damaged partway".into())); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index dc80cb6a29..df9bce657d 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5218,6 +5218,38 @@ fn a_content_stream_that_does_not_decode_is_kept_and_edited_around() { assert!(editable_objects(&split_page(&large, "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"), 0).is_ok()); } +/// A content stream whose `/DecodeParms`, or a parameter in them, is given by reference is read +/// through the reference: its text is listed and edited, not read with the predictor's tag bytes +/// left in it. +#[test] +fn content_with_parameters_given_by_reference_is_read_and_edited() { + use pdfcraft_cos::{Dict, ObjRef, Stream}; + let content = b"BT /F1 12 Tf 72 700 Td (Predicted) Tj ET"; + // PNG Up, one column: each byte a row of its own, tagged 2. + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in content { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let texts = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + for given in ["parameters", "predictor"] { + let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"); + let predictor = Object::Int(12); + let parms = if given == "parameters" { + Object::Ref(doc.add(Object::Dict([(b"Predictor".to_vec(), predictor)].into_iter().collect::()))) + } else { + Object::Dict([(b"Predictor".to_vec(), Object::Ref(doc.add(predictor)))].into_iter().collect()) + }; + let mut s = Stream::flate(Dict::new(), &rows); + s.dict.set(b"DecodeParms".to_vec(), parms); + doc.set(ObjRef::new(4, 0), s); + assert_eq!(texts(&doc), ["Predicted", "Whole"], "{given} by reference"); + text::replace_line(&mut doc, 0, 0, "Edited").unwrap(); + assert_eq!(texts(&reopen(&doc)), ["Edited", "Whole"], "{given} by reference"); + } +} + /// Objects are selected only from content read whole: what a damaged stream hides past the /// damage could change the graphics state of every later item. #[test] diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index a28806078b..bf66cc0f8b 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -92,7 +92,7 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D let mut out = Vec::with_capacity(list.len()); for entry in list { let (data, damaged) = match &*doc.resolve(&entry) { - Object::Stream(s) => match budget.decode_with_damage(s, usize::MAX) { + Object::Stream(s) => match budget.decode_with_damage(doc, s, usize::MAX) { Ok(read) => read, Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))), Err(_) => (Vec::new(), false), diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 21f6953a8d..6ad6413696 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -215,7 +215,7 @@ impl Walk<'_> { self.complete = false; return; } - let Ok((data, false)) = self.budget.decode_with_damage(s, usize::MAX) else { + let Ok((data, false)) = self.budget.decode_with_damage(doc, s, usize::MAX) else { self.complete = false; return; }; diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index 8683b4d810..591e804b1e 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -219,7 +219,7 @@ impl Pruner<'_> { } #[cfg(test)] crate::work(1); - match DecodeBudget::new(usize::MAX, 0).decode_with_damage(s, usize::MAX) { + match DecodeBudget::new(usize::MAX, 0).decode_with_damage(self.doc, s, usize::MAX) { Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1), // What the form draws is unknown, past damage too: keep everything it could reach. Ok((_, true)) | Err(_) => { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 40bb4210d4..8d41f474cc 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2669,6 +2669,37 @@ fn extract_keeps_images_content_damaged_partway_might_draw() { } } +/// A form whose `/DecodeParms` is given by reference is read through it: the image it draws is +/// kept, not lost to content read with the predictor's tag bytes left in it. +#[test] +fn extract_keeps_images_a_form_with_parameters_by_reference_draws() { + use pdfcraft_cos::ObjRef; + let content = b"q 10 0 0 10 0 0 cm /ImB Do Q"; + // PNG Up, one column: each byte a row of its own, tagged 2. + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in content { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let mut src = doc_images_with(&raw("/Fm Do"), ""); + let parms = src.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::())); + let mut fm = pdfcraft_cos::Stream::flate(Dict::new(), &rows); + fm.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + fm.dict.set(b"Subtype".to_vec(), Object::name("Form")); + fm.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + let fm = src.add(fm); + let xobjects: Dict = + [(b"ImA".to_vec(), Object::Ref(ObjRef::new(8, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"Fm".to_vec(), Object::Ref(fm))] + .into_iter() + .collect(); + src.update_dict(ObjRef::new(7, 0), |d| d.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap(); + let part = full_roundtrip(&extract_pages(&src, &[1]).unwrap()); + let names = xobject_names(&part, 0); + assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{names:?}"); + assert!(!names.iter().any(|n| n.as_slice() == b"ImA"), "what the part doesn't draw still goes: {names:?}"); +} + #[test] fn extract_keeps_images_type3_glyphs_may_draw() { // A Type 3 font without its own /Resources draws through the page's: its glyphs could `Do`. From 3bf6c6f027f5bad86a4a16fccf955b87d449266e Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 08:44:13 +0100 Subject: [PATCH 06/27] M2.6: marking content as an artifact refuses what may be past the damage A tag's content not found before the damage in a damaged stream may be past it. Marking a figure decorative took it for absent: it succeeded, took the figure out of the structure tree and left its content tagged. Marking a tag as an artifact said its content wasn't on the page. Both now refuse with `Damaged`, as when the content is before the damage, and change nothing. Clearing the parent tree read the page's `/StructParents` only when direct, so with it given by reference (ISO 32000-2 lets it be) the tree went on pointing the content at the retired tag. It is read through the reference. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/alt.rs | 11 ++++++++--- crates/a11y/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index dd4b43a641..bdc72d2e33 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -417,7 +417,7 @@ enum Marked { /// None of the content is on the page, or with `require_all` not all of it: nothing changed. NotFound, /// Some of it is in a stream damaged partway, which can't be rewritten without losing what - /// follows the damage: nothing changed. + /// follows the damage, or may be past the damage: nothing changed. Damaged, } @@ -442,6 +442,11 @@ fn artifact_marked_content( edits.push((op.span, b"/Artifact BMC".to_vec())); } } + // Content not found may be past the damage in a damaged stream: whether it is on the page + // isn't known. + if found != *ids && streams.iter().any(|(_, s)| s.as_ref().is_some_and(|(_, _, damaged)| *damaged)) { + return Ok(Marked::Damaged); + } if if require_all { found != *ids } else { found.is_empty() } { return Ok(Marked::NotFound); } @@ -494,7 +499,7 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> match artifact_marked_content(doc, page, streams, &ids, false)? { // The parent tree no longer points those ids at the figure. Marked::Done => { - if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { + if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) { clear_parent_tree(doc, key, obj)?; } } @@ -545,7 +550,7 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact Marked::NotFound => return Err(ArtifactError::ContentNotFound), Marked::Damaged => return Err(ArtifactError::Damaged), } - if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { + if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) { clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?; } doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a129f207cd..78a6d96589 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1129,6 +1129,42 @@ fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() { assert_ne!(list.get(1), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten"); } +/// A tag's content not found before the damage in a damaged stream may be past it: it isn't known +/// to be off the page, so neither marking changes anything. +#[test] +fn a_tags_content_past_the_damage_is_not_taken_for_absent() { + use pdfcraft_cos::ObjRef; + let damaged = + damaged_flate(b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n", b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC"); + let mut doc = figures_doc(); + doc.set(ObjRef::new(4, 0), damaged); + let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(); + let before = (contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))); + assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged)); + assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged)); + assert_eq!((contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))), before); +} + +/// A page's `/StructParents` given by reference is read through it: the parent tree no longer +/// points the marked content at the tag. +#[test] +fn struct_parents_given_by_reference_are_read() { + use pdfcraft_cos::{ObjRef, Object}; + let by_reference = || { + let mut doc = figures_doc(); + let key = doc.add(Object::Int(0)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"StructParents".to_vec(), Object::Ref(key))).unwrap(); + doc + }; + let nums = |doc: &Document| doc.get(ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned()); + let mut doc = by_reference(); + mark_decorative(&mut doc, ObjRef::new(7, 0)).unwrap(); + assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Null, Object::Ref(ObjRef::new(8, 0))])]))); + let mut doc = by_reference(); + mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).unwrap(); + assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Null])]))); +} + /// A content stream that doesn't decode at all is kept as stored, and a tag in the page's other /// streams is still marked: only running past the decode budget refuses. #[test] From 91968610c35dc20189f1ebb0c27d23eec8ba50bb Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 12:19:30 +0100 Subject: [PATCH 07/27] M2.6: redaction reads content with its /DecodeParms given by reference `DecodeBudget::decode_strict` read a stream's `/DecodeParms` only when direct, so content behind a predictor whose parameters are an indirect object decoded with the predictor's tag bytes left in. Redaction then found no glyph under a mark, left the text in the file and reported the mark applied. ISO 32000-2 lets those parameters be indirect. `decode_strict` takes the document and reads through `decoding_view`, as `decode_with_damage` does. Its other readers read the parameters too: Remove Hidden Information's hidden text and layers, the audit's covered-text check, and edit's backdrop scan. The accessibility_mark_tag_artifact description says that content which is, or may be, in a stream damaged partway is refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/audit/src/cover.rs | 4 +-- crates/automation/src/tools.rs | 2 +- crates/cos/src/budget.rs | 14 +++++----- crates/edit/src/backdrop.rs | 8 +++--- crates/redact/src/interp.rs | 2 +- crates/redact/src/lib.rs | 2 +- crates/redact/src/tests.rs | 48 ++++++++++++++++++++++++++++++++++ 7 files changed, 65 insertions(+), 15 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index bb5adf7ff8..9f771f7040 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -283,7 +283,7 @@ impl<'a> Walker<'a> { return Err(AuditError::TooManyForms); } // A form stream that cannot be decoded fails the audit: it might hide a cover. - let data = self.budget.decode_strict(stream)?; + let data = self.budget.decode_strict(self.doc, stream)?; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); sub_gs.ctm = matrix.then(&gs.ctm); @@ -326,7 +326,7 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi let mut streams = Vec::with_capacity(contents.len()); for c in &contents { if let Object::Stream(s) = &*doc.resolve(c) { - streams.push(budget.decode_strict(s)?); + streams.push(budget.decode_strict(doc, s)?); } } let pieces = Pieces::join(&streams); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index af5146377a..e69a57dc33 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1285,7 +1285,7 @@ pub fn tools() -> Vec { "property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] }, "value": { "type": "string" }, }), &["doc", "object", "generation", "property"])), - t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.") + t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that is, or may be, in a content stream damaged partway are rejected. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index d5d67503ee..51b05e034d 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -286,10 +286,10 @@ impl DecodeBudget { } /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to - /// the allowance. - pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { + /// the allowance, with its `/DecodeParms` given by reference read in `doc`. + pub fn decode_strict(&mut self, doc: &Document, stream: &Stream) -> Result, CosError> { let strict: Decoder = |s, max| s.decoded_strict_within_counted(max).map(|(data, spent)| (data, false, spent)); - self.charge(stream, usize::MAX, strict).map(|(data, _)| data) + self.charge(&doc.decoding_view(stream), usize::MAX, strict).map(|(data, _)| data) } /// Decodes `stream` within `max` and the allowance, charging what every filter stage @@ -788,7 +788,7 @@ mod tests { assert_eq!(budget.remaining(), 0); let mut budget = DecodeBudget::new((1 << 20) - 1, 1); let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") }; - assert!(budget.decode_strict(stream).is_err()); + assert!(budget.decode_strict(&doc, stream).is_err()); assert_eq!(budget.remaining(), 0); let mut budget = DecodeBudget::new(10 << 20, 1); @@ -812,8 +812,9 @@ mod tests { let corrupt = Stream::from_raw(whole.dict.clone(), whole.raw.get(..cut).unwrap_or_default().to_vec()); let partial = corrupt.decoded().map(|d| d.len()).unwrap_or(0); assert!(partial > 0, "a cut flate stream decodes in part"); + let doc = Document::new_empty(); let mut budget = DecodeBudget::new(10 << 20, 1); - assert!(budget.decode_strict(&corrupt).is_err()); + assert!(budget.decode_strict(&doc, &corrupt).is_err()); assert_eq!(budget.remaining(), (10 << 20) - partial); let mut budget = DecodeBudget::new(10 << 20, 1); assert!(budget.decode_within(&corrupt, 1 << 30).is_ok()); @@ -877,6 +878,7 @@ mod tests { dict.set(b"Other".to_vec(), Object::Array(vec![Object::Int(0); 1000])); let s = Stream::from_raw(dict, raw.to_vec()); assert_eq!(DecodeBudget::page().decode_with_damage(&doc, &s, usize::MAX).ok(), Some((content.to_vec(), false)), "{parms:?}"); + assert_eq!(DecodeBudget::page().decode_strict(&doc, &s).ok(), Some(content.to_vec()), "{parms:?}"); let r = doc.add(Object::Stream(s.clone())); let read = DecodeBudget::page().decode_contents_with_damage(&doc, &Object::Array(vec![Object::Ref(r)])); assert_eq!(read, [Ok((content.to_vec(), false))], "{parms:?}"); @@ -935,7 +937,7 @@ mod tests { assert_eq!(budget.remaining(), (10 << 20) - charged); assert!(budget.decode_within(&chained, 2 << 20).is_ok()); assert_eq!(budget.remaining(), (10 << 20) - 2 * charged); - assert!(budget.decode_strict(&chained).is_ok()); + assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok()); assert_eq!(budget.remaining(), (10 << 20) - 3 * charged); } } diff --git a/crates/edit/src/backdrop.rs b/crates/edit/src/backdrop.rs index 43e16b40ec..f6788bb51c 100644 --- a/crates/edit/src/backdrop.rs +++ b/crates/edit/src/backdrop.rs @@ -208,7 +208,7 @@ pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result let same = |w: &ObjRef| match &*doc.get(*w) { Object::Stream(s) => { [TAG, &b"PCMark"[..], b"Subtype"].iter().all(|k| s.dict.get(k).is_none()) - && DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok().as_deref() == Some(bytes.as_slice()) + && DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok().as_deref() == Some(bytes.as_slice()) } _ => false, }; @@ -248,7 +248,7 @@ fn restored_copy(doc: &Document, page: &pdfcraft_model::Page, copy: ObjRef) -> O return None; } let hash = note.get(b"Before")?.as_string()?; - let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok()?; + let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok()?; let parsed = pdfcraft_content::parse(head(&bytes)); // The `n` standing in for the fill, after exactly the operators that came before it. let fill = parsed.ops.get(index)?; @@ -384,11 +384,11 @@ fn scan(doc: &Document, p: &pdfcraft_model::Page, swap: Option<(ObjRef, &[u8])>) if b.len() > SCAN_BYTES.saturating_sub(total) { return None; } - budget.decode_strict(&Stream::from_raw(Dict::new(), b.to_vec())).ok()? + budget.decode_strict(doc, &Stream::from_raw(Dict::new(), b.to_vec())).ok()? } // Tolerant decoding can return an empty or partial stream after corruption. // That cannot prove which paint preceded the candidate backdrop. - _ => budget.decode_strict(s).ok()?, + _ => budget.decode_strict(doc, s).ok()?, }; if bytes.len() > SCAN_BYTES.saturating_sub(total).saturating_sub(1) { return None; diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index b0a7257cb3..4cb95c81d8 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -979,7 +979,7 @@ fn xobject( } // Strict, like page content: a Form read only in part counts as undecodable. let data = if scope.budget.enter_form() { - scope.budget.decode_strict(s) + scope.budget.decode_strict(doc, s) } else { Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects"))) }; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6d323004ad..8785cb357a 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -290,7 +290,7 @@ pub(crate) fn page_streams_within( match &*doc.resolve(o) { // Strict: a tolerant decode keeps what precedes corruption and drops the rest, which // still draws in viewers that read further; anything not read can't be cleaned. - Object::Stream(s) => data.push(budget.decode_strict(s).map_err(|_| RedactError::Unreadable(index + 1))?), + Object::Stream(s) => data.push(budget.decode_strict(doc, s).map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8978e0e7d6..c98a21c14f 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -81,6 +81,54 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() { assert!(!content(&doc, 0).contains("1234")); } +/// Content whose `/DecodeParms` is given by reference is read through the reference, on the page +/// and in a form: the text under a mark goes. Read with the predictor's tag bytes left in, no glyph +/// was found under the mark, and the redaction left the text in the file and said it was done. +#[test] +fn content_with_parameters_given_by_reference_is_redacted() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + // PNG Up, one column: each byte a row of its own, tagged 2. + let predicted = |doc: &mut Document, mut dict: Dict| { + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET" { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let parms = doc.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::())); + dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + let mut s = Stream::flate(Dict::new(), &rows); + for (k, v) in dict.iter() { + s.dict.set(k.to_vec(), v.clone()); + } + s + }; + let marked = [[5.0, 90.0, 120.0, 115.0]]; + let mut page = one_page(b"", "", vec![]); + let s = predicted(&mut page, Dict::new()); + page.set(ObjRef::new(4, 0), s); + // The parameters take object 7 and the form 8. + let mut form = one_page(b"/Fm Do", "/XObject << /Fm 8 0 R >>", vec![]); + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Form")); + dict.set(b"BBox".to_vec(), Object::Array([0, 0, 300, 300].into_iter().map(Object::Int).collect())); + dict.set( + b"Resources".to_vec(), + Object::Dict( + [(b"Font".to_vec(), Object::Dict([(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0)))].into_iter().collect()))].into_iter().collect(), + ), + ); + let s = predicted(&mut form, dict); + form.set(ObjRef::new(8, 0), s); + for (mut doc, what) in [(page, "page"), (form, "form")] { + assert_eq!(under(&mut doc, 0, &marked), 6, "{what}"); + mark(&mut doc, 0, &marked, ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.marks, r.glyphs), (1, 6), "{what}"); + assert_eq!(under(&mut reopen(&doc), 0, &marked), 0, "{what}"); + } +} + /// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a /// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to /// lie past a mark that covers all of it on screen, and was kept. From 2d672d951754dc2b69fa5819a55274ce091bcc13 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 12:45:11 +0100 Subject: [PATCH 08/27] M2.6: redaction keeps an image the page still draws behind parameters by reference Once a redaction removes an image or form under a mark, it takes the name out of the page's resources unless the page's content still draws it elsewhere. It read the page's streams again for that with `decoded`, which reads `/DecodeParms` only when direct, so a stream behind a predictor whose parameters are an indirect object seemed not to draw it, and the page lost the image there. It now looks in what it read for the redaction, with the parameters by reference, and decodes again only the streams it has just written, which have none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/lib.rs | 7 +++- crates/redact/src/tests.rs | 68 +++++++++++++++++++++++++++----------- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 8785cb357a..db729715f1 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -820,7 +820,12 @@ fn redact_page_content( // longer draws so the removed image or form bytes cannot survive in the saved file. let page_content: Vec> = new_list .iter() - .map(|o| match &*doc.resolve(o) { + .zip(&list) + .zip(&data) + .map(|((now, was), read)| match &*doc.resolve(now) { + // Left as it was: what was read above, with its parameters by reference. + _ if now == was => read.clone(), + // Written above by `Stream::flate`, which leaves no `/DecodeParms`. Object::Stream(s) => s.decoded().unwrap_or_default(), _ => Vec::new(), }) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c98a21c14f..2de12ba374 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -81,31 +81,36 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() { assert!(!content(&doc, 0).contains("1234")); } +/// `content` behind a PNG Up predictor whose `/DecodeParms` is an indirect object added to `doc`, +/// in a stream with `dict`'s entries too. +fn predicted(doc: &mut Document, dict: pdfcraft_cos::Dict, content: &[u8]) -> pdfcraft_cos::Stream { + use pdfcraft_cos::{Dict, Object, Stream}; + // One column: each byte a row of its own, tagged 2. + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in content { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let parms = doc.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::())); + let mut s = Stream::flate(Dict::new(), &rows); + for (k, v) in dict.iter() { + s.dict.set(k.to_vec(), v.clone()); + } + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s +} + /// Content whose `/DecodeParms` is given by reference is read through the reference, on the page /// and in a form: the text under a mark goes. Read with the predictor's tag bytes left in, no glyph /// was found under the mark, and the redaction left the text in the file and said it was done. #[test] fn content_with_parameters_given_by_reference_is_redacted() { - use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; - // PNG Up, one column: each byte a row of its own, tagged 2. - let predicted = |doc: &mut Document, mut dict: Dict| { - let mut rows = Vec::new(); - let mut above = 0u8; - for &b in b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET" { - rows.extend([2, b.wrapping_sub(above)]); - above = b; - } - let parms = doc.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::())); - dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); - let mut s = Stream::flate(Dict::new(), &rows); - for (k, v) in dict.iter() { - s.dict.set(k.to_vec(), v.clone()); - } - s - }; + use pdfcraft_cos::{Dict, ObjRef, Object}; + let secret = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET"; let marked = [[5.0, 90.0, 120.0, 115.0]]; let mut page = one_page(b"", "", vec![]); - let s = predicted(&mut page, Dict::new()); + let s = predicted(&mut page, Dict::new(), secret); page.set(ObjRef::new(4, 0), s); // The parameters take object 7 and the form 8. let mut form = one_page(b"/Fm Do", "/XObject << /Fm 8 0 R >>", vec![]); @@ -118,7 +123,7 @@ fn content_with_parameters_given_by_reference_is_redacted() { [(b"Font".to_vec(), Object::Dict([(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0)))].into_iter().collect()))].into_iter().collect(), ), ); - let s = predicted(&mut form, dict); + let s = predicted(&mut form, dict, secret); form.set(ObjRef::new(8, 0), s); for (mut doc, what) in [(page, "page"), (form, "form")] { assert_eq!(under(&mut doc, 0, &marked), 6, "{what}"); @@ -129,6 +134,31 @@ fn content_with_parameters_given_by_reference_is_redacted() { } } +/// An image removed under a mark stays in the page's resources while another of its content +/// streams, with `/DecodeParms` given by reference, still draws it. Read without the parameters, +/// that stream seemed not to draw it, and the page lost the image there. +#[test] +fn an_image_drawn_again_in_content_with_parameters_by_reference_stays() { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [255, 255, 255, 255]); + let mut doc = one_page(b"q 10 0 0 10 10 10 cm /Im1 Do Q", "/XObject << /Im1 7 0 R >>", vec![img]); + let again = predicted(&mut doc, Dict::new(), b"q 10 0 0 10 200 200 cm /Im1 Do Q"); + let again = doc.add(Object::Stream(again)); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(again)]))) + .unwrap(); + mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let doc = reopen(&doc); + assert!(content(&doc, 0).contains("200 200 cm /Im1 Do"), "{}", content(&doc, 0)); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + // The save numbers objects afresh: the image is told by what it is. + let Some(Object::Stream(image)) = xo.reference(b"Im1").map(|r| (*doc.get(r)).clone()) else { panic!("no /Im1: {xo:?}") }; + assert_eq!((image.dict.name(b"Subtype"), image.decoded().ok()), (Some(&b"Image"[..]), Some(vec![255; 4]))); +} + /// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a /// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to /// lie past a mark that covers all of it on screen, and was kept. From ebf9c7a3ce19eb71d58dc723181914f843dfb56d Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 13:07:06 +0100 Subject: [PATCH 09/27] M2.6: a damaged-stream refusal says the content may be past the damage Since marking a figure decorative or a tag as an artifact refuses content not found beside a damaged stream, the refusal also covers content that only may be past the damage. Its message said the content is in a damaged stream; it now says it is, or may be. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/alt.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index bdc72d2e33..607a1ec5c4 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -25,7 +25,9 @@ pub enum AltError { NotAFigure(u32), #[error("the page has more content than can be edited")] TooLarge, - #[error("the figure's content is in a content stream damaged partway, which can't be rewritten without losing what follows the damage")] + #[error( + "the figure's content is, or may be, in a content stream damaged partway, which can't be rewritten without losing what follows the damage" + )] Damaged, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), @@ -54,7 +56,9 @@ pub enum ArtifactError { ParentTree(String), #[error("the page has more content than can be edited")] TooLarge, - #[error("the tag's marked content is in a content stream damaged partway, which can't be rewritten without losing what follows the damage")] + #[error( + "the tag's marked content is, or may be, in a content stream damaged partway, which can't be rewritten without losing what follows the damage" + )] Damaged, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), From 0a350f6809ded06bdc519f5fdbea86d1e78e5482 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 01:00:43 +0100 Subject: [PATCH 10/27] M2.6: nothing after a damaged content stream, or in one that doesn't decode, is rewritten A page's content streams are read as one stream, so what a stream damaged partway hides past the damage is unknown to the streams after it too. A string or comment the damage leaves open runs on into the next stream, and operators the damage hides can change the graphics state the later streams draw in. Marking a tag's content as an artifact took what read as a BDC in a later stream for the tag's own, and could rewrite bytes inside a string. Moving an image after the damage worked the move out without a cm the damage may hide. Now an edit refuses a change to any stream after one damaged partway (rewritten_contents), and an image isn't arranged into a new stream placed after one. Marking an artifact or a decorative figure refuses content that reads as the tag's in a stream after the damage. Edits and marking before the damage go on as before. A content stream that doesn't decode at all was read as empty and whole. A styled edit whose paragraph marker closes past the page's last operator appended the close to it, so the edit replaced that stream with one holding only the close. Edits now refuse a change to such a stream, and still go on around it. Marking an artifact or a decorative figure whose content isn't found takes it as possibly in such a stream, as it does for content past the damage, and refuses rather than take it for absent. Tests: an_edit_never_rewrites_a_content_stream_damaged_partway (an edit after the damage refused, one before it made), text_a_damaged_stream_runs_on_into_is_not_edited, images_after_a_content_stream_damaged_partway_are_not_moved, a_content_stream_that_does_not_decode_is_never_written_over, a_tags_content_after_a_damaged_stream_is_not_rewritten, a_tags_content_not_found_beside_a_stream_that_does_not_decode_is_not_taken_for_absent, and a_tags_content_in_a_stream_damaged_partway_is_not_rewritten now checks the damaged stream itself and marks content before the damage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/alt.rs | 49 ++++++++++----- crates/a11y/src/tests.rs | 64 +++++++++++++++++--- crates/automation/src/tools.rs | 2 +- crates/edit/src/images.rs | 3 +- crates/edit/src/objects.rs | 2 +- crates/edit/src/tests.rs | 107 ++++++++++++++++++++++++++++++--- crates/edit/src/text.rs | 3 +- crates/edit/src/text/lines.rs | 74 ++++++++++++++++++----- 8 files changed, 255 insertions(+), 49 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 607a1ec5c4..dfe083bec9 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -26,7 +26,7 @@ pub enum AltError { #[error("the page has more content than can be edited")] TooLarge, #[error( - "the figure's content is, or may be, in a content stream damaged partway, which can't be rewritten without losing what follows the damage" + "the figure's content is, or may be, in a content stream that is damaged partway or doesn't decode, or after the damage, where it can't be rewritten without losing or misreading what wasn't read" )] Damaged, #[error("{0}")] @@ -57,7 +57,7 @@ pub enum ArtifactError { #[error("the page has more content than can be edited")] TooLarge, #[error( - "the tag's marked content is, or may be, in a content stream damaged partway, which can't be rewritten without losing what follows the damage" + "the tag's marked content is, or may be, in a content stream that is damaged partway or doesn't decode, or after the damage, where it can't be rewritten without losing or misreading what wasn't read" )] Damaged, #[error(transparent)] @@ -380,9 +380,19 @@ fn content_entries(doc: &Document, page: &Dict) -> Vec { } } -/// A `/Contents` entry and, for a stream, the stream, its decoded data and whether it is damaged -/// partway (the data is then what decoded before the damage). -type DecodedStream = (Object, Option<(Stream, Vec, bool)>); +/// How much of a content stream decoded. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Read { + Whole, + /// Damaged partway: the data is what decoded before the damage. What follows it is unknown, + /// the syntax the later streams start in too. + Damaged, + /// Not at all (a filter this reader lacks, say): the data is empty. + Unread, +} + +/// A `/Contents` entry and, for a stream, the stream, its decoded data and how much of it decoded. +type DecodedStream = (Object, Option<(Stream, Vec, Read)>); /// Decode the page's content streams within the page decode budget. `None` when a stream is /// rejected because the budget is exhausted. @@ -393,14 +403,14 @@ fn decode_page_streams(doc: &Document, page: &Dict) -> Option for entry in entries { let decoded = match &*doc.resolve(&entry) { Object::Stream(s) => { - let (data, damaged) = match budget.decode_with_damage(doc, s, usize::MAX) { - Ok(read) => read, + let (data, read) = match budget.decode_with_damage(doc, s, usize::MAX) { + Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }), // Content past the budget may hold more of the tag: editing the rest // would leave that part marked as a tag the tree no longer has. Err(_) if budget.exhausted() => return None, - Err(_) => (Vec::new(), false), + Err(_) => (Vec::new(), Read::Unread), }; - Some((s.clone(), data, damaged)) + Some((s.clone(), data, read)) } _ => None, }; @@ -420,8 +430,8 @@ enum Marked { Done, /// None of the content is on the page, or with `require_all` not all of it: nothing changed. NotFound, - /// Some of it is in a stream damaged partway, which can't be rewritten without losing what - /// follows the damage, or may be past the damage: nothing changed. + /// Some of it is, or may be, in a stream damaged partway or one that doesn't decode, neither of + /// which can be rewritten without losing what wasn't read, or after the damage: nothing changed. Damaged, } @@ -436,27 +446,34 @@ fn artifact_marked_content( require_all: bool, ) -> Result { let props = page_properties(doc, &page.dict); + // The pieces are the streams, in order. let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes, _)| bytes.as_slice())).collect(); + let read: Vec = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, _, read)| *read)).collect(); + let damage = read.iter().position(|r| *r == Read::Damaged); let pieces = pdfcraft_content::Pieces::join(&decoded); let mut found = HashSet::new(); let mut edits = Vec::new(); for op in pieces.parse().ops { if let Some(id) = marked_id(doc, &props, &op, ids) { + // What follows the damage is unknown: a string or comment the damage left open runs + // on into the next stream, so what reads as an operator after it may not be one. + if damage.is_some_and(|d| pieces.piece_of(op.span.start) > d) { + return Ok(Marked::Damaged); + } found.insert(id); edits.push((op.span, b"/Artifact BMC".to_vec())); } } - // Content not found may be past the damage in a damaged stream: whether it is on the page - // isn't known. - if found != *ids && streams.iter().any(|(_, s)| s.as_ref().is_some_and(|(_, _, damaged)| *damaged)) { + // Content not found may be past the damage, or in a stream that doesn't decode: whether it + // is on the page isn't known. + if found != *ids && read.iter().any(|r| *r != Read::Whole) { return Ok(Marked::Damaged); } if if require_all { found != *ids } else { found.is_empty() } { return Ok(Marked::NotFound); } let spliced = pieces.splice(edits); - // The pieces are the streams that decoded, in order. - if streams.iter().filter_map(|(_, s)| s.as_ref()).zip(&spliced).any(|((_, old, damaged), new)| *damaged && old != new) { + if read.iter().zip(&decoded).zip(&spliced).any(|((read, old), new)| *read != Read::Whole && *old != new.as_slice()) { return Ok(Marked::Damaged); } let mut spliced = spliced.into_iter(); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 78a6d96589..7e719acabb 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1103,8 +1103,9 @@ fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream { } /// A tag's content in a stream damaged partway stays tagged: rewriting the stream from what -/// decoded would lose what follows the damage. Nothing changes, the structure tree included. -/// Content in a whole stream beside a damaged one is marked, and the damaged one kept as stored. +/// decoded would lose what follows the damage. Nothing changes, the structure tree and the stream +/// included. Content in a whole stream before a damaged one is marked, and the damaged one kept +/// as stored. #[test] fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() { use pdfcraft_cos::{ObjRef, Object}; @@ -1113,20 +1114,69 @@ fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() { let mut doc = figures_doc(); doc.set(ObjRef::new(4, 0), damaged.clone()); let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(); - let (before_contents, before_pt, before_kids) = (contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))); + let state = |doc: &Document| (contents(doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)), doc.get(ObjRef::new(4, 0))); + let before = state(&doc); assert_eq!(figures(&doc).len(), 2, "read up to the damage"); assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged)); assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged)); - assert_eq!((contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))), (before_contents, before_pt, before_kids)); + assert_eq!(state(&doc), before); let mut doc = figures_doc(); let other = doc.add(damaged_flate(b"0 g\n", b"0 0 1 rg 0 0 10 10 re f")); - doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))]))) + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(other)]))) .unwrap(); mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap(); let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) }; - assert_eq!(list.first(), Some(&Object::Ref(other)), "kept as stored"); - assert_ne!(list.get(1), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten"); + assert_ne!(list.first(), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten"); + assert_eq!(list.get(1), Some(&Object::Ref(other)), "kept as stored"); +} + +/// What follows the damage is unknown, the syntax the next stream starts in too, so a tag's +/// content in a stream after a damaged one stays tagged. Here a comment the damage leaves open +/// runs on into the next stream, up to its first end of line, and a string starting there reads +/// as operators, among them what looks like the figure's content: rewritten, it would change the +/// string. Nothing changes, the structure tree included. +#[test] +fn a_tags_content_after_a_damaged_stream_is_not_rewritten() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let in_a_string = b"(a note\n/Figure << /MCID 0 >> BDC 0 g EMC\n) pop /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC"; + for (damaged, after) in [(b"0 g\n".as_slice(), None), (b"0 g % a comment".as_slice(), Some(in_a_string.as_slice()))] { + let mut doc = figures_doc(); + if let Some(after) = after { + doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), after.to_vec())); + } + let first = doc.add(damaged_flate(damaged, b"\n0 0 1 rg 0 0 10 10 re f")); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(4, 0))]))) + .unwrap(); + let state = |doc: &Document| { + (doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))) + }; + let before = state(&doc); + assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "{damaged:?}"); + assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "{damaged:?}"); + assert_eq!(state(&doc), before, "{damaged:?}"); + } +} + +/// A tag's content not found on the page may be in a stream that doesn't decode: it isn't known +/// to be off the page, so neither marking changes anything, the structure tree included. +#[test] +fn a_tags_content_not_found_beside_a_stream_that_does_not_decode_is_not_taken_for_absent() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut doc = figures_doc(); + doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(2))).unwrap(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + let other = doc.add(Object::Stream(Stream::from_raw(dict, b"/Figure << /MCID 2 >> BDC 0 g EMC".to_vec()))); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))]))) + .unwrap(); + let state = |doc: &Document| { + (doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))) + }; + let before = state(&doc); + assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged)); + assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged)); + assert_eq!(state(&doc), before); } /// A tag's content not found before the damage in a damaged stream may be past it: it isn't known diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e69a57dc33..4f3b0c374e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1285,7 +1285,7 @@ pub fn tools() -> Vec { "property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] }, "value": { "type": "string" }, }), &["doc", "object", "generation", "property"])), - t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that is, or may be, in a content stream damaged partway are rejected. Undoable.") + t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that is, or may be, in a content stream damaged partway or that doesn't decode, or after the damage, are rejected. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 201612e5b2..5e5c44fab7 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -1428,13 +1428,14 @@ fn arrange_image(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStr }; if let Some(piece) = owned_destination { + let insertion = piece.saturating_add(usize::from(insert_after)); + crate::text::drawn_before_damage(all, insertion)?; let changed = pieces.splice([(target_span, removal_replacement)]); let contents = crate::text::rewritten_contents(doc, all, changed)?; let mut list = match contents { Object::Array(list) => list, one => vec![one], }; - let insertion = piece.saturating_add(usize::from(insert_after)); if insertion > list.len() { return Err(EditError::Invalid("the destination content stream is missing".into())); } diff --git a/crates/edit/src/objects.rs b/crates/edit/src/objects.rs index c0a57c358a..6a64e3cfce 100644 --- a/crates/edit/src/objects.rs +++ b/crates/edit/src/objects.rs @@ -338,7 +338,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result, Ed return Err(EditError::Invalid("the page graphics state is nested too deeply".into())); } } - streams.push(ContentStream { entry: object, data: bytes, damaged: false }); + streams.push(ContentStream { entry: object, data: bytes, read: crate::text::Read::Whole }); } Ok(streams) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index df9bce657d..f3b4e3cc2f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5172,8 +5172,10 @@ fn damaged_page() -> (Document, pdfcraft_cos::Stream) { } /// An edit that would rewrite a content stream damaged partway is refused: written from what -/// decoded, it would lose what follows the damage, which other readers may still read. An edit -/// in the page's other stream goes on, and leaves the damaged one as it is stored. +/// decoded, it would lose what follows the damage, which other readers may still read. So is an +/// edit in a later stream: what the damage hides can change how that stream reads, a string or +/// comment it leaves open, or the graphics state. An edit in a stream before the damage goes on, +/// and leaves the damaged one as it is stored. #[test] fn an_edit_never_rewrites_a_content_stream_damaged_partway() { let (mut doc, damaged) = damaged_page(); @@ -5183,21 +5185,64 @@ fn an_edit_never_rewrites_a_content_stream_damaged_partway() { let listed = contents(&doc); let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err(); - assert!(refused.to_string().contains("damaged partway"), "{refused}"); + assert!(refused.to_string().contains("is damaged partway"), "{refused}"); assert_eq!(contents(&doc), listed); let refused = text::rewrite_block(&mut doc, 0, 0, Some("Edited"), &text::BlockStyle::default()).unwrap_err(); - assert!(refused.to_string().contains("damaged partway"), "{refused}"); + assert!(refused.to_string().contains("is damaged partway"), "{refused}"); + assert_eq!(contents(&doc), listed); + let refused = text::replace_line(&mut doc, 0, 1, "Edited").unwrap_err(); + assert!(refused.to_string().contains("comes after a content stream that is damaged partway"), "{refused}"); assert_eq!(contents(&doc), listed); - text::replace_line(&mut doc, 0, 1, "Edited").unwrap(); + let mut doc = split_page("BT /F1 12 Tf 72 600 Td (Whole) Tj ET", ""); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), damaged.clone()); + text::replace_line(&mut doc, 0, 0, "Edited").unwrap(); let doc = reopen(&doc); - assert_eq!(texts(&doc), ["Damaged", "Edited"]); + assert_eq!(texts(&doc), ["Edited", "Damaged"]); let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) }; - assert_eq!(list.first(), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)))); - let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(4, 0)) else { panic!("not a stream") }; + assert_eq!(list.get(1), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)))); + let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(5, 0)) else { panic!("not a stream") }; assert_eq!(kept.raw, damaged.raw, "stored as it was"); } +/// A comment the damage leaves open runs on into the next stream, up to its first end of line, +/// so a string starting there reads as operators: a line of text inside it. That line is in a +/// stream after the damage, and isn't edited. +#[test] +fn text_a_damaged_stream_runs_on_into_is_not_edited() { + let mut doc = split_page("", "(note\nBT /F1 12 Tf 72 600 Td (Inside a string) Tj ET\n) pop"); + doc.set(pdfcraft_cos::ObjRef::new(4, 0), damaged_flate(b"0 g % a comment", b"\n")); + let texts = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + assert_eq!(texts(&doc), ["Inside a string"], "the comment runs on into the next stream"); + let listed = pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").cloned(); + let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err(); + assert!(refused.to_string().contains("comes after"), "{refused}"); + assert_eq!(pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").cloned(), listed); +} + +/// A content stream that doesn't decode is never written over. A partial rewrite of the page's +/// last paragraph closes its marker past the last operator read, in the page's last stream, here +/// one that doesn't decode: the rewrite is refused, not that stream replaced by the close. An +/// empty stream there takes the close. +#[test] +fn a_content_stream_that_does_not_decode_is_never_written_over() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET"; + let mut doc = split_page(src, ""); + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), pdfcraft_cos::Stream::from_raw(dict, b"0 0 1 rg 0 0 10 10 re f".to_vec())); + let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned(); + let listed = contents(&doc); + let refused = text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap_err(); + assert!(refused.to_string().contains("can't be decoded"), "{refused}"); + assert_eq!(contents(&doc), listed); + + let mut doc = split_page(src, ""); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let texts: Vec<_> = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."]); +} + /// A content stream that doesn't decode at all is no damage to write over: it is kept as stored, /// and the page's other streams are read and edited around it. A stream of a few MB is read for /// selecting objects. @@ -5310,6 +5355,52 @@ fn arrange_and_crop_image_in_second_content_stream_succeeds() { assert_eq!(after_crop[1].visible_rect, Some(after_crop[1].rect)); } +/// What follows the damage in a content stream is unknown, the graphics state the later streams +/// draw in too: here it hides a `cm` that doubles them. An image after the damage isn't moved, as +/// the move is worked out without that `cm`, and one before it isn't arranged to after it, beside +/// an added item there. An image before the damage is moved. +#[test] +fn images_after_a_content_stream_damaged_partway_are_not_moved() { + use pdfcraft_cos::{Dict, ObjRef, Stream}; + let image = "q 100 0 0 100 200 200 cm /Im1 Do Q"; + let hidden_scale = || damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm"); + let shift = images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 0.0]); + let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned(); + + let mut doc = two_stream_image_page("", image); + doc.set(ObjRef::new(4, 0), hidden_scale()); + let listed = contents(&doc); + let refused = images::change_image(&mut doc, 0, 0, &shift).unwrap_err(); + assert!(refused.to_string().contains("comes after"), "{refused}"); + assert_eq!(contents(&doc), listed); + + let mut doc = two_stream_image_page(image, ""); + let damaged = doc.add(hidden_scale()); + let mut added = Dict::new(); + added.set(b"PCAdded".to_vec(), Object::Dict(Dict::new())); + let added = doc.add(Object::Stream(Stream::from_raw(added, b"q 0 0 1 rg 0 0 10 10 re f Q".to_vec()))); + doc.update_dict(ObjRef::new(3, 0), |p| { + p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(damaged), Object::Ref(added)])) + }) + .unwrap(); + let listed = contents(&doc); + let refused = images::change_image(&mut doc, 0, 0, &images::ImageChange::Arrange(images::ArrangeOrder::BringToFront)).unwrap_err(); + assert!(refused.to_string().contains("comes after"), "{refused}"); + assert_eq!(contents(&doc), listed); + // Arranged within the content before the damage, it goes on. + doc.update_dict(ObjRef::new(3, 0), |p| { + p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(added), Object::Ref(damaged)])) + }) + .unwrap(); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Arrange(images::ArrangeOrder::BringToFront)).unwrap(); + + let mut doc = two_stream_image_page(image, ""); + doc.set(ObjRef::new(5, 0), hidden_scale()); + images::change_image(&mut doc, 0, 0, &shift).unwrap(); + let moved = images::page_images(&reopen(&doc), 0).unwrap(); + assert!(close(moved[0].rect, [210.0, 200.0, 310.0, 300.0]), "{:?}", moved[0].rect); +} + /// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש, /// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`. fn hebrew_page(content: &str) -> Document { diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 01c0acdb70..40675b4892 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -56,7 +56,8 @@ pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name}; use interp::{Origin, Paint, union}; use lines::page_dict; pub(crate) use lines::{ - ContentStream, FormBudget, content_streams, content_streams_within, dict_in_place, form_call, joined, resource_dict, rewritten_contents, splice, + ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, form_call, joined, resource_dict, + rewritten_contents, splice, }; pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; pub use rewrite::{LineEdit, replace_line, rewrite_block}; diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index bf66cc0f8b..58b4ac6289 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -67,9 +67,19 @@ pub(crate) struct ContentStream { pub(crate) entry: Object, /// Its decoded data, empty when it doesn't decode. pub(crate) data: Vec, - /// The stream is damaged partway: `data` is what decoded before the damage, so the stream - /// can't be written again from it without losing the rest ([`rewritten_contents`]). - pub(crate) damaged: bool, + /// How much of it decoded. + pub(crate) read: Read, +} + +/// How much of a content stream decoded ([`rewritten_contents`] says what each allows). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Read { + Whole, + /// Damaged partway: the data is what decoded before the damage. What follows it is unknown, + /// the graphics state and syntax the later streams start in too. + Damaged, + /// Not at all (a filter this reader lacks, say): the data is empty. + Unread, } /// The page's content streams as listed, each with its decoded data, within one page's @@ -91,15 +101,16 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D }; let mut out = Vec::with_capacity(list.len()); for entry in list { - let (data, damaged) = match &*doc.resolve(&entry) { + let (data, read) = match &*doc.resolve(&entry) { Object::Stream(s) => match budget.decode_with_damage(doc, s, usize::MAX) { - Ok(read) => read, + Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }), Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))), - Err(_) => (Vec::new(), false), + Err(_) => (Vec::new(), Read::Unread), }, - _ => (Vec::new(), false), + // Nothing to lose: a null or missing entry draws nothing. + _ => (Vec::new(), Read::Whole), }; - out.push(ContentStream { entry, data, damaged }); + out.push(ContentStream { entry, data, read }); } Ok(out) } @@ -136,15 +147,50 @@ pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> pieces.splice(edits) } +/// An error when content at `/Contents` index `at` (a stream there, or one inserted there) is drawn +/// after a stream damaged partway: what the damage hides can change how it reads. +pub(crate) fn drawn_before_damage(streams: &[ContentStream], at: usize) -> Result<(), EditError> { + if at > first_damaged(streams) { + return Err(EditError::Invalid( + "this change comes after a content stream that is damaged partway, and what the damage hides can change how the rest of the page reads" + .into(), + )); + } + Ok(()) +} + +/// The index of the first stream damaged partway, or the number of streams. +fn first_damaged(streams: &[ContentStream]) -> usize { + streams.iter().position(|s| s.read == Read::Damaged).unwrap_or(streams.len()) +} + /// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams /// (keeping their dictionaries), the others stay as they were. An error, before anything is -/// added, when a piece that changed is a damaged stream: written from what decoded, it would -/// lose everything after the damage. +/// added, when a piece that changed is a stream that didn't decode whole, or comes after one +/// damaged partway. Written from what decoded, a damaged stream would lose everything after the +/// damage, and one that doesn't decode all of it. What follows the damage is unknown, so a later +/// stream may not read as it seems: a string or comment the damage left open runs on into it, and +/// operators the damage hides can change the graphics state it draws in. pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[ContentStream], new: Vec>) -> Result { - if streams.iter().zip(&new).any(|(s, data)| s.damaged && s.data != *data) { - return Err(EditError::Invalid( - "this change is in a content stream that is damaged partway, and rewriting it would lose what follows the damage".into(), - )); + // The last stream that changed: when it is drawn before the damage, so are the others. + if let Some(i) = streams.iter().zip(&new).rposition(|(s, data)| s.data != *data) { + drawn_before_damage(streams, i)?; + } + for (s, data) in streams.iter().zip(&new) { + if s.data == *data { + continue; + } + match s.read { + Read::Whole => {} + Read::Damaged => { + return Err(EditError::Invalid( + "this change is in a content stream that is damaged partway, and rewriting it would lose what follows the damage".into(), + )); + } + Read::Unread => { + return Err(EditError::Invalid("this change is in a content stream that can't be decoded, and rewriting it would lose it".into())); + } + } } let mut contents: Vec = Vec::with_capacity(streams.len()); for (s, data) in streams.iter().zip(new) { From 8bf741222016dc9fd997f96100897edc95c4049e Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 01:01:24 +0100 Subject: [PATCH 11/27] M2.6: pruning reads every form that damaged content may draw Earlier in this change, a page or form damaged partway, like one that doesn't decode, kept its own /XObject dictionary whole and was read no further. So a form it draws was never read: when the form's resources are shared with another page, the names the form draws there went, and extracted or split pages lost those images. On main the part before the damage was read, so forms drawn there kept theirs, and forms drawn past it didn't. Now every form the resources of such content name is read as drawn, as any of them may be drawn before the damage or past it. Each set of resources has its forms read once. Test: extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources, with a page and a form each damaged before and past the form they draw, and each not decoding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/organize/src/prune.rs | 92 +++++++++++++++++++++--------------- crates/organize/src/tests.rs | 63 ++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 37 deletions(-) diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index 591e804b1e..bac8ad5f90 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -53,6 +53,9 @@ struct Pruner<'a> { /// Forms already analysed, with the owner of the resources they drew against: drawing one /// again records nothing new, and re-reading it on every `Do` is exponential in the nesting. forms_seen: HashSet<(ObjRef, Owner)>, + /// Owners of resources that content not read whole drew against, whose every form was read + /// as drawn ([`Pruner::unread`]): once is enough. + partly_read: HashSet, } impl Pruner<'_> { @@ -194,39 +197,57 @@ impl Pruner<'_> { } let Some(name) = op.name(0) else { continue }; self.used.entry(key).or_default().insert(name.to_vec()); - let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue }; - let Object::Stream(s) = &*self.doc.get(r) else { continue }; - if s.dict.name(b"Subtype") != Some(b"Form") { - continue; - } - let (inner, inner_owner) = match s.dict.get(b"Resources") { - Some(res) => match res.as_ref() { - Some(rr) => match self.doc.get(rr).as_dict().cloned() { - Some(d) => (d, Owner::Resources(rr)), - None => continue, - }, - None => match self.doc.resolve(res).as_dict().cloned() { - // The form carries its resources inline: filter them on the form itself. - Some(d) => (d, Owner::Resources(r)), - None => continue, - }, + self.form(&xobjects, name, resources, owner, depth); + } + } + + /// For content that can't be read whole (damaged partway, or not decoding at all): what it + /// draws is unknown, so its `/XObject` dictionary is kept whole, and every form there is read + /// as drawn, which also reads every form the part before any damage draws. A form whose + /// resources are shared keeps what it draws from them too. + fn unread(&mut self, resources: &Dict, owner: Owner, depth: u8) { + self.protect_type3_resources(resources, owner); + let Some((xobjects, key)) = self.xobjects(resources, owner) else { return }; + self.unsure.insert(key); + if depth > MAX_FORM_DEPTH || !self.partly_read.insert(owner) { + return; + } + for (name, _) in xobjects.iter() { + self.form(&xobjects, name, resources, owner, depth); + } + } + + /// Record what the XObject `name` of `xobjects` draws when it is a form, drawn from content + /// with `resources`. + fn form(&mut self, xobjects: &Dict, name: &[u8], resources: &Dict, owner: Owner, depth: u8) { + let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { return }; + let Object::Stream(s) = &*self.doc.get(r) else { return }; + if s.dict.name(b"Subtype") != Some(b"Form") { + return; + } + let (inner, inner_owner) = match s.dict.get(b"Resources") { + Some(res) => match res.as_ref() { + Some(rr) => match self.doc.get(rr).as_dict().cloned() { + Some(d) => (d, Owner::Resources(rr)), + None => return, }, - // A form without its own resources draws against the ones it was invoked with. - None => (resources.clone(), owner), - }; - if !self.forms_seen.insert((r, inner_owner)) { - continue; - } - #[cfg(test)] - crate::work(1); - match DecodeBudget::new(usize::MAX, 0).decode_with_damage(self.doc, s, usize::MAX) { - Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1), - // What the form draws is unknown, past damage too: keep everything it could reach. - Ok((_, true)) | Err(_) => { - self.protect_type3_resources(&inner, inner_owner); - self.distrust(&inner, inner_owner); - } - } + None => match self.doc.resolve(res).as_dict().cloned() { + // The form carries its resources inline: filter them on the form itself. + Some(d) => (d, Owner::Resources(r)), + None => return, + }, + }, + // A form without its own resources draws against the ones it was invoked with. + None => (resources.clone(), owner), + }; + if !self.forms_seen.insert((r, inner_owner)) { + return; + } + #[cfg(test)] + crate::work(1); + match DecodeBudget::new(usize::MAX, 0).decode_with_damage(self.doc, s, usize::MAX) { + Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1), + Ok((_, true)) | Err(_) => self.unread(&inner, inner_owner, depth + 1), } } } @@ -321,6 +342,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result Result {} Ok((data, false)) => pruner.content(&data, &res_dict, owner, 0), - // What the page draws past damage is unknown too. - Ok((_, true)) | Err(_) => { - pruner.protect_type3_resources(&res_dict, owner); - pruner.distrust(&res_dict, owner); - } + Ok((_, true)) | Err(_) => pruner.unread(&res_dict, owner, 0), } } let Pruner { used, unsure, glyph_budget_exhausted, .. } = pruner; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 8d41f474cc..d6273cc65b 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2669,6 +2669,69 @@ fn extract_keeps_images_content_damaged_partway_might_draw() { } } +/// Two pages: the first draws `/Fm` through resources of its own, `/Fm` draws `/Nested` through +/// resources of its own too, and `/Nested` draws `/ImA` through resources it shares with the second +/// page, which draws `/ImB` through them. The three streams are the first page's content, `/Fm` +/// and `/Nested`. +fn doc_shared_form_resources(page: pdfcraft_cos::Stream, fm: pdfcraft_cos::Stream, nested: pdfcraft_cos::Stream) -> Document { + use pdfcraft_cos::ObjRef; + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /Fm 7 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources 8 0 R >>".into(), + raw(""), + raw("/ImB Do"), + raw(""), + "<< /XObject << /ImA 9 0 R /ImB 10 0 R >> >>".into(), + image(1, 1, 1), + image(1, 1, 2000), + raw(""), + ]; + let mut doc = open(build(&b, "/Root 1 0 R")); + doc.set(ObjRef::new(5, 0), page); + let nested_res: Dict = + [(b"XObject".to_vec(), Object::Dict([(b"Nested".to_vec(), Object::Ref(ObjRef::new(11, 0)))].into_iter().collect()))].into_iter().collect(); + for (num, mut form, resources) in [(7, fm, Object::Dict(nested_res)), (11, nested, Object::Ref(ObjRef::new(8, 0)))] { + form.dict.set(b"Subtype".to_vec(), Object::name("Form")); + form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + form.dict.set(b"Resources".to_vec(), resources); + doc.set(ObjRef::new(num, 0), form); + } + doc +} + +/// Every form the resources of a page or form damaged partway name may be drawn, before the +/// damage or past it, so each is read: one whose resources another page shares keeps what it draws +/// from them. So is each one content that doesn't decode may draw. +#[test] +fn extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources() { + let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec()); + let undecodable = |content: &[u8]| { + let mut s = whole(content); + s.dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + s + }; + let cases = [ + ("page, before the damage", damaged_flate(b"/Fm Do\n", b"0 g"), whole(b"/Nested Do")), + ("page, past the damage", damaged_flate(b"0 g\n", b"/Fm Do"), whole(b"/Nested Do")), + ("page that doesn't decode", undecodable(b"/Fm Do"), whole(b"/Nested Do")), + ("form, before the damage", whole(b"/Fm Do"), damaged_flate(b"/Nested Do\n", b"0 g")), + ("form, past the damage", whole(b"/Fm Do"), damaged_flate(b"0 g\n", b"/Nested Do")), + ("form that doesn't decode", whole(b"/Fm Do"), undecodable(b"/Nested Do")), + ]; + for (what, page, fm) in cases { + let part = full_roundtrip(&extract_pages(&doc_shared_form_resources(page, fm, whole(b"/ImA Do")), &[0, 1]).unwrap()); + let names = xobject_names(&part, 1); + assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{what}: {names:?}"); + assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{what}: {names:?}"); + } + // What nothing draws still goes. + let src = doc_shared_form_resources(whole(b"0 g"), whole(b"/Nested Do"), whole(b"/ImA Do")); + let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()); + assert_eq!(xobject_names(&part, 1), [b"ImB".to_vec()]); +} + /// A form whose `/DecodeParms` is given by reference is read through it: the image it draws is /// kept, not lost to content read with the predictor's tag bytes left in it. #[test] From 749a3233422614f3a9c97cf7d71a40f5696935b0 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 01:01:24 +0100 Subject: [PATCH 12/27] M2.6: tests pin damage in an earlier filter stage, on another page, and in the saved file - cos: a filter chain whose first stage stops at damage and whose second reads all it was given is damaged. - optimize: a page damaged partway stops resampling for the whole document, so another page drawing the same image whole doesn't get it resampled. - redact: after redacting content with /DecodeParms by reference, no stream in the saved file holds the text, read with its parameters. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/cos/src/budget.rs | 11 +++++++++++ crates/optimize/tests/optimize.rs | 18 ++++++++++++++++++ crates/redact/src/tests.rs | 9 ++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 51b05e034d..23bcaf1af7 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -850,6 +850,17 @@ mod tests { dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); let hex = Stream::from_raw(dict, Stream::flate(Dict::new(), b"4142zz4344>").raw.to_vec()); assert_eq!(budget.decode_with_damage(&doc, &hex, usize::MAX).ok(), Some((b"AB".to_vec(), true))); + // So is an earlier stage's, though the stage after it reads all it was given: a stored + // block of whole hex, then a block of the reserved type (RFC 1951 §3.2.3). + let readable = b"4142>"; + let len = u16::try_from(readable.len()).unwrap_or_default(); + let mut raw = vec![0x78, 0x01, 0x00]; + raw.extend_from_slice(&len.to_le_bytes()); + raw.extend_from_slice(&(!len).to_le_bytes()); + raw.extend_from_slice(readable); + raw.push(0x07); + let flate_damaged = Stream::from_raw(hex.dict.clone(), raw); + assert_eq!(budget.decode_with_damage(&doc, &flate_damaged, usize::MAX).ok(), Some((b"AB".to_vec(), true))); } /// `/DecodeParms` given by reference, or a parameter given by reference in them, is read: a diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 7250e9a0a0..2785ddac6c 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3220,6 +3220,24 @@ fn content_damaged_partway_leaves_every_image_at_its_resolution() { } } +/// One page damaged partway stops resampling for the whole document: another page drawing the same +/// image at its full resolution, whole, doesn't get it resampled either. +#[test] +fn content_damaged_partway_on_one_page_leaves_the_image_another_page_draws() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 1, false, None); + let damaged = page(&mut doc, &[("Im", img)], ""); + let bad = doc.add(Object::Stream(damaged_flate(b"0 g\n", b"q 720 0 0 720 0 0 cm /Im Do Q"))); + doc.update_dict(damaged, |d| d.set(b"Contents".to_vec(), Object::Ref(bad))).unwrap(); + let whole = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q"); + assert_eq!(effective_resolutions(&doc, &[whole])[&img], 720.0); + let ppi = effective_resolutions(&doc, &[damaged, whole]).get(&img).copied().unwrap_or(f64::INFINITY); + assert_eq!(ppi, f64::INFINITY); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, 0, "{r:?}"); + assert_eq!(doc.get(img).as_dict().and_then(|d| d.int(b"Width")), Some(720)); +} + /// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square. fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef { let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do"); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 2de12ba374..f080df0e46 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -130,7 +130,14 @@ fn content_with_parameters_given_by_reference_is_redacted() { mark(&mut doc, 0, &marked, ""); let r = apply(&mut doc, None).unwrap(); assert_eq!((r.marks, r.glyphs), (1, 6), "{what}"); - assert_eq!(under(&mut reopen(&doc), 0, &marked), 0, "{what}"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &marked), 0, "{what}"); + // Nor is the text left in a stream the page no longer draws, read with its parameters. + for (r, o) in saved.scan_objects() { + let Object::Stream(s) = &*o else { continue }; + let data = pdfcraft_cos::DecodeBudget::new(usize::MAX, 0).decode_with_damage(&saved, s, usize::MAX).map(|(d, _)| d).unwrap_or_default(); + assert!(!data.windows(6).any(|w| w == b"SECRET"), "{what}: {r:?}"); + } } } From 75bf5ed264ba3995247e779308d1d67a689d1fc6 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 01:35:15 +0100 Subject: [PATCH 13/27] M2.6: a test pins that pruning reads the forms of damaged content at the depth limit Forms are read 8 deep. A damaged form that deep has every form it names read as drawn, so one drawing through resources another page shares keeps what it draws: a_damaged_form_at_the_depth_limit_has_its_forms_read. Mutation testing found nothing pinned that boundary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/organize/src/tests.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d6273cc65b..3d956727e3 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2732,6 +2732,34 @@ fn extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources( assert_eq!(xobject_names(&part, 1), [b"ImB".to_vec()]); } +/// Forms are read 8 deep (`prune.rs`'s `MAX_FORM_DEPTH`). A damaged form that deep still has +/// every form it names read as drawn, and one drawing through resources another page shares keeps +/// what it draws from them. +#[test] +fn a_damaged_form_at_the_depth_limit_has_its_forms_read() { + use pdfcraft_cos::ObjRef; + let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec()); + let mut src = doc_shared_form_resources(whole(b"/C Do"), damaged_flate(b"0 g\n", b"/Nested Do"), whole(b"/ImA Do")); + // Seven forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 8 deep. + let mut next = ObjRef::new(7, 0); + for _ in 1..8 { + let mut form = whole(b"/C Do"); + form.dict.set(b"Subtype".to_vec(), Object::name("Form")); + form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect(); + form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect())); + next = src.add(Object::Stream(form)); + } + let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect(); + src.update_dict(ObjRef::new(3, 0), |p| { + p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect())) + }) + .unwrap(); + let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()); + let names = xobject_names(&part, 1); + assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{names:?}"); +} + /// A form whose `/DecodeParms` is given by reference is read through it: the image it draws is /// kept, not lost to content read with the predictor's tag bytes left in it. #[test] From 084fa0927edbb387bd7cee5365224e22142096b9 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 01:37:51 +0100 Subject: [PATCH 14/27] M2.6: say why pruning reads a form's /Subtype only when direct The sweep flags the read in Pruner::form, moved there by this change. A /Subtype given by reference reads as no form wherever the workspace tells forms from images; resolving it is a change across all of them, and the comment says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/organize/src/prune.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index bac8ad5f90..85ddb5f743 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -222,6 +222,9 @@ impl Pruner<'_> { fn form(&mut self, xobjects: &Dict, name: &[u8], resources: &Dict, owner: Owner, depth: u8) { let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { return }; let Object::Stream(s) = &*self.doc.get(r) else { return }; + // A `/Subtype` given by reference reads as no form here, as wherever the workspace tells + // forms from images (redaction, edit and Optimize among them): resolving it is a change + // across all of them. if s.dict.name(b"Subtype") != Some(b"Form") { return; } From d289038ca01432a97e164e29af181c55500bbb76 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 01:52:43 +0100 Subject: [PATCH 15/27] M2.6: autotagging reads page content with its /DecodeParms given by reference Autotagging a text-only page rewrites its content, marked, from what it read. It read each stream through its filters with /DecodeParms only when direct, so content behind a predictor whose parameters are given by reference was read with the predictor's tag bytes in it, and written back so: with eight-byte rows, a \0 every eight bytes split `24 Tf` into `2 4 Tf` and the text's string, and the tool returned ok. Found by the consumers review of the CodeRabbit round; inherited from main. It now reads through DecodeBudget::decode_strict, which reads the parameters in the document and is strict on damage as before, within the same 16 MiB. pdfcraft-a11y no longer uses pdfcraft-filters. Test: autotag_text_reads_content_with_parameters_given_by_reference. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- Cargo.lock | 1 - crates/a11y/Cargo.toml | 1 - crates/a11y/src/autotag.rs | 19 +++++++++---------- crates/a11y/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8617c45522..520413f003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4254,7 +4254,6 @@ version = "0.18.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", - "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-model", "thiserror 2.0.21", diff --git a/crates/a11y/Cargo.toml b/crates/a11y/Cargo.toml index 42a86bbad2..adef6053a1 100644 --- a/crates/a11y/Cargo.toml +++ b/crates/a11y/Cargo.toml @@ -14,6 +14,5 @@ workspace = true pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-content = { workspace = true } -pdfcraft-filters = { workspace = true } pdfcraft-fonts = { workspace = true } thiserror.workspace = true diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 9b65f6972b..5f3a75ceb0 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -4,7 +4,7 @@ //! which can then be retagged with the existing tag-property tool after human review. use pdfcraft_content::{Op, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; const MAX_PAGES: usize = 1_000; const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024; @@ -59,16 +59,15 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R if remaining == 0 { return Err(unsupported(index, "decoded content exceeds 16 MiB")); } - let chain = stream.filters(); - let decoded = if chain.is_empty() { - if stream.raw.len() > remaining { - return Err(unsupported(index, "decoded content exceeds 16 MiB")); + // Strictly, and with `/DecodeParms` given by reference: the content is written back from it. + let mut budget = DecodeBudget::new(remaining, 0); + let decoded = budget.decode_strict(doc, stream).map_err(|e| { + if budget.remaining() == 0 { + unsupported(index, "decoded content exceeds 16 MiB") + } else { + unsupported(index, format!("content cannot be decoded strictly: {e}")) } - stream.raw.to_vec() - } else { - pdfcraft_filters::decode(&chain, &stream.raw, remaining) - .map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))? - }; + })?; all.extend_from_slice(&decoded); all.push(b' '); } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 7e719acabb..304e4ad8c4 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -79,6 +79,39 @@ fn autotag_text_writes_marked_content_and_parent_tree_across_pages() { assert_eq!(status(&check(&reopened, &Options::default()), Rule::TaggedContent), Status::Passed); } +/// Page content behind a predictor whose `/DecodeParms` are given by reference is read through +/// them: autotagging writes the text back as it was, marked, not with the predictor's tag bytes in +/// it (a `\0` every eight bytes, which split `24` into `2 4`). +#[test] +fn autotag_text_reads_content_with_parameters_given_by_reference() { + use pdfcraft_cos::{Dict, ObjRef}; + let mut content = b"BT /F1 24 Tf 72 700 Td (Hello World) Tj ET".to_vec(); + content.resize(content.len().div_ceil(8) * 8, b' '); + // PNG rows of eight bytes, each tagged 0 (no prediction). + let rows: Vec = content.chunks(8).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect(); + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + stream(""), + FONT.into(), + ], + "", + ); + let parms: Dict = [(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(8))].into_iter().collect(); + let parms = doc.add(Object::Dict(parms)); + let mut predicted = pdfcraft_cos::Stream::flate(Dict::new(), &rows); + predicted.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + doc.set(ObjRef::new(4, 0), predicted); + assert_eq!(autotag_text(&mut doc).unwrap(), AutoTagResult { pages: 1, paragraphs: 1 }); + let page = &pdfcraft_model::pages(&doc)[0]; + let written = doc.resolve(page.dict.get(b"Contents").unwrap()); + let Object::Stream(written) = &*written else { panic!("page content") }; + let text = String::from_utf8_lossy(&written.decoded_strict().unwrap()).into_owned(); + assert!(text.contains("/F1 24 Tf") && text.contains("(Hello World) Tj"), "{text}"); +} + #[test] fn autotag_text_refuses_paint_and_does_not_modify_the_document() { let mut doc = pdf( From 477f0abd49ea318bede4314e67d4842fd41fa9b1 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 01:57:10 +0100 Subject: [PATCH 16/27] M2.6: a test pins why autotagging refuses a page it cannot read Content past 16 MiB says so, and content that does not decode strictly says that: autotag_text_says_why_it_cannot_read_a_page. Mutation testing found nothing pinned which message each gets. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/tests.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 304e4ad8c4..6275c50437 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -112,6 +112,35 @@ fn autotag_text_reads_content_with_parameters_given_by_reference() { assert!(text.contains("/F1 24 Tf") && text.contains("(Hello World) Tj"), "{text}"); } +/// Autotagging refuses a page whose content it can't read whole, and says why: content past +/// 16 MiB, or content that doesn't decode strictly. +#[test] +fn autotag_text_says_why_it_cannot_read_a_page() { + use pdfcraft_cos::{Dict, ObjRef, Stream}; + let reason = |content: Stream| { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + stream(""), + FONT.into(), + ], + "", + ); + doc.set(ObjRef::new(4, 0), content); + match autotag_text(&mut doc) { + Err(AutoTagError::Unsupported { reason, .. }) => reason, + other => panic!("{other:?}"), + } + }; + assert_eq!(reason(Stream::from_raw(Dict::new(), vec![b' '; 17 << 20])), "decoded content exceeds 16 MiB"); + let mut corrupt = Stream::from_raw(Dict::new(), b"not zlib data".to_vec()); + corrupt.dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let why = reason(corrupt); + assert!(why.starts_with("content cannot be decoded strictly"), "{why}"); +} + #[test] fn autotag_text_refuses_paint_and_does_not_modify_the_document() { let mut doc = pdf( From 79ea9154cfd63fff70391f63eeaf87b480bf4148 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 02:23:10 +0100 Subject: [PATCH 17/27] M2.6: autotagging says decoding takes more than 16 MiB, not that the content does Read through DecodeBudget::decode_strict since cbc3fc221, a stream counts what each of its filter stages decodes against the 16 MiB, as a page's decode allowance does, where main bounded each stage alone. A page of 12 MiB behind ASCIIHex and Flate, with 9.1 MiB between them, was tagged on main and is refused now; the refusal said the decoded content exceeds 16 MiB, which it does not. It now says decoding the content takes more than that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/autotag.rs | 5 +++-- crates/a11y/src/tests.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 5f3a75ceb0..b9ffa46669 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -57,13 +57,14 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) }; let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1)); if remaining == 0 { - return Err(unsupported(index, "decoded content exceeds 16 MiB")); + return Err(unsupported(index, "decoding the content takes more than 16 MiB")); } // Strictly, and with `/DecodeParms` given by reference: the content is written back from it. let mut budget = DecodeBudget::new(remaining, 0); let decoded = budget.decode_strict(doc, stream).map_err(|e| { + // The allowance counts what every filter stage decodes, as a page's does. if budget.remaining() == 0 { - unsupported(index, "decoded content exceeds 16 MiB") + unsupported(index, "decoding the content takes more than 16 MiB") } else { unsupported(index, format!("content cannot be decoded strictly: {e}")) } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 6275c50437..0ad6ecd3e5 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -134,7 +134,7 @@ fn autotag_text_says_why_it_cannot_read_a_page() { other => panic!("{other:?}"), } }; - assert_eq!(reason(Stream::from_raw(Dict::new(), vec![b' '; 17 << 20])), "decoded content exceeds 16 MiB"); + assert_eq!(reason(Stream::from_raw(Dict::new(), vec![b' '; 17 << 20])), "decoding the content takes more than 16 MiB"); let mut corrupt = Stream::from_raw(Dict::new(), b"not zlib data".to_vec()); corrupt.dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); let why = reason(corrupt); From c3ca0633737be8a96a3e5d83b5e6751ae0c64d70 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 02:35:33 +0100 Subject: [PATCH 18/27] M2.6: a test pins that pruning reads a long chain of forms only to the depth limit A chain of 100,000 forms, whole or each damaged partway, is read only down to the depth limit, so the walk's recursion stays bounded: a_long_chain_of_forms_is_read_only_to_the_depth_limit. Nothing pinned it: with the depth step dropped in either of the two calls that go one form deeper (reading a form whole, and reading one that is damaged), or the depth check in the walk of damaged content made to apply only with the read-once guard, the walk follows the chain to its end and the stack overflows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/organize/src/tests.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 3d956727e3..dfd9eecf35 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2760,6 +2760,29 @@ fn a_damaged_form_at_the_depth_limit_has_its_forms_read() { assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{names:?}"); } +/// A chain of forms nested far past the depth limit, whole or each damaged partway, is read only +/// down to it: the walk's depth stays bounded however long the chain is, and pruning goes on. +#[test] +fn a_long_chain_of_forms_is_read_only_to_the_depth_limit() { + use pdfcraft_cos::ObjRef; + let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec()); + let xobjects = |names: Dict| Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()); + for damaged in [false, true] { + let mut doc = doc_shared_form_resources(whole(b"/C Do"), whole(b""), whole(b"")); + let mut names = Dict::new(); + for _ in 0..100_000 { + let mut form = if damaged { damaged_flate(b"", b"/C Do") } else { whole(b"/C Do") }; + form.dict.set(b"Subtype".to_vec(), Object::name("Form")); + form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + form.dict.set(b"Resources".to_vec(), xobjects(names)); + let next = doc.add(Object::Stream(form)); + names = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect(); + } + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Resources".to_vec(), xobjects(names))).unwrap(); + assert!(crate::prune::prune_unused_xobjects(&mut doc).is_ok(), "damaged: {damaged}"); + } +} + /// A form whose `/DecodeParms` is given by reference is read through it: the image it draws is /// kept, not lost to content read with the predictor's tag bytes left in it. #[test] From 104e22904ae34c7fbd421b8dcc830ea89c5c5eca Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 03:23:18 +0100 Subject: [PATCH 19/27] M2.6: shorter wording for content in or after damage Marking a figure decorative or a tag's content as an artifact refuses content that may be in a damaged or unreadable content stream, or after the damage. The two errors now say so in one clause, and that nothing was changed, which holds: both return before touching the document. The mark-as-artifact tool's description names the case in a few words. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/alt.rs | 8 ++------ crates/automation/src/tools.rs | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index dfe083bec9..77dda106ed 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -25,9 +25,7 @@ pub enum AltError { NotAFigure(u32), #[error("the page has more content than can be edited")] TooLarge, - #[error( - "the figure's content is, or may be, in a content stream that is damaged partway or doesn't decode, or after the damage, where it can't be rewritten without losing or misreading what wasn't read" - )] + #[error("the figure's content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")] Damaged, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), @@ -56,9 +54,7 @@ pub enum ArtifactError { ParentTree(String), #[error("the page has more content than can be edited")] TooLarge, - #[error( - "the tag's marked content is, or may be, in a content stream that is damaged partway or doesn't decode, or after the damage, where it can't be rewritten without losing or misreading what wasn't read" - )] + #[error("the tag's marked content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")] Damaged, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4f3b0c374e..e206f042bc 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1285,7 +1285,7 @@ pub fn tools() -> Vec { "property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] }, "value": { "type": "string" }, }), &["doc", "object", "generation", "property"])), - t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that is, or may be, in a content stream damaged partway or that doesn't decode, or after the damage, are rejected. Undoable.") + t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that may be in or after a damaged content stream are rejected. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, From d1de0a4badd11203ef19ebf7e881c2c9a5c74484 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 03:52:04 +0100 Subject: [PATCH 20/27] M2.6: autotagging's reason for content it can't decode reads as on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since autotagging reads page content through DecodeBudget::decode_strict, the reason it gave for content that doesn't decode carried that error's own text, so it said twice that the data couldn't be decoded: "content cannot be decoded strictly: stream data could not be decoded: FlateDecode: …". It now gives the decoder's reason right after, as main does: "content cannot be decoded strictly: FlateDecode: …". Test: autotag_text_says_why_it_cannot_read_a_page now checks the decoder's reason follows the prefix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/a11y/src/autotag.rs | 8 +++++++- crates/a11y/src/tests.rs | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index b9ffa46669..5c0dc2bd19 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -66,7 +66,13 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R if budget.remaining() == 0 { unsupported(index, "decoding the content takes more than 16 MiB") } else { - unsupported(index, format!("content cannot be decoded strictly: {e}")) + // The decoder's own reason: `CosError::Filter`'s text would say again that the data + // couldn't be decoded. + let why = match e { + pdfcraft_cos::CosError::Filter(why) => why, + other => other.to_string(), + }; + unsupported(index, format!("content cannot be decoded strictly: {why}")) } })?; all.extend_from_slice(&decoded); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 0ad6ecd3e5..960eb39d3d 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -113,7 +113,7 @@ fn autotag_text_reads_content_with_parameters_given_by_reference() { } /// Autotagging refuses a page whose content it can't read whole, and says why: content past -/// 16 MiB, or content that doesn't decode strictly. +/// 16 MiB, or content that doesn't decode strictly, with the decoder's reason right after. #[test] fn autotag_text_says_why_it_cannot_read_a_page() { use pdfcraft_cos::{Dict, ObjRef, Stream}; @@ -138,7 +138,7 @@ fn autotag_text_says_why_it_cannot_read_a_page() { let mut corrupt = Stream::from_raw(Dict::new(), b"not zlib data".to_vec()); corrupt.dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); let why = reason(corrupt); - assert!(why.starts_with("content cannot be decoded strictly"), "{why}"); + assert!(why.starts_with("content cannot be decoded strictly: FlateDecode: "), "{why}"); } #[test] From 1d0c3034662aad0b697094afda59753237dba3af Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 13:15:45 +0100 Subject: [PATCH 21/27] M2.6: the redaction proof reads /DecodeParms given by reference The proof that came with #5557 decodes every stream it sweeps through `Ctx::decode`, which called `decode_strict` with the stream alone. This PR's `decode_strict` also takes the document, to read `/DecodeParms` given by reference, so the branch merged with main no longer built. `Ctx::decode` now takes the document, and each of its callers passes the one it reads, so the proof reads such a stream as redaction reads page content and forms. Before, it decoded the stream with the parameters' defaults: a PNG predictor's rows kept their tag bytes, and a copy of the removed text split by them went unreported (`a_survivor_behind_parameters_given_by_reference_is_found`). The test helpers' streams are direct, so they pass an empty document. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/tests_verify.rs | 18 ++++++++++++++++++ crates/redact/src/verify/extract.rs | 6 +++--- crates/redact/src/verify/geometry.rs | 8 ++++---- crates/redact/src/verify/mod.rs | 10 +++++----- crates/redact/src/verify/snapshot.rs | 2 +- crates/redact/src/verify/sweep.rs | 8 ++++---- crates/redact/src/verify/util.rs | 5 +++-- 7 files changed, 38 insertions(+), 19 deletions(-) diff --git a/crates/redact/src/tests_verify.rs b/crates/redact/src/tests_verify.rs index ee443f3209..dc3c3c89d9 100644 --- a/crates/redact/src/tests_verify.rs +++ b/crates/redact/src/tests_verify.rs @@ -332,6 +332,24 @@ fn a_survivor_in_an_xfa_packet_is_found() { assert!(!proof.passed(), "{}", proof.to_text()); } +#[test] +fn a_survivor_behind_parameters_given_by_reference_is_found() { + // Object 7 holds the text behind Flate and a PNG predictor whose `/DecodeParms` is object 8, + // a reference. Read without them, its rows keep their tag bytes, which split the text; the + // sweep reads them as redaction does, and reports the copy. + let text = format!("note: {SECRET} xx"); + let rows: Vec = text.as_bytes().chunks(4).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect(); + let packed = stream("/Filter /FlateDecode /DecodeParms 8 0 R", Stream::flate(Dict::new(), &rows).raw.as_ref()); + let parms = b"<< /Predictor 12 /Columns 4 >>".to_vec(); + let doc = marked(Spec { extra: vec![packed, parms], ..Spec::default() }); + let snap = Snapshot::capture(&doc, None).unwrap(); + let proof = snap.prove_saved(doc.bytes(), None, &ProofOptions::default()); + let s = surface(&proof, Surface::DecodedStreams); + assert_eq!(s.verdict, Verdict::Survivor, "{}", proof.to_text()); + assert!(s.locations.iter().any(|l| l == "object 7 (decoded)"), "{:?}", s.locations); + assert!(!proof.passed(), "{}", proof.to_text()); +} + #[test] fn a_survivor_in_an_unused_object_is_found() { // Object 7 is reachable from nothing: no catalog entry, no page resource. A reader that diff --git a/crates/redact/src/verify/extract.rs b/crates/redact/src/verify/extract.rs index f6db96229b..fe92e4e78d 100644 --- a/crates/redact/src/verify/extract.rs +++ b/crates/redact/src/verify/extract.rs @@ -58,7 +58,7 @@ impl Decoder { let mut d = Decoder { map: HashMap::new(), width: if composite { 2 } else { 1 }, opaque: false }; let tu = font.get(b"ToUnicode").map(|t| doc.resolve(t)); if let Some(Object::Stream(s)) = tu.as_deref() { - let data = ctx.decode(s)?; + let data = ctx.decode(doc, s)?; if data.len() > MAX_CMAP { return Err(()); } @@ -435,7 +435,7 @@ pub(super) fn page_content(doc: &Document, page: &Dict, ctx: &Ctx, skip_overlay: for o in &list { match &*resolve_strict(doc, o)? { Object::Stream(s) => { - let d = ctx.decode(s)?; + let d = ctx.decode(doc, s)?; if skip_overlay && ctx.is_overlay(&d) { continue; } @@ -536,7 +536,7 @@ impl Extractor<'_> { self.stack.push(r); } let result = (|| { - let data = self.ctx.decode(s)?; + let data = self.ctx.decode(self.doc, s)?; let own = s.dict.get(b"Resources").and_then(|r| self.doc.resolve(r).as_dict().cloned()); self.scope(&data, own.as_ref().unwrap_or(parent), ctm, depth, placed) })(); diff --git a/crates/redact/src/verify/geometry.rs b/crates/redact/src/verify/geometry.rs index fb69f91351..7ea0de5699 100644 --- a/crates/redact/src/verify/geometry.rs +++ b/crates/redact/src/verify/geometry.rs @@ -98,7 +98,7 @@ impl Geo<'_> { // own placement: where it lands comes from each `/Rect`, so the first // placement tells nothing about the others. Repeated decodes spend the // same bounded budget as everything else. - let Ok(data) = self.ctx.decode(&st) else { + let Ok(data) = self.ctx.decode(self.doc, &st) else { self.unverifiable("an annotation appearance can't be read in full"); continue; }; @@ -278,7 +278,7 @@ impl Geo<'_> { { return; } - let Ok(data) = self.ctx.decode(s) else { + let Ok(data) = self.ctx.decode(self.doc, s) else { self.unverifiable("a form under the region can't be decoded in full"); return; }; @@ -336,7 +336,7 @@ impl Geo<'_> { if depth > MAX_DEPTH { return Err(()); } - let data = self.ctx.decode(s)?; + let data = self.ctx.decode(self.doc, s)?; let own = s.dict.get(b"Resources").and_then(|r| self.doc.resolve(r).as_dict().cloned()); let res = own.as_ref().unwrap_or(parent); let xobjects = res_dict(self.doc, res, b"XObject"); @@ -464,7 +464,7 @@ pub(super) fn image_cleared(doc: &Document, ctx: &Ctx, s: &Stream, ctm: &Matrix, if !matches!(bpc, 1 | 2 | 4 | 8 | 16) || ncomp == 0 || ncomp > 32 { return Err(()); } - let data = ctx.decode(s)?; + let data = ctx.decode(doc, s)?; let bits = ncomp.checked_mul(bpc).ok_or(())?; let row = w.checked_mul(bits).ok_or(())?.div_ceil(8); if data.len() < row.checked_mul(h).ok_or(())? { diff --git a/crates/redact/src/verify/mod.rs b/crates/redact/src/verify/mod.rs index b374f93fdc..3a36c9e08e 100644 --- a/crates/redact/src/verify/mod.rs +++ b/crates/redact/src/verify/mod.rs @@ -252,7 +252,7 @@ pub struct ProofOptions { #[cfg(test)] pub(crate) mod tests_support { use pdfcraft_content::parse; - use pdfcraft_cos::{Dict, Object, Stream}; + use pdfcraft_cos::{Dict, Document, Object, Stream}; use super::extract::{carries_glyphs, parse_cmap}; use super::limits::{MAX_STREAM, MAX_TOTAL_DECODED, MAX_WORK, MIN_STRUCTURED_NEEDLE}; @@ -308,9 +308,9 @@ pub(crate) mod tests_support { pub fn decode_budget_runs_out() -> (bool, bool) { let ctx = Ctx::new(Default::default()); let s = Stream::from_raw(Dict::new(), vec![0u8; 100]); - let before = ctx.decode(&s).is_ok(); + let before = ctx.decode(&Document::new_empty(), &s).is_ok(); let spent = Ctx::with_decode_left(50); - (before, spent.decode(&s).is_err()) + (before, spent.decode(&Document::new_empty(), &s).is_err()) } /// Does a decode that fails past the one-stream cap pay for the attempt, so the same bomb @@ -322,9 +322,9 @@ pub(crate) mod tests_support { drop(big); let small = Stream::from_raw(Dict::new(), vec![0u8; 100]); let ctx = Ctx::with_decode_left(u64::try_from(MAX_STREAM + 50).unwrap_or(u64::MAX)); - let refused = ctx.decode(&bomb).is_err(); + let refused = ctx.decode(&Document::new_empty(), &bomb).is_err(); let charged = ctx.decoded.get() == MAX_TOTAL_DECODED - 50; - let gone = ctx.decode(&small).is_err(); + let gone = ctx.decode(&Document::new_empty(), &small).is_err(); (refused, charged, gone) } diff --git a/crates/redact/src/verify/snapshot.rs b/crates/redact/src/verify/snapshot.rs index fe74571bb4..8421fc1136 100644 --- a/crates/redact/src/verify/snapshot.rs +++ b/crates/redact/src/verify/snapshot.rs @@ -200,7 +200,7 @@ impl Snapshot { for r in created { let obj = doc.try_get(r.num).map_err(|_| RedactError::OverlayUnreadable)?; let Object::Stream(s) = &*obj else { continue }; - let data = ctx.decode(s).map_err(|_| RedactError::OverlayUnreadable)?; + let data = ctx.decode(doc, s).map_err(|_| RedactError::OverlayUnreadable)?; digests.insert(sha256(&[data])); } self.overlay = digests; diff --git a/crates/redact/src/verify/sweep.rs b/crates/redact/src/verify/sweep.rs index 73b44c005d..3c607ed8e7 100644 --- a/crates/redact/src/verify/sweep.rs +++ b/crates/redact/src/verify/sweep.rs @@ -495,7 +495,7 @@ fn sweep_objects(needles: &[Needle], doc: &Document, depth: usize, tag: &str, ac for n in acc.bytes(Surface::DecodedStreams, needles, &s.raw, MIN_STRUCTURED_NEEDLE) { acc.survivor(Surface::DecodedStreams, format!("{loc} (raw)"), Some(n)); } - match acc.ctx.decode(s) { + match acc.ctx.decode(doc, s) { Ok(d) => { let loc = format!("{loc} (decoded)"); for (n, c) in acc.counts(Surface::DecodedStreams, needles, &d, MIN_STRUCTURED_NEEDLE) { @@ -508,7 +508,7 @@ fn sweep_objects(needles: &[Needle], doc: &Document, depth: usize, tag: &str, ac Err(()) => acc.raw_only(Surface::DecodedStreams), } } else { - match acc.ctx.decode(s) { + match acc.ctx.decode(doc, s) { // The boxes and labels this run drew are not text to look for. Ok(d) if acc.ctx.is_overlay(&d) => overlay_raw.push(s.raw.to_vec()), Ok(d) => { @@ -578,7 +578,7 @@ fn sweep_attachments(needles: &[Needle], doc: &Document, embedded: &BTreeSet p, Err(()) => { acc.problem(Surface::EmbeddedFiles, format!("{loc} can't be decoded in full")); @@ -742,7 +742,7 @@ pub(super) fn sweep_xmp(needles: &[Needle], doc: &Document, tag: &str, acc: &mut acc.problem(Surface::Xmp, format!("{loc} is not a stream")); return; }; - let Ok(data) = acc.ctx.decode(s) else { + let Ok(data) = acc.ctx.decode(doc, s) else { acc.problem(Surface::Xmp, format!("{loc} can't be decoded in full")); return; }; diff --git a/crates/redact/src/verify/util.rs b/crates/redact/src/verify/util.rs index 77c4309cdd..2051ae15a5 100644 --- a/crates/redact/src/verify/util.rs +++ b/crates/redact/src/verify/util.rs @@ -33,11 +33,12 @@ impl Ctx { /// attempt is charged to the cumulative allowance, failures included: a decode that fails /// past the one-stream cap pays the cap, and one that fails mid-way pays what it decoded /// before the error — the work of finding out is not work a repeated reference gets for free. - pub(super) fn decode(&self, s: &Stream) -> Result, ()> { + /// `/DecodeParms` given by reference are read in `doc`, as redaction itself reads them. + pub(super) fn decode(&self, doc: &Document, s: &Stream) -> Result, ()> { let left = MAX_TOTAL_DECODED.saturating_sub(self.decoded.get()); let cap = usize::try_from(left).unwrap_or(usize::MAX).min(MAX_STREAM); let mut budget = DecodeBudget::new(cap, usize::MAX); - let result = budget.decode_strict(s); + let result = budget.decode_strict(doc, s); // The attempt's own accounting covers a success (what it decoded), a stream cut short // by corruption (what it decoded first) and one refused past the cap (the cap): charge // it through, whichever way the attempt went. From 0916bec6effba931a260da5215906699d6946233 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 13:15:45 +0100 Subject: [PATCH 22/27] M2.6: a font's streams read /DecodeParms given by reference The redaction proof now reads a font's /ToUnicode with its /DecodeParms given by reference, and `Metrics::from_dict`, which redaction takes the removed text from, read it only with direct ones. Behind a predictor so described, the two got different text for the same codes, so a copy the redaction rightly left counted as a survivor and the proof failed a sound redaction (`a_unicode_map_behind_parameters_given_by_reference_is_read_alike`). Every stream `from_dict` reads now goes through `Document::with_direct_parms`: the /ToUnicode; a composite font's /Encoding, which sets how its codes split (`cmap_streams_read_their_parameters_given_by_reference`); a subset's program and /CIDToGIDMap, which say which of its CIDs have a glyph (`a_subsets_program_and_cid_map_read_their_parameters_given_by_reference`); and the program a simple font without /Widths is measured with (`a_font_without_widths_measures_with_its_embedded_program`). Read without them, as on `main`, such a font was measured with widths guessed from its name, so redaction removed glyphs the mark doesn't cover and left some it does, and the proof, which also guesses from the name, passed. Redaction now measures such a font with its program. The proof still guesses, so it fails that redaction, as it already does on `main` when the program's parameters are direct. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/fonts/src/pdf.rs | 13 +-- crates/fonts/src/pdf/tests.rs | 133 ++++++++++++++++++++++++++---- crates/redact/src/tests_verify.rs | 21 +++++ 3 files changed, 144 insertions(+), 23 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 240b1c4d55..e9a909ea5c 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -156,13 +156,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() }; let program = doc.resolve(&program); let Object::Stream(stream) = program.as_ref() else { return Vec::new() }; - let Ok(bytes) = stream.decoded_within(16 << 20) else { return Vec::new() }; + let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { return Vec::new() }; let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() }; let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start); match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() { None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))), Some(Object::Stream(map)) => { - let Ok(map) = map.decoded_within(1 << 17) else { return Vec::new() }; + let Ok(map) = doc.with_direct_parms(map).decoded_within(1 << 17) else { return Vec::new() }; bit_set_below_64k( map.as_chunks::<2>() .0 @@ -189,7 +189,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); if upem == 0.0 { @@ -691,7 +691,7 @@ impl Metrics { m.codes = Codes::Two; if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) && let Object::Stream(s) = &*e - && let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) + && let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { @@ -1293,7 +1293,10 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { Some(Object::Stream(s)) => { - if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { + // With `/DecodeParms` given by reference, as every stream of the font is read here, + // and as the redaction proof reads the map: the two must get the same text, or a copy + // redaction rightly left counts as a survivor. + if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index c2acef191c..69c309c0e9 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -104,6 +104,18 @@ fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { d } +/// `data` behind Flate and a PNG predictor whose `/DecodeParms` is a reference: read without +/// it, every row of four bytes keeps its tag byte. +fn behind_parms_by_reference(doc: &mut Document, data: &[u8]) -> Object { + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(4))]))); + let mut data = data.to_vec(); + data.resize(data.len().next_multiple_of(4), 0); + let rows: Vec = data.chunks(4).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect(); + let mut stream = Stream::flate(Dict::new(), &rows); + stream.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + Object::Ref(doc.add(Object::Stream(stream))) +} + #[test] fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { use pdfcraft_cos::Stream; @@ -124,34 +136,46 @@ fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { } /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name -/// ("Mono" would mean Courier, 600 units for every glyph). +/// ("Mono" would mean Courier, 600 units for every glyph), the program's `/DecodeParms` given +/// directly or by reference. #[test] fn a_font_without_widths_measures_with_its_embedded_program() { use pdfcraft_cos::Stream; use skrifa::MetadataProvider as _; - let mut doc = Document::new_empty(); let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let face = skrifa::FontRef::new(bytes).unwrap(); let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); let upem = f64::from(face.metrics(size, location).units_per_em); let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("TrueType")), - ("BaseFont", Object::name("HouseMono")), - ("Encoding", Object::name("WinAnsiEncoding")), - ("FontDescriptor", Object::Dict(descriptor)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - for c in ['i', 'l', 'W', ' '] { - assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + for by_reference in [false, true] { + let mut doc = Document::new_empty(); + let program = if by_reference { + behind_parms_by_reference(&mut doc, bytes) + } else { + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), bytes)))) + }; + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), program); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!( + (m.width(u32::from(c)) - advance(c)).abs() < 1e-9, + "{c}, by reference {by_reference}: {} vs {}", + m.width(u32::from(c)), + advance(c) + ); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6, by reference {by_reference}"); } - assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); } #[test] @@ -342,6 +366,79 @@ fn cmap_streams_decode_within_a_size_cap() { } } +#[test] +fn cmap_streams_read_their_parameters_given_by_reference() { + // Both maps behind a predictor whose `/DecodeParms` is a reference: read without it, every + // row keeps its tag byte inside the map's tokens. + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; + let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; + let mut doc = Document::new_empty(); + let (tu, enc) = (behind_parms_by_reference(&mut doc, to_unicode), behind_parms_by_reference(&mut doc, encoding)); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let f = font( + &mut doc, + vec![("Subtype", Object::name("Type0")), ("Encoding", enc), ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), ("ToUnicode", tu)], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24]), "A", "ToUnicode"); + assert!(matches!(m.codes, Codes::Ranges(_)), "Encoding"); +} + +/// A Type 0 subset over Inter with `/DW` alone, whose CIDs 1 to 7 draw "A B C D" (ToUnicode +/// gives CIDs 1 and 2), its program and its `/CIDToGIDMap` each behind a predictor whose +/// `/DecodeParms` is a reference, or behind Flate alone. +fn inter_subset(program_by_reference: bool, map_by_reference: bool) -> Metrics { + use skrifa::MetadataProvider as _; + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let gid = |c: char| u16::try_from(face.charmap().map(c).unwrap().to_u32()).unwrap(); + let gids: Vec = std::iter::once(0).chain("A B C D".chars().map(gid)).flat_map(u16::to_be_bytes).collect(); + let stream = |doc: &mut Document, data: &[u8], by_reference: bool| { + if by_reference { behind_parms_by_reference(doc, data) } else { Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data)))) } + }; + let mut doc = Document::new_empty(); + let program = stream(&mut doc, bytes, program_by_reference); + let map = stream(&mut doc, &gids, map_by_reference); + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 2 beginbfchar <0001> <0041> <0002> <0020> endbfchar"; + let to_unicode = stream(&mut doc, to_unicode, false); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), program); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"DW".to_vec(), Object::Int(600)); + desc.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + desc.set(b"CIDToGIDMap".to_vec(), map); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("ABCDEF+Inter")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", to_unicode), + ], + ); + Metrics::from_dict(&doc, &f) +} + +/// A subset with `/DW` alone has a glyph for a CID when the program's glyph it maps to has an +/// outline, so the space, Inter's empty glyph, is refused. Its program, or its `/CIDToGIDMap`, +/// behind parameters given by reference says the same: read without them, no program says +/// which glyphs are empty, or the map points the CIDs at other glyphs. +#[test] +fn a_subsets_program_and_cid_map_read_their_parameters_given_by_reference() { + let direct = inter_subset(false, false); + assert!(!direct.outlined_cids.is_empty()); + assert_eq!(direct.encode("A").as_deref(), Some(&[0, 1][..])); + assert_eq!(direct.encode(" "), None, "the space's glyph is empty"); + for (program, map) in [(true, false), (false, true)] { + let m = inter_subset(program, map); + assert_eq!(m.outlined_cids, direct.outlined_cids, "program by reference: {program}, map: {map}"); + assert_eq!(m.encode(" "), None, "program by reference: {program}, map: {map}"); + } +} + #[test] fn composite_fonts_use_to_unicode() { let mut doc = Document::new_empty(); diff --git a/crates/redact/src/tests_verify.rs b/crates/redact/src/tests_verify.rs index dc3c3c89d9..28853cc4a4 100644 --- a/crates/redact/src/tests_verify.rs +++ b/crates/redact/src/tests_verify.rs @@ -350,6 +350,27 @@ fn a_survivor_behind_parameters_given_by_reference_is_found() { assert!(!proof.passed(), "{}", proof.to_text()); } +#[test] +fn a_unicode_map_behind_parameters_given_by_reference_is_read_alike() { + // The font's /ToUnicode (object 7) reads codes A..H as "SECRETVA", behind Flate and a PNG + // predictor whose `/DecodeParms` is object 8, a reference. The page draws the codes twice, + // once under the mark. Redaction takes the removed text from that map, and the proof the page + // text it allows for: read by one and not the other, the copy that stays counted as a + // survivor of a sound redaction. + let cmap = b"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapName /Test def\n1 begincodespacerange\n<00> \nendcodespacerange\n8 beginbfchar\n<41> <0053>\n<42> <0045>\n<43> <0043>\n<44> <0052>\n<45> <0045>\n<46> <0054>\n<47> <0056>\n<48> <0041>\nendbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"; + let mut data = cmap.to_vec(); + data.resize(data.len().next_multiple_of(4), b' '); + let rows: Vec = data.chunks(4).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect(); + let to_unicode = stream("/Filter /FlateDecode /DecodeParms 8 0 R", Stream::flate(Dict::new(), &rows).raw.as_ref()); + let parms = b"<< /Predictor 12 /Columns 4 >>".to_vec(); + let content = b"BT /F1 10 Tf 10 200 Td (ABCDEFGH) Tj ET BT /F1 10 Tf 10 100 Td (ABCDEFGH) Tj ET"; + let mut doc = marked_content(content, Spec { extra: vec![to_unicode, parms], ..Spec::default() }); + doc.update_dict(ObjRef::new(5, 0), |font| font.set(b"ToUnicode".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + let (result, proof) = run(&mut doc); + assert!(result.is_ok(), "{result:?}"); + assert!(proof.passed(), "{}", proof.to_text()); +} + #[test] fn a_survivor_in_an_unused_object_is_found() { // Object 7 is reachable from nothing: no catalog entry, no page resource. A reader that From 86cb375869428d0a69c0638321bfde366fb117c1 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 18:41:02 +0100 Subject: [PATCH 23/27] M2.6: edits stop at a content stream that doesn't decode, as at damage An edit was refused after a content stream damaged partway, since what the damage hides can change how the later streams read, the graphics state they draw in among it. A stream that doesn't decode at all hides all of its operators, and an edit after one went ahead. It now stops there too: text lines and blocks, and images (arrange, crop, transform, move), as selecting objects on `main` already does for a page with such a stream. An edit in a stream before it goes on, and the stream is kept as stored. A file may give a stream's /Filter by reference, which isn't read here, so the case needs no damage: `content_after_a_stream_that_does_not_decode_is_not_edited` has such a stream move the text and image after it with a `cm`. `a_content_stream_that_does_not_decode_is_kept_and_edited_around` becomes `..._kept_and_edited_before`, its edit now in the stream before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/edit/src/tests.rs | 43 ++++++++++++++++++++++++++++++----- crates/edit/src/text/lines.rs | 20 ++++++++-------- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f3b4e3cc2f..5f9787a8e6 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5191,7 +5191,7 @@ fn an_edit_never_rewrites_a_content_stream_damaged_partway() { assert!(refused.to_string().contains("is damaged partway"), "{refused}"); assert_eq!(contents(&doc), listed); let refused = text::replace_line(&mut doc, 0, 1, "Edited").unwrap_err(); - assert!(refused.to_string().contains("comes after a content stream that is damaged partway"), "{refused}"); + assert!(refused.to_string().contains("comes after a content stream that is damaged or can't be decoded"), "{refused}"); assert_eq!(contents(&doc), listed); let mut doc = split_page("BT /F1 12 Tf 72 600 Td (Whole) Tj ET", ""); @@ -5244,25 +5244,56 @@ fn a_content_stream_that_does_not_decode_is_never_written_over() { } /// A content stream that doesn't decode at all is no damage to write over: it is kept as stored, -/// and the page's other streams are read and edited around it. A stream of a few MB is read for +/// and the page's streams before it are read and edited. A stream of a few MB is read for /// selecting objects. #[test] -fn a_content_stream_that_does_not_decode_is_kept_and_edited_around() { - let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"); +fn a_content_stream_that_does_not_decode_is_kept_and_edited_before() { + let mut doc = split_page("BT /F1 12 Tf 72 600 Td (Whole) Tj ET", ""); let mut dict = pdfcraft_cos::Dict::new(); dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); let unknown = pdfcraft_cos::Stream::from_raw(dict, b"BT /F1 12 Tf 72 700 Td (Unread) Tj ET".to_vec()); - doc.set(pdfcraft_cos::ObjRef::new(4, 0), unknown.clone()); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), unknown.clone()); text::replace_line(&mut doc, 0, 0, "Edited").unwrap(); let doc = reopen(&doc); assert_eq!(text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(), ["Edited"]); - let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(4, 0)) else { panic!("not a stream") }; + let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(5, 0)) else { panic!("not a stream") }; assert_eq!(kept.raw, unknown.raw, "stored as it was"); let large = format!("{}0 0 m 10 10 l S", " ".repeat(2 << 20)); assert!(editable_objects(&split_page(&large, "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"), 0).is_ok()); } +/// A content stream that doesn't decode hides its operators as damage does. Here its `/Filter` is +/// given by reference, which a file may do and which isn't read here, and it holds a `cm` that +/// moves what the page draws after it. Text and images there aren't edited, as the edit would be +/// worked out without the `cm`. +#[test] +fn content_after_a_stream_that_does_not_decode_is_not_edited() { + use pdfcraft_cos::{Dict, ObjRef, Stream}; + let hidden_move = |doc: &mut Document| { + let filter = doc.add(Object::name("FlateDecode")); + let mut s = Stream::flate(Dict::new(), b"1 0 0 1 0 -300 cm"); + s.dict.set(b"Filter".to_vec(), Object::Ref(filter)); + doc.set(ObjRef::new(4, 0), s); + }; + let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned(); + + let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (After) Tj ET"); + hidden_move(&mut doc); + let listed = contents(&doc); + let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err(); + assert!(refused.to_string().contains("comes after a content stream that is damaged or can't be decoded"), "{refused}"); + assert_eq!(contents(&doc), listed); + + let mut doc = two_stream_image_page("", "q 100 0 0 100 200 200 cm /Im1 Do Q"); + hidden_move(&mut doc); + let listed = contents(&doc); + let shift = images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 0.0]); + let refused = images::change_image(&mut doc, 0, 0, &shift).unwrap_err(); + assert!(refused.to_string().contains("comes after"), "{refused}"); + assert_eq!(contents(&doc), listed); +} + /// A content stream whose `/DecodeParms`, or a parameter in them, is given by reference is read /// through the reference: its text is listed and edited, not read with the predictor's tag bytes /// left in it. diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 58b4ac6289..bc0812e00d 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -148,29 +148,31 @@ pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> } /// An error when content at `/Contents` index `at` (a stream there, or one inserted there) is drawn -/// after a stream damaged partway: what the damage hides can change how it reads. +/// after a stream not read whole, damaged partway or not decoding at all: what wasn't read can +/// change how it reads, the graphics state it draws in among it. pub(crate) fn drawn_before_damage(streams: &[ContentStream], at: usize) -> Result<(), EditError> { if at > first_damaged(streams) { return Err(EditError::Invalid( - "this change comes after a content stream that is damaged partway, and what the damage hides can change how the rest of the page reads" + "this change comes after a content stream that is damaged or can't be decoded, and what wasn't read can change how the rest of the page reads" .into(), )); } Ok(()) } -/// The index of the first stream damaged partway, or the number of streams. +/// The index of the first stream not read whole, or the number of streams. fn first_damaged(streams: &[ContentStream]) -> usize { - streams.iter().position(|s| s.read == Read::Damaged).unwrap_or(streams.len()) + streams.iter().position(|s| s.read != Read::Whole).unwrap_or(streams.len()) } /// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams /// (keeping their dictionaries), the others stay as they were. An error, before anything is -/// added, when a piece that changed is a stream that didn't decode whole, or comes after one -/// damaged partway. Written from what decoded, a damaged stream would lose everything after the -/// damage, and one that doesn't decode all of it. What follows the damage is unknown, so a later -/// stream may not read as it seems: a string or comment the damage left open runs on into it, and -/// operators the damage hides can change the graphics state it draws in. +/// added, when a piece that changed is a stream that didn't decode whole, or comes after one. +/// Written from what decoded, a damaged stream would lose everything after the damage, and one +/// that doesn't decode all of it. What follows the damage is unknown, so a later stream may not +/// read as it seems: a string or comment the damage left open runs on into it, and operators the +/// damage hides can change the graphics state it draws in. A stream that doesn't decode hides its +/// operators all the same. pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[ContentStream], new: Vec>) -> Result { // The last stream that changed: when it is drawn before the damage, so are the others. if let Some(i) = streams.iter().zip(&new).rposition(|(s, data)| s.data != *data) { From ae677644e0b54860a16b2053a0044e19366df18d Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 18:41:21 +0100 Subject: [PATCH 24/27] M2.6: Extract and Split prune nothing past the form depth limit Pruning reads forms 8 deep. Past that a form's /XObject dictionary was kept whole, but the forms it draws weren't read, nor what they draw through resources of their own. When those resources are shared with content that was read, they were cut to the names that content draws, and the images only the deep forms draw were dropped from the part. This was so on `main` for a form read whole, and in this change's reading of a damaged form too. A walk the depth limit stops now leaves the file unpruned, as an overlarge Type 3 resource graph already does: `nothing_is_pruned_past_the_depth_limit` (a form 9 deep, whole or damaged partway, drawing a form that draws through resources the second page shares). That also settles a form first reached past the limit and drawn again higher up, whose second reading `forms_seen` skips. Down to the limit pruning goes on, under a page whole or damaged partway and with the form 8 deep whole or damaged: `pruning_goes_on_down_to_the_depth_limit`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/organize/src/prune.rs | 22 ++++++---- crates/organize/src/tests.rs | 78 ++++++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index 85ddb5f743..b771b07da9 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -40,12 +40,15 @@ enum Owner { struct Pruner<'a> { doc: &'a Document, used: HashMap>>, - /// Dictionaries whose drawn names can't be known for certain (undecodable content, nesting - /// past the depth limit, Type 3 glyphs drawing through them). They are left untouched: - /// pruning must never drop an XObject something still draws. + /// Dictionaries whose drawn names can't be known for certain (undecodable content, Type 3 + /// glyphs drawing through them). They are left untouched: pruning must never drop an XObject + /// something still draws. unsure: HashSet, /// An overlarge glyph-resource graph is not safe to prune at all. glyph_budget_exhausted: bool, + /// Nor is a file with forms nested past [`MAX_FORM_DEPTH`]: what the forms there draw isn't + /// read, and they may draw it through resources shared with content that was. + too_deep: bool, glyph_work_left: usize, glyph_seen: HashSet, /// An indirect font's explicit resources have one owner regardless of which page uses it. @@ -188,7 +191,7 @@ impl Pruner<'_> { return; }; if depth > MAX_FORM_DEPTH { - self.unsure.insert(key); + self.too_deep = true; return; } for op in pdfcraft_content::parse(data).ops { @@ -209,7 +212,11 @@ impl Pruner<'_> { self.protect_type3_resources(resources, owner); let Some((xobjects, key)) = self.xobjects(resources, owner) else { return }; self.unsure.insert(key); - if depth > MAX_FORM_DEPTH || !self.partly_read.insert(owner) { + if depth > MAX_FORM_DEPTH { + self.too_deep = true; + return; + } + if !self.partly_read.insert(owner) { return; } for (name, _) in xobjects.iter() { @@ -341,6 +348,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result Result pruner.unread(&res_dict, owner, 0), } } - let Pruner { used, unsure, glyph_budget_exhausted, .. } = pruner; - if glyph_budget_exhausted { + let Pruner { used, unsure, glyph_budget_exhausted, too_deep, .. } = pruner; + if glyph_budget_exhausted || too_deep { return Ok(0); } let mut removed = 0; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index dfd9eecf35..7eb677a84c 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2733,8 +2733,8 @@ fn extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources( } /// Forms are read 8 deep (`prune.rs`'s `MAX_FORM_DEPTH`). A damaged form that deep still has -/// every form it names read as drawn, and one drawing through resources another page shares keeps -/// what it draws from them. +/// every form it names read as drawn: here `/Nested`, which draws through resources another page +/// shares. Read 9 deep, it leaves the file unpruned, so what it draws from them stays. #[test] fn a_damaged_form_at_the_depth_limit_has_its_forms_read() { use pdfcraft_cos::ObjRef; @@ -2760,8 +2760,78 @@ fn a_damaged_form_at_the_depth_limit_has_its_forms_read() { assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{names:?}"); } +/// Past the depth limit a form isn't read, nor what the forms it draws draw: one of those may draw +/// through resources another page shares, and keep images there that page doesn't draw, so nothing +/// is pruned. Here `/Fm` is 9 deep, whole or damaged partway, and `/Nested`, which it draws, draws +/// `/ImA` through the resources the second page draws `/ImB` through. +#[test] +fn nothing_is_pruned_past_the_depth_limit() { + use pdfcraft_cos::ObjRef; + let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec()); + for (what, fm) in [("whole", whole(b"/Nested Do")), ("damaged", damaged_flate(b"0 g\n", b"/Nested Do"))] { + let mut src = doc_shared_form_resources(whole(b"/C Do"), fm, whole(b"/ImA Do")); + // Eight forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 9 deep. + let mut next = ObjRef::new(7, 0); + for _ in 0..8 { + let mut form = whole(b"/C Do"); + form.dict.set(b"Subtype".to_vec(), Object::name("Form")); + form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect(); + form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect())); + next = src.add(Object::Stream(form)); + } + let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect(); + src.update_dict(ObjRef::new(3, 0), |p| { + p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect())) + }) + .unwrap(); + let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()); + let names = xobject_names(&part, 1); + assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{what}: {names:?}"); + } +} + +/// Down to the depth limit pruning goes on: under a page whole or damaged partway, a form 8 deep, +/// whole or damaged, is read, and the image nothing draws, `/ImA`, still goes from the resources +/// the second page draws `/ImB` through. +#[test] +fn pruning_goes_on_down_to_the_depth_limit() { + use pdfcraft_cos::ObjRef; + let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec()); + let cases = [ + ("whole", whole(b"/C Do"), whole(b"0 g")), + ("damaged form", whole(b"/C Do"), damaged_flate(b"0 g\n", b"0 g")), + ("damaged page", damaged_flate(b"/C Do\n", b"0 g"), whole(b"0 g")), + ]; + for (what, page, fm) in cases { + let mut src = doc_shared_form_resources(page, fm, whole(b"")); + // `/Fm` names an image, not `/Nested`: nothing is read 9 deep. + let image: Dict = [(b"Img".to_vec(), Object::Ref(ObjRef::new(9, 0)))].into_iter().collect(); + let resources = Object::Dict([(b"XObject".to_vec(), Object::Dict(image))].into_iter().collect()); + src.update_dict(ObjRef::new(7, 0), |f| f.set(b"Resources".to_vec(), resources)).unwrap(); + // Seven forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 8 deep. + let mut next = ObjRef::new(7, 0); + for _ in 1..8 { + let mut form = whole(b"/C Do"); + form.dict.set(b"Subtype".to_vec(), Object::name("Form")); + form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect(); + form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect())); + next = src.add(Object::Stream(form)); + } + let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect(); + src.update_dict(ObjRef::new(3, 0), |p| { + p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect())) + }) + .unwrap(); + let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()); + assert_eq!(xobject_names(&part, 1), [b"ImB".to_vec()], "{what}"); + } +} + /// A chain of forms nested far past the depth limit, whole or each damaged partway, is read only -/// down to it: the walk's depth stays bounded however long the chain is, and pruning goes on. +/// down to it: the walk's depth stays bounded however long the chain is, and the run ends without +/// an error, having pruned nothing. #[test] fn a_long_chain_of_forms_is_read_only_to_the_depth_limit() { use pdfcraft_cos::ObjRef; @@ -2779,7 +2849,7 @@ fn a_long_chain_of_forms_is_read_only_to_the_depth_limit() { names = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect(); } doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Resources".to_vec(), xobjects(names))).unwrap(); - assert!(crate::prune::prune_unused_xobjects(&mut doc).is_ok(), "damaged: {damaged}"); + assert!(matches!(crate::prune::prune_unused_xobjects(&mut doc), Ok(0)), "damaged: {damaged}"); } } From 569f631f2b074784bf479020fce8fb862347fda9 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 20:18:07 +0100 Subject: [PATCH 25/27] M2.6: an image's move changes no state a stream not read whole draws in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving or resizing an image in a stream before one damaged partway, or one that doesn't decode, goes on, as the edit changes no stream past it. But the image's `q cm … Q` can close past that stream, and what the stream draws inside the scope is unknown. When the image looked alone there, the move changed the scope's `cm` in place, and anything the stream draws in it moved too: a square drawn there in the image's `cm` moved with the image, as on `main`. A scope still open after such a stream now counts as one that paints something else (`first_op_after_damage`, read by `image_scopes` and `enclosing_transform`). An operator counts by its keyword there: a page may split its content between an operator's operands and its keyword, so a `Q` whose operands come before the hidden stream still closes after it. The move gives the `Do` a `cm` of its own and leaves the scope's as it was; with a clip on the image, the clip stays, as one the stream may share. A crop goes around the `Do` alone rather than at the scope's start, where it would clip what the stream draws. A crop PdfCraft put in such a scope isn't removed, and the refusal says why rather than that there is no crop. Arrange is unchanged: it takes out the scope it finds, which changes the stream past it and is refused. `an_image_whose_scope_closes_past_a_stream_not_read_whole_moves_alone`: a stream that doesn't decode and one damaged, each also with the square's operands before what is hidden (ending the stream before, or in the damaged stream before its damage); the image with no clip, a clip, and a PdfCraft crop; moved, cropped, and its crop removed. A scope that closes in a damaged stream before its damage still takes the move in its `cm`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/edit/src/images.rs | 57 ++++++++++++++++++-------- crates/edit/src/tests.rs | 77 +++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 4 +- crates/edit/src/text/lines.rs | 9 ++++ 4 files changed, 128 insertions(+), 19 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 5e5c44fab7..fbb37ec1f0 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -527,12 +527,17 @@ fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> { /// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced /// `q … Q` scope), with the index and value of that `cm`. Another transform of the same /// placement is folded into it, so repeated moves and resizes don't nest one more `q` each. -fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> { +/// None when its `Q` is at or past `hidden` ([`crate::text::first_op_after_damage`]): a stream +/// between that wasn't read may draw in that `cm` too. +fn enclosing_transform(ops: &[Op], start: usize, end: usize, hidden: usize) -> Option<(usize, Matrix)> { let at = start.checked_sub(1)?; let cm = ops.get(at)?; if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 { return None; } + if end.checked_add(1)? >= hidden { + return None; + } let m = Matrix(cm.nums::<6>()?); m.0.iter().all(|v| v.is_finite()).then_some((at, m)) } @@ -1052,7 +1057,9 @@ fn determine_arrange_spans( has_other_paint: bool, marked_unit: bool, ) -> Result<(std::ops::Range, usize, usize, usize), EditError> { - let scope = image_scopes(ops, target, "arranged") + // Nothing is hidden for Arrange: it takes out the scope it finds, so a scope that closes past a + // stream not read whole changes a stream past it, which `rewritten_contents` refuses. + let scope = image_scopes(ops, target, "arranged", ops.len()) .ok() .and_then(|s| s.isolated) .filter(|&(start, end, _)| start >= target_unit.start && end < target_unit.end); @@ -1491,8 +1498,10 @@ impl ImageScopes { } } -/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. -fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result { +/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. A scope +/// still open at op `hidden` ([`crate::text::first_op_after_damage`]) holds a stream that wasn't +/// read, which may paint there: it counts as painting something else. +fn image_scopes(ops: &[Op], target: usize, done: &str, hidden: usize) -> Result { struct Scope { start: usize, matrix: Matrix, @@ -1511,6 +1520,11 @@ fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result