mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-04 01:29:57 +00:00
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:
9 files changed
+687
-32
No files matched your search
@@ -500,3 +500,32 @@ fn forms_nested_past_depth_limit_fail_closed() {
|
|||||||
let result = audit_redactions(&open(nested_forms(33)));
|
let result = audit_redactions(&open(nested_forms(33)));
|
||||||
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,10 @@ pub struct Parsed {
|
|||||||
pub ops: Vec<Op>,
|
pub ops: Vec<Op>,
|
||||||
/// Bytes that could not be read as operands or operators.
|
/// Bytes that could not be read as operands or operators.
|
||||||
pub skipped: usize,
|
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 {
|
fn is_ws(b: u8) -> bool {
|
||||||
@@ -72,12 +76,25 @@ fn is_delim(b: u8) -> bool {
|
|||||||
|
|
||||||
/// Parse a (decoded) content stream.
|
/// Parse a (decoded) content stream.
|
||||||
pub fn parse(data: &[u8]) -> Parsed {
|
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 lx = Lexer::new(data, 0);
|
||||||
let mut operands: Vec<Object> = Vec::new();
|
let mut operands: Vec<Object> = Vec::new();
|
||||||
let mut start: Option<usize> = None;
|
let mut start: Option<usize> = None;
|
||||||
loop {
|
loop {
|
||||||
|
let trivia = lx.pos;
|
||||||
lx.skip_ws();
|
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 Some(&b) = data.get(lx.pos) else { break };
|
||||||
let at = lx.pos;
|
let at = lx.pos;
|
||||||
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
|
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
|
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
|
/// 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.
|
/// 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> {
|
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
|
/// 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
|
/// 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
|
/// 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)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
pub struct Pieces {
|
pub struct Pieces {
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
@@ -319,7 +371,7 @@ impl Pieces {
|
|||||||
let mut ends = Vec::with_capacity(pieces.len());
|
let mut ends = Vec::with_capacity(pieces.len());
|
||||||
for p in pieces {
|
for p in pieces {
|
||||||
data.extend_from_slice(p.as_ref());
|
data.extend_from_slice(p.as_ref());
|
||||||
data.push(b'\n');
|
data.push(b' ');
|
||||||
ends.push(data.len());
|
ends.push(data.len());
|
||||||
}
|
}
|
||||||
Pieces { data, ends }
|
Pieces { data, ends }
|
||||||
@@ -341,12 +393,22 @@ impl Pieces {
|
|||||||
|
|
||||||
/// Parse the pieces as one stream.
|
/// Parse the pieces as one stream.
|
||||||
pub fn parse(&self) -> Parsed {
|
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).
|
/// The piece holding joined position `at` (the last piece for positions past the end).
|
||||||
pub fn piece_of(&self, at: usize) -> usize {
|
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).
|
/// The pieces an operator spans: (the one its first token is in, the one its keyword is in).
|
||||||
|
|||||||
@@ -283,3 +283,108 @@ fn pieces_parse_as_one_stream_and_splice_back() {
|
|||||||
assert_eq!(out.len(), 3);
|
assert_eq!(out.len(), 3);
|
||||||
assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty());
|
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
@@ -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_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)]
|
#[cfg(test)]
|
||||||
fn stream_bytes(doc: &Document, o: &Object) -> Option<Vec<u8>> {
|
fn stream_bytes(doc: &Document, o: &Object) -> Option<Vec<u8>> {
|
||||||
@@ -350,27 +380,138 @@ fn tagged(tag: &str) -> Dict {
|
|||||||
d
|
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
|
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
|
||||||
/// original content in q/Q).
|
/// original content in q/Q).
|
||||||
fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
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)
|
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> {
|
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 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))));
|
let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
|
||||||
if behind {
|
if behind {
|
||||||
list.insert(0, mark);
|
list.insert(0, mark);
|
||||||
} else {
|
} else {
|
||||||
let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
|
if let Some((split, unclosed)) = wrap {
|
||||||
let original = list.iter().any(|o| stream_tag(doc, o).is_none());
|
let mut close_bytes = b"\nQ".repeat(unclosed);
|
||||||
if !wrapped && original {
|
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 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()))));
|
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
|
||||||
// Behind-marks stay outside the wrapper.
|
list.insert(split.min(list.len()), open);
|
||||||
let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len());
|
|
||||||
list.insert(split, open);
|
|
||||||
list.push(close);
|
list.push(close);
|
||||||
}
|
}
|
||||||
list.push(mark);
|
list.push(mark);
|
||||||
@@ -613,8 +754,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on
|
/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with
|
||||||
/// top of a page, the q/Q wrapper goes too, restoring the original content list.
|
/// 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> {
|
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result<usize, EditError> {
|
||||||
let all = page_list(doc);
|
let all = page_list(doc);
|
||||||
check(pages, all.len())?;
|
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 before = list.len();
|
||||||
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
|
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
|
||||||
removed += before - kept.len();
|
removed += before - kept.len();
|
||||||
// Marks on top come after the closing wrapper; without any, the wrapper has no purpose.
|
// Strip only wrapper pairs whose close ends the list (after stripping the pairs after
|
||||||
let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap");
|
// it): nothing comes after them to need the state they restore. Pairs come from the
|
||||||
let close = kept.iter().rposition(&is_wrap);
|
// operators; for content that can't be read that precisely, from the wrapper streams
|
||||||
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")));
|
// alone, so updating a mark there doesn't nest another wrapper each time.
|
||||||
if !marks_on_top {
|
if before != kept.len() {
|
||||||
kept.retain(|o| !is_wrap(o));
|
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() {
|
if before != kept.len() {
|
||||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?;
|
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?;
|
||||||
|
|||||||
+225
-1
@@ -77,7 +77,8 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content()
|
|||||||
let s0 = streams(&doc, 0);
|
let s0 = streams(&doc, 0);
|
||||||
// q-wrapper, original, Q-wrapper, header/footer.
|
// q-wrapper, original, Q-wrapper, header/footer.
|
||||||
assert_eq!(s0.len(), 4, "{s0:?}");
|
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];
|
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("/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");
|
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
@@ -1237,12 +1237,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
|
|||||||
path: Vec<pdfcraft_cos::ObjRef>,
|
path: Vec<pdfcraft_cos::ObjRef>,
|
||||||
visits: usize,
|
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 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 cache = HashMap::new();
|
||||||
let mut invoked = Vec::new();
|
let mut invoked = Vec::new();
|
||||||
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true);
|
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true);
|
||||||
let mut last = None;
|
let mut last = None;
|
||||||
|
let mut last_stream = None;
|
||||||
let mut forms = invoked.into_iter().peekable();
|
let mut forms = invoked.into_iter().peekable();
|
||||||
for s in shown {
|
for s in shown {
|
||||||
while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) {
|
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.
|
// Text after a form starts a new line.
|
||||||
last = None;
|
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 {
|
for (_, name, ts) in forms {
|
||||||
enter(w, resources, &name, ts);
|
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 {
|
if let Some(r) = form.obj {
|
||||||
w.path.push(r);
|
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() {
|
if form.obj.is_some() {
|
||||||
w.path.pop();
|
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 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 w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 };
|
||||||
let mut carry = Carry::new();
|
let mut carry = Carry::new();
|
||||||
for (si, (_, data)) in streams.into_iter().enumerate() {
|
// Read the page's streams as the one stream they are, in one pass, as rendering and editing
|
||||||
walk(&mut w, &parse(&data).ops, &res, &mut carry, si);
|
// 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;
|
let mut lines = w.lines;
|
||||||
// A visual-order Indic font draws pre-base vowel signs before their consonant.
|
// 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) {
|
for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) {
|
||||||
|
|||||||
@@ -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.
|
// next. Parse them joined, then give each operator back to the piece its keyword is in.
|
||||||
let joined = Pieces::join(streams);
|
let joined = Pieces::join(streams);
|
||||||
let mut parsed = joined.parse();
|
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() {
|
if !scope.hidden_layers.is_empty() {
|
||||||
let props = res_dict(doc, resources, b"Properties");
|
let props = res_dict(doc, resources, b"Properties");
|
||||||
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
|
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());
|
let mut ops: Vec<Op> = Vec::with_capacity(piece.len());
|
||||||
for op in piece {
|
for op in piece {
|
||||||
let o = op.op.as_slice();
|
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() {
|
if !path.is_empty() {
|
||||||
ops.append(&mut path);
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// Tags must not keep what the page no longer shows.
|
||||||
let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default();
|
let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default();
|
||||||
let (_, new_data) = page_streams(doc, &after, pi)?;
|
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)?;
|
report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1117,3 +1117,42 @@ fn masks_and_alternates_outside_redaction_stay() {
|
|||||||
assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates"));
|
assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates"));
|
||||||
assert_eq!(image.decoded().unwrap(), b"PUBLIC_TOPBOTTOM");
|
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"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user