diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index ed36a8d5e..63d9220cc 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -500,3 +500,32 @@ fn forms_nested_past_depth_limit_fail_closed() { let result = audit_redactions(&open(nested_forms(33))); assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } + +#[test] +fn comment_across_streams_neither_invents_text_nor_a_cover() { + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Visible text) Tj ET % note", b"0 g 70 690 200 40 re f")); + assert!(audit_redactions(&doc).unwrap().is_empty(), "commented cover must not be reported"); + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET")); + let text: String = pdfcraft_edit::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect(); + assert!(!text.contains("After comment"), "editor exposed phantom text: {text}"); +} + +#[test] +fn reading_blocks_respect_stream_comments_and_split_operands() { + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET")); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Before comment"); + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Split operand)", b"Tj ET")); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Split operand"); + // Marked content opened in one stream applies to text in the next: its /ActualText replaces + // the glyphs, as when the streams are read joined. + for (s1, s2) in [ + (b"BT /F1 24 Tf 72 700 Td /Span <> BDC (Glyphs) Tj EMC ET".as_slice()), + (b"BT /F1 24 Tf 72 700 Td /Span <> BDC", b"(Glyphs) Tj EMC ET"), + ] { + let doc = open(two_streams(s1, s2)); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); + } +} diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 32334e047..c7c8bca9e 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -60,6 +60,10 @@ pub struct Parsed { pub ops: Vec, /// Bytes that could not be read as operands or operators. pub skipped: usize, + /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before + /// and whether one runs out of it into the next. Only boundaries are recorded, so the size + /// depends on the number of pieces, not of comments. + comment_edges: Vec<(bool, bool)>, } fn is_ws(b: u8) -> bool { @@ -72,12 +76,25 @@ fn is_delim(b: u8) -> bool { /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { - let mut out = Parsed::default(); + parse_pieces(data, &[]) +} + +/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment +/// crosses. +fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { + let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() }; + let mut boundary = 0; let mut lx = Lexer::new(data, 0); let mut operands: Vec = Vec::new(); let mut start: Option = None; loop { + let trivia = lx.pos; lx.skip_ws(); + // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a + // string, a name, an array or dictionary operand or inline image data is not one. + if boundary < ends.len() { + comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges); + } let Some(&b) = data.get(lx.pos) else { break }; let at = lx.pos; let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); @@ -148,6 +165,39 @@ pub fn parse(data: &[u8]) -> Parsed { out } +/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and +/// comments). `boundary` is the first boundary not yet passed; boundaries only move forward. +fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], boundary: &mut usize, edges: &mut [(bool, bool)]) { + let mut at = trivia.start; + while at < trivia.end { + if data.get(at) != Some(&b'%') { + at += 1; + continue; + } + let comment = at; + while at < trivia.end && !matches!(data.get(at), Some(b'\r' | b'\n')) { + at += 1; + } + // The separator of piece `boundary` is the last byte before its end. + while let Some(&end) = ends.get(*boundary) { + let separator = end.saturating_sub(1); + if separator >= at { + break; + } + if separator >= comment { + if let Some(edge) = edges.get_mut(*boundary) { + edge.1 = true; + } + // Includes a comment whose end of line is the next piece's first byte. + if let Some(edge) = edges.get_mut(boundary.saturating_add(1)) { + edge.0 = true; + } + } + *boundary += 1; + } + } +} + /// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds /// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read. fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { @@ -305,7 +355,9 @@ pub fn serialize_ops(ops: &[Op]) -> Vec { /// A page's content streams joined into the one stream they are. Writers may split it between /// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its /// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back -/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together. +/// with [`Pieces::piece_of`]. A space separates the pieces, as renderers join them (hayro, +/// MuPDF and Poppler alike): tokens never run together, and a comment at the end of a piece +/// runs on into the next one, up to its first end of line. #[derive(Clone, Debug, Default, PartialEq)] pub struct Pieces { data: Vec, @@ -319,7 +371,7 @@ impl Pieces { let mut ends = Vec::with_capacity(pieces.len()); for p in pieces { data.extend_from_slice(p.as_ref()); - data.push(b'\n'); + data.push(b' '); ends.push(data.len()); } Pieces { data, ends } @@ -341,12 +393,22 @@ impl Pieces { /// Parse the pieces as one stream. pub fn parse(&self) -> Parsed { - parse(&self.data) + parse_pieces(&self.data, &self.ends) + } + + /// Per piece, from `parsed` (this piece set's [`Pieces::parse`]): whether a comment runs + /// into it from the piece before, and whether one runs out of it into the next. A writer + /// that rewrites a piece without its comments must keep both: end the incoming comment + /// before its first operator, and leave a comment open at its end. + pub fn comment_edges(&self, parsed: &Parsed) -> Vec<(bool, bool)> { + let mut edges = parsed.comment_edges.clone(); + edges.resize(self.len(), (false, false)); + edges } /// The piece holding joined position `at` (the last piece for positions past the end). pub fn piece_of(&self, at: usize) -> usize { - self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1)) + self.ends.partition_point(|&e| e <= at).min(self.ends.len().saturating_sub(1)) } /// The pieces an operator spans: (the one its first token is in, the one its keyword is in). diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 18e0b8cbc..1119bdaf9 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -283,3 +283,108 @@ fn pieces_parse_as_one_stream_and_splice_back() { assert_eq!(out.len(), 3); assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty()); } + +#[test] +fn stream_boundary_comment_matches_space_concatenation() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut tail = b"(HIDDEN) Tj".to_vec(); + tail.extend(eol); + tail.extend(b"(LIVE) Tj"); + let pieces = Pieces::join(&[b"BT % comment".as_slice(), b"(ALSO HIDDEN) Tj", &tail, b"ET"]); + let parsed = pieces.parse(); + let shown: Vec<_> = parsed.ops.iter().filter(|op| op.is("Tj")).collect(); + assert_eq!(shown.len(), 1); + assert_eq!(shown[0].operands[0].as_string().unwrap().bytes, b"LIVE"); + } +} + +#[test] +fn comment_edges_terminate_at_next_piece_byte_zero() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut next = eol.to_vec(); + next.extend_from_slice(b"q Q"); + let pieces = Pieces::join(&[b"% outgoing".as_slice(), next.as_slice()]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)], "EOL {eol:?}"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q"]); + } +} + +#[test] +fn comment_edges_span_empty_pieces_and_end_of_content() { + let pieces = Pieces::join(&[b"q % outgoing".as_slice(), b"", b"", b"ignored\nQ"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true), (true, false)]); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q"]); + + let pieces = Pieces::join(&[b"% outgoing".as_slice(), b"", b"still ignored"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true)]); + assert!(parsed.ops.is_empty()); + + let pieces = Pieces::join::<&[u8]>(&[]); + assert!(pieces.comment_edges(&pieces.parse()).is_empty()); +} + +#[test] +fn comment_edges_ignore_percent_inside_operands_and_image_data() { + for first in [ + b"(literal % not a comment) Tj".as_slice(), + b"/Percent#25Name MP", + b"[(literal % not a comment)] TJ", + b"/P << /ActualText (literal % not a comment) >> BDC EMC", + b"BI /W 1 /H 1 /BPC 8 /CS /G ID % EI", + ] { + let pieces = Pieces::join(&[first, b"q Q"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)], "{first:?}"); + assert_eq!(parsed.ops.last().unwrap().op, b"Q"); + assert_eq!(parsed.skipped, 0); + } +} + +#[test] +fn compound_operand_comment_split_is_owned_by_spanning_operator() { + for parts in [[b"[(A) % ignored".as_slice(), b"still ignored\n(B)] TJ"], [b"/P << /MCID 0 % ignored".as_slice(), b"still ignored\n>> BDC"]] { + let pieces = Pieces::join(&parts); + let parsed = pieces.parse(); + // The object lexer consumes this nested comment. Its containing operator spans + // both pieces, which forces both to be rewritten together by the redactor. + assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)]); + assert_eq!(parsed.ops.len(), 1); + assert_eq!(pieces.pieces_of(&parsed.ops[0]), (0, 1)); + assert_eq!(parsed.skipped, 0); + } +} + +#[test] +fn comment_edges_do_not_confuse_local_comments_with_boundary_comments() { + let pieces = Pieces::join(&[b"% local\nq % crossing".as_slice(), b"ignored\n% local again\nQ\n% final local\n", b"q Q"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false), (false, false)]); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q", b"q", b"Q"]); +} + +#[test] +fn comment_edge_storage_depends_on_piece_count_not_comment_count() { + let mut first = b"%\n".repeat(16_384); + first.extend_from_slice(b"q % crossing"); + let pieces = Pieces::join(&[first.as_slice(), b"ignored\nQ"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)]); + assert_eq!(parsed.ops.len(), 2); + // Also exercise public parsing without piece-boundary metadata. + assert_eq!(parse(&first).ops.len(), 1); +} + +#[test] +fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() { + // Joined layout: qQ; separator belongs to prior piece. + let pieces = Pieces::join(&[b"q".as_slice(), b"", b"Q"]); + for (at, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 2), (usize::MAX, 2)] { + assert_eq!(pieces.piece_of(at), expected, "position {at}"); + } + let empty = Pieces::join::<&[u8]>(&[]); + assert_eq!(empty.piece_of(0), 0); + assert_eq!(empty.piece_of(usize::MAX), 0); +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 515f17d8d..cef8f3c9d 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -327,7 +327,37 @@ fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Optio } const WRAP_OPEN: &[u8] = b"q %PdfCraft\n"; -const WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; +/// Starts with an end of line, so a comment left open at the end of the content before it +/// can't swallow the `Q`. Older files have the close without it. +const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n"; +const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; + +/// Whether `bytes` are a wrapper's close: [`WRAP_CLOSE`] after a `Q` for each `q` the wrapped +/// content left open, or [`OLD_WRAP_CLOSE`]. +fn is_wrap_close(bytes: &[u8]) -> bool { + let mut rest = bytes; + loop { + if rest == WRAP_CLOSE || rest == OLD_WRAP_CLOSE { + return true; + } + match rest.strip_prefix(b"\nQ".as_slice()) { + Some(shorter) => rest = shorter, + None => return false, + } + } +} + +/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)` +/// for a close. +fn wrapper_role(doc: &Document, o: &Object) -> Option { + if stream_tag(doc, o).as_deref() != Some("Wrap") { + return None; + } + let Object::Stream(stream) = &*doc.resolve(o) else { return None }; + // Wrappers are a few bytes; one that decodes to more isn't PdfCraft's. + let bytes = stream.decoded_within(64 * 1024).ok()?; + if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) } +} #[cfg(test)] fn stream_bytes(doc: &Document, o: &Object) -> Option> { @@ -350,27 +380,138 @@ fn tagged(tag: &str) -> Dict { d } +/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags, +/// which another writer may keep, copy or forge). +enum Isolation { + /// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths. + /// `suffix`: the stream from which the content leaves graphics state set (or a comment open) + /// that a mark on top must not inherit, so it needs a wrapper from there to the end. + /// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes. + Clean { suffix: Option, wrappers: Vec<(usize, usize)> }, + /// The content could not be read that precisely (it doesn't decode, is too large, or has + /// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is + /// the count of `q` left open at the end, when known. + Unknown { unclosed: usize }, +} + +const ISOLATION_MAX_STREAMS: usize = 100_000; +const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024; +const ISOLATION_MAX_OPS: usize = 1_000_000; +const ISOLATION_MAX_DEPTH: usize = 10_000; + +fn isolation(doc: &Document, list: &[Object]) -> Isolation { + let unknown = Isolation::Unknown { unclosed: 0 }; + if list.len() > ISOLATION_MAX_STREAMS { + return unknown; + } + let mut remaining = ISOLATION_MAX_BYTES; + let mut data = Vec::with_capacity(list.len()); + for object in list { + let resolved = doc.resolve(object); + let Object::Stream(stream) = &*resolved else { return unknown }; + let Ok(bytes) = stream.decoded_within(remaining) else { return unknown }; + let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown }; + remaining = left; + data.push(bytes); + } + let pieces = pdfcraft_content::Pieces::join(&data); + let parsed = pieces.parse(); + if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS { + return unknown; + } + let edges = pieces.comment_edges(&parsed); + let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false); + let mut stack: Vec = Vec::new(); + let mut split = 0; + let mut previous_last = None; + let mut wrappers = Vec::new(); + for op in &parsed.ops { + let (first, last) = pieces.pieces_of(op); + // A wrapper can only go in before a whole stream, at the top level, between operators, + // and where no comment runs into the stream (it would swallow the wrapper's `q`). + if stack.is_empty() + && !text_open + && !path_open + && !dirty + && previous_last.is_none_or(|previous| first > previous) + && !edges.get(first).is_some_and(|edge| edge.0) + { + split = first; + } + previous_last = Some(last); + match op.op.as_slice() { + b"BT" if !text_open => text_open = true, + b"ET" if text_open => text_open = false, + b"BT" | b"ET" => malformed = true, + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true, + b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false, + _ => {} + } + match op.op.as_slice() { + b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first), + b"Q" if op.operands.is_empty() => match stack.pop() { + Some(open) => { + // Only a standalone PdfCraft wrapper pair may later be removed again. + let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); + if wrap(open) + && wrap(first) + && data.get(open).is_some_and(|d| d == WRAP_OPEN) + && data.get(first).is_some_and(|d| is_wrap_close(d)) + { + wrappers.push((open, first)); + } + } + None => malformed = true, + }, + b"q" | b"Q" => malformed = true, + _ if stack.is_empty() => dirty = true, + _ => {} + } + } + if malformed || text_open || path_open || !stack.is_empty() { + return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() } }; + } + let comment_open = edges.last().is_some_and(|edge| edge.1); + Isolation::Clean { suffix: (dirty || comment_open).then_some(split), wrappers } +} + /// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the /// original content in q/Q). fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { place_tagged(doc, page, kind.tag(), content, behind) } -/// Put content on a page, tagged `tag` (see [`place`]). +/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the +/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including +/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of +/// content that leaves state set is wrapped in q/Q, once. fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> { let mut list = contents(doc, page)?; + let wrap = if behind { + None + } else { + match isolation(doc, &list) { + Isolation::Clean { suffix, .. } => suffix.map(|split| (split, 0)), + // Content that can't be read precisely is wrapped whole, as before: after the + // behind-marks at the start. A `q` it leaves open is closed at its end, where + // closing it changes nothing already drawn. + Isolation::Unknown { unclosed } => { + let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()]; + let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len()); + (split < list.len()).then_some((split, unclosed)) + } + } + }; let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); if behind { list.insert(0, mark); } else { - let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); - let original = list.iter().any(|o| stream_tag(doc, o).is_none()); - if !wrapped && original { + if let Some((split, unclosed)) = wrap { + let mut close_bytes = b"\nQ".repeat(unclosed); + close_bytes.extend_from_slice(WRAP_CLOSE); let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec())))); - let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_CLOSE.to_vec())))); - // Behind-marks stay outside the wrapper. - let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len()); - list.insert(split, open); + let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes)))); + list.insert(split.min(list.len()), open); list.push(close); } list.push(mark); @@ -613,8 +754,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl Ok(()) } -/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on -/// top of a page, the q/Q wrapper goes too, restoring the original content list. +/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with +/// nothing after them go too, restoring the original content list; a wrapper that content +/// after it still relies on (another tool's appended stream) stays. pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result { let all = page_list(doc); check(pages, all.len())?; @@ -625,12 +767,41 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu let before = list.len(); let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect(); removed += before - kept.len(); - // Marks on top come after the closing wrapper; without any, the wrapper has no purpose. - let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap"); - let close = kept.iter().rposition(&is_wrap); - let marks_on_top = close.is_some_and(|c| kept[c + 1..].iter().any(|o| stream_tag(doc, o).is_some_and(|t| t != "Wrap"))); - if !marks_on_top { - kept.retain(|o| !is_wrap(o)); + // Strip only wrapper pairs whose close ends the list (after stripping the pairs after + // it): nothing comes after them to need the state they restore. Pairs come from the + // operators; for content that can't be read that precisely, from the wrapper streams + // alone, so updating a mark there doesn't nest another wrapper each time. + if before != kept.len() { + let wrappers = match isolation(doc, &kept) { + Isolation::Clean { wrappers, .. } => wrappers, + Isolation::Unknown { .. } => { + let mut opens = Vec::new(); + let mut pairs = Vec::new(); + for (i, o) in kept.iter().enumerate() { + match wrapper_role(doc, o) { + Some(true) => opens.push(i), + Some(false) => pairs.extend(opens.pop().map(|open| (open, i))), + None => {} + } + } + pairs + } + }; + let pairs: std::collections::HashMap = wrappers.into_iter().map(|(open, close)| (close, open)).collect(); + let mut discarded = std::collections::HashSet::new(); + let mut end = kept.len(); + while let Some(close) = end.checked_sub(1) { + if discarded.contains(&close) { + end = close; + } else if let Some(&open) = pairs.get(&close) { + discarded.insert(open); + discarded.insert(close); + end = close; + } else { + break; + } + } + kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect(); } if before != kept.len() { doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df..88e3d7806 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -77,7 +77,8 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() let s0 = streams(&doc, 0); // q-wrapper, original, Q-wrapper, header/footer. assert_eq!(s0.len(), 4, "{s0:?}"); - assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "Q %PdfCraft\n")); + // The close starts on a new line, so a comment the original ends in can't swallow it. + assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n")); let mark = &s0[3]; assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}"); assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity"); @@ -4249,3 +4250,226 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st } } } + +/// Small graphics-state oracle for synthetic streams: every tracked state change must +/// have been restored when a probe XObject would draw. No external assets are involved. +fn probe_states(doc: &Document) -> Vec, Vec>> { + let bytes = streams(doc, 0); + let joined = pdfcraft_content::Pieces::join(&bytes); + let mut state = std::collections::BTreeMap::new(); + let mut stack = Vec::new(); + let mut probes = Vec::new(); + for op in joined.parse().ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().expect("balanced graphics state"), + b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => { + state.insert(op.op, op.operands); + } + b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()), + _ => {} + } + } + probes +} + +fn isolation_fixture(parts: &[(&str, &str)]) -> Document { + let mut doc = fixture(); + let page = page_list(&doc).remove(0); + let list = parts + .iter() + .map(|(tag, bytes)| { + Object::Ref(doc.add(Object::Stream(Stream::from_raw(if tag.is_empty() { Dict::new() } else { tagged(tag) }, bytes.as_bytes().to_vec())))) + }) + .collect(); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap(); + doc +} + +#[test] +fn new_marks_isolate_state_appended_after_old_wrappers() { + for dirt in ["0.5 0 0 0.5 0 0 cm", "0 0 200 200 re W n", "50 Tz", "5 Tc 20 Tw", "3 Tr", "/Pattern cs"] { + for tag in ["", "OCR", "Wrap"] { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("Added", "q /PCProbe Do Q"), + (tag, dirt), + ]); + let before = probe_states(&doc); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + let after = probe_states(&reopen(&doc)); + assert_eq!(&after[..before.len()], &before, "existing content must not change"); + assert!(after.last().unwrap().is_empty(), "tag={tag}, dirt={dirt}: {after:?}"); + } + } +} + +#[test] +fn new_marks_isolate_dirty_tagged_only_pages() { + let mut doc = isolation_fixture(&[("OCR", "50 Tz 5 Tc 20 Tw")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn new_marks_do_not_accumulate_wrappers_for_self_contained_marks() { + let mut doc = isolation_fixture(&[("", "2 0 0 2 0 0 cm")]); + for _ in 0..10 { + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + } + assert_eq!(streams(&doc, 0).len(), 13); + assert!(probe_states(&doc).iter().all(|s| s.is_empty())); +} + +#[test] +fn new_marks_close_wrapper_after_an_unterminated_comment() { + let mut doc = isolation_fixture(&[("", "50 Tz % no final newline")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + // Check literal concatenation too: a separator must exist in the saved stream itself, + // not just in the inspection parser, or the comment can swallow the closing Q. + let bytes = streams(&doc, 0).concat(); + let parsed = pdfcraft_content::parse(bytes.as_bytes()); + assert_eq!(parsed.ops.iter().filter(|op| op.is("Q")).count(), 2); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn new_marks_wrap_unbalanced_graphics_state_whole_without_changing_it() { + // Content that can't be isolated precisely is wrapped whole, as before #3084, instead of + // refusing: redaction stamps its boxes after removing the text, so it can't back out. + for (data, probe_clean) in [("q 50 Tz", true), ("50 Tz q", true), ("q q Q", true), ("Q 50 Tz", false), ("q Q Q 50 Tz", false)] { + let mut doc = isolation_fixture(&[("Wrap", data)]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap(); + let after = streams(&doc, 0); + assert_eq!(after.len(), 4, "{data}: {after:?}"); + assert_eq!((after[0].as_str(), after[1].as_str()), ("q %PdfCraft\n", data), "{data}"); + // A `q` the content leaves open is closed after it, so the mark starts clean. A stray + // `Q` pops the wrapper itself: nothing placed after it can undo that. + let probe = probe_states_lenient(&doc); + assert_eq!(probe.last().unwrap().is_empty(), probe_clean, "{data}: {after:?}"); + } +} + +/// [`probe_states`] for unbalanced content: a `Q` with nothing saved leaves the state as is, +/// as renderers do. +fn probe_states_lenient(doc: &Document) -> Vec, Vec>> { + let joined = pdfcraft_content::Pieces::join(&streams(doc, 0)); + let mut state = std::collections::BTreeMap::new(); + let mut stack = Vec::new(); + let mut probes = Vec::new(); + for op in joined.parse().ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().unwrap_or(state), + b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => { + state.insert(op.op, op.operands); + } + b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()), + _ => {} + } + } + probes +} + +#[test] +fn new_marks_preserve_cross_stream_operands_and_q_pairs() { + let mut doc = isolation_fixture(&[("", "q 2 0 0 2"), ("", "0 0 cm Q 50"), ("", "Tz")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn removing_marks_keeps_wrappers_needed_by_later_external_content() { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("HeaderFooter", "q /PCProbe Do Q"), + ("", "/PCProbe Do"), + ]); + remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(); + assert_eq!(probe_states(&doc).len(), 1); + assert!(probe_states(&doc)[0].is_empty(), "the external content still needs the original reset"); +} + +#[test] +fn new_marks_wrap_unfinished_text_paths_and_operands_whole() { + for data in ["BT", "ET", "BT BT ET", "0 0 10 10 re", "q 0 0 m Q", "50"] { + let mut doc = isolation_fixture(&[("Background", "q Q"), ("", data)]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap(); + // The behind-mark stays outside the wrapper and the content itself is untouched. + assert_eq!(streams(&doc, 0), ["q Q", "q %PdfCraft\n", data, "\nQ %PdfCraft\n", "q /PCProbe Do Q"], "{data}"); + } +} + +#[test] +fn new_marks_do_not_reveal_cross_stream_comments() { + let mut doc = isolation_fixture(&[("", "q 50 Tz % comment continues"), ("", "Q 20 Tc\nQ 5 Tw")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); + let joined = pdfcraft_content::Pieces::join(&streams(&doc, 0)); + assert!(!joined.parse().ops.iter().any(|op| op.is("Tc")), "commented content must remain inert"); +} + +#[test] +fn new_marks_follow_clean_streams_ending_in_comments() { + let mut doc = isolation_fixture(&[("Added", "q 50 Tz Q % no newline")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert_eq!(probe_states(&doc).len(), 1, "the new mark must not be swallowed by a comment"); + assert!(probe_states(&doc)[0].is_empty()); +} + +#[test] +fn removing_marks_does_not_strip_arbitrary_wrap_tagged_content() { + let mut doc = isolation_fixture(&[("Wrap", "q /PCProbe Do Q"), ("HeaderFooter", "q Q")]); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(probe_states(&doc).len(), 1); +} + +#[test] +fn removing_marks_strips_nested_trailing_wrapper_pairs() { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("Wrap", "Q %PdfCraft\n"), + ("HeaderFooter", "q Q"), + ]); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(streams(&doc, 0), ["2 0 0 2 0 0 cm"]); +} + +#[test] +fn updating_marks_on_unreadable_content_does_not_nest_wrappers() { + for data in ["q 50 Tz", "Q 50 Tz", "BT"] { + let mut doc = isolation_fixture(&[("", data)]); + let page = page_list(&doc).remove(0); + for _ in 0..3 { + place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q /PCProbe Do Q".to_vec(), false).unwrap(); + assert_eq!(streams(&doc, 0).len(), 4, "{data}: {:?}", streams(&doc, 0)); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(streams(&doc, 0), [data], "{data}: the original list comes back"); + } + } +} + +#[test] +fn removing_marks_strips_wrappers_that_closed_open_state() { + let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); + // Now balanced, the page reads cleanly: the close (with its extra `Q`) still pairs. + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap(); + assert_eq!(streams(&doc, 0).len(), 5, "no second wrapper: {:?}", streams(&doc, 0)); + let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap(); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1); + assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a3..916aaeadd 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1237,12 +1237,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError path: Vec, visits: usize, } - fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: usize) { + /// `stream` gives the stream number of the operator at an index of `ops`. + fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) { let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut invoked = Vec::new(); let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true); let mut last = None; + let mut last_stream = None; let mut forms = invoked.into_iter().peekable(); for s in shown { while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) { @@ -1250,7 +1252,12 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError // Text after a form starts a new line. last = None; } - add_shown(&mut w.lines, &mut last, s, stream); + let si = stream(s.op); + // Text from another stream starts a new line, as when each stream was read alone. + if last_stream.replace(si).is_some_and(|prev| prev != si) { + last = None; + } + add_shown(&mut w.lines, &mut last, s, si); } for (_, name, ts) in forms { enter(w, resources, &name, ts); @@ -1266,7 +1273,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError if let Some(r) = form.obj { w.path.push(r); } - walk(w, &ops, &form.resources, &mut carry, stream); + walk(w, &ops, &form.resources, &mut carry, &|_| stream); if form.obj.is_some() { w.path.pop(); } @@ -1276,9 +1283,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError let streams = content_streams(doc, &p.dict); let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 }; let mut carry = Carry::new(); - for (si, (_, data)) in streams.into_iter().enumerate() { - walk(&mut w, &parse(&data).ops, &res, &mut carry, si); - } + // Read the page's streams as the one stream they are, in one pass, as rendering and editing + // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) + // can run on from one stream into the next. Text still counts as the stream its operator + // starts in, which keeps lines from different streams apart. + let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::>()); + 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); let mut lines = w.lines; // A visual-order Indic font draws pre-base vowel signs before their consonant. for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) { diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c76e11995..50802eff3 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -380,6 +380,9 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); + // A rewritten piece loses its comments, so keep where they began and ended: a comment that + // ran on into the next piece still hides that piece's first line. + let comment_edges = joined.comment_edges(&parsed); if !scope.hidden_layers.is_empty() { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -405,7 +408,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< } } - for (piece, mut changed) in pieces.into_iter().zip(rewrite) { + for ((piece, mut changed), (comment_in, comment_out)) in pieces.into_iter().zip(rewrite).zip(comment_edges) { let mut ops: Vec = Vec::with_capacity(piece.len()); for op in piece { let o = op.op.as_slice(); @@ -622,7 +625,16 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< if !path.is_empty() { ops.append(&mut path); } - out.streams.push(changed.then(|| serialize_ops(&ops))); + out.streams.push(changed.then(|| { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(&ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes + })); } out } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 61231bd98..21af3691c 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -400,7 +400,8 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result