mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
fix(edit): isolate new marks from state left by content after an earlier wrapper (#3084)
place_tagged wrapped the page's content in q/Q only when no Wrap stream existed yet. Content appended after an earlier wrapper (by another tool, or an older OCR layer) that left a cm, a clip or text state set then moved, hid or distorted every later header, Bates number, watermark, added text, flattened annotation and redaction cover. Placing a mark on top now reads the page's actual operators instead of the /PCMark tags. When the content parses cleanly with balanced q/Q and closed text objects and paths, the stretch from the first stream that leaves state set is wrapped, once, at a stream boundary no comment runs into. Content that is already self-contained gets no new wrapper, so repeated marks don't pile up. Content that can't be read that precisely (stray bytes, unbalanced q/Q, an unfinished text object or path, too large) is wrapped whole as before, rather than refusing: redaction stamps its covers after removing the text and can't back out. A q that content leaves open is closed after it, where closing it changes nothing already drawn. The wrapper's close now starts with a newline, so a comment at the end of the content before it can't swallow the Q. Removing marks strips only wrapper pairs that end the list, keeping one that later content still relies on, and recognises the older close bytes too.
This commit is contained in:
+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_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
@@ -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"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user