Merge pull request #3102 from skylartaylor/fix/content-boundaries-and-mark-isolation

Read stream-boundary comments as the renderer does, and isolate new marks from appended graphics state
This commit is contained in:
skylar taylor-barrick
2026-10-17 18:03:38 -06:00
committed by GitHub
9 changed files with 687 additions and 32 deletions
+29
View File
@@ -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 <</ActualText (Replacement text)".as_slice(), b">> BDC (Glyphs) Tj EMC ET".as_slice()),
(b"BT /F1 24 Tf 72 700 Td /Span <</ActualText (Replacement text)>> 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));
}
}
+67 -5
View File
@@ -60,6 +60,10 @@ pub struct Parsed {
pub ops: Vec<Op>,
/// 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<Object> = Vec::new();
let mut start: Option<usize> = 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<usize>, 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<u8>, usize), usize> {
@@ -305,7 +355,9 @@ pub fn serialize_ops(ops: &[Op]) -> Vec<u8> {
/// 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<u8>,
@@ -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).
+105
View File
@@ -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<_>>(), 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<_>>(), 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<_>>(), 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: q<space><space>Q<space>; 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);
}
+188 -17
View File
@@ -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<bool> {
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<Vec<u8>> {
@@ -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<usize>, 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<usize> = 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<u8>, 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<u8>, 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<usize, EditError> {
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<Object> = 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<usize, usize> = 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)))?;
+225 -1
View File
@@ -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<std::collections::BTreeMap<Vec<u8>, Vec<Object>>> {
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<std::collections::BTreeMap<Vec<u8>, Vec<Object>>> {
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"]);
}
+18 -6
View File
@@ -1237,12 +1237,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
path: Vec<pdfcraft_cos::ObjRef>,
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<Vec<TextLine>, 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<Vec<TextLine>, 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<Vec<TextLine>, 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::<Vec<_>>());
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) {
+14 -2
View File
@@ -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<Op> = 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
}
+2 -1
View File
@@ -400,7 +400,8 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
// Tags must not keep what the page no longer shows.
let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default();
let (_, new_data) = page_streams(doc, &after, pi)?;
let (changed_ids, empty_ids) = tags::touched(doc, &resources, &data.join(&b'\n'), &new_data.join(&b'\n'));
let (changed_ids, empty_ids) =
tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data());
report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?;
}
+39
View File
@@ -1117,3 +1117,42 @@ fn masks_and_alternates_outside_redaction_stay() {
assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates"));
assert_eq!(image.decoded().unwrap(), b"PUBLIC_TOPBOTTOM");
}
#[test]
fn stream_boundary_comment_stays_inert_after_unrelated_redaction() {
for tail in [b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET".as_slice(), b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET\nBT /F1 10 Tf 10 250 Td (LIVE) Tj ET"] {
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET % note", "", vec![]);
let hidden = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail.to_vec())));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden)]))).unwrap();
mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
let reopened = reopen(&doc);
let page = &pdfcraft_model::pages(&reopened)[0];
let (_, data) = page_streams(&reopened, &page.dict, 0).unwrap();
// Independent of Pieces::join: the renderer concatenates streams with spaces.
let ops = pdfcraft_content::parse(&data.join(&b' ')).ops;
assert!(!ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"HIDDEN"))), "hidden text became executable");
let live = ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"LIVE")));
assert_eq!(live, tail.contains(&b'\n'));
}
}
#[test]
fn rewriting_a_piece_keeps_its_leading_comment_terminator() {
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
let mut doc = one_page(b"q Q % note", "", vec![]);
let mut tail = eol.to_vec();
tail.extend(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET BT /F1 10 Tf 10 200 Td (KEEP) Tj ET");
let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail)));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(second)]))).unwrap();
mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
let p = &pdfcraft_model::pages(&doc)[0];
let (_, data) = page_streams(&doc, &p.dict, 0).unwrap();
let ops = pdfcraft_content::parse(&data.join(&b' ')).ops;
assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), 2, "leading EOL lost; first live operator swallowed");
assert!(ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"KEEP"))));
}
}