mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
M2.6: nothing after a damaged content stream, or in one that doesn't decode, is rewritten
A page's content streams are read as one stream, so what a stream damaged partway hides past the damage is unknown to the streams after it too. A string or comment the damage leaves open runs on into the next stream, and operators the damage hides can change the graphics state the later streams draw in. Marking a tag's content as an artifact took what read as a BDC in a later stream for the tag's own, and could rewrite bytes inside a string. Moving an image after the damage worked the move out without a cm the damage may hide. Now an edit refuses a change to any stream after one damaged partway (rewritten_contents), and an image isn't arranged into a new stream placed after one. Marking an artifact or a decorative figure refuses content that reads as the tag's in a stream after the damage. Edits and marking before the damage go on as before. A content stream that doesn't decode at all was read as empty and whole. A styled edit whose paragraph marker closes past the page's last operator appended the close to it, so the edit replaced that stream with one holding only the close. Edits now refuse a change to such a stream, and still go on around it. Marking an artifact or a decorative figure whose content isn't found takes it as possibly in such a stream, as it does for content past the damage, and refuses rather than take it for absent. Tests: an_edit_never_rewrites_a_content_stream_damaged_partway (an edit after the damage refused, one before it made), text_a_damaged_stream_runs_on_into_is_not_edited, images_after_a_content_stream_damaged_partway_are_not_moved, a_content_stream_that_does_not_decode_is_never_written_over, a_tags_content_after_a_damaged_stream_is_not_rewritten, a_tags_content_not_found_beside_a_stream_that_does_not_decode_is_not_taken_for_absent, and a_tags_content_in_a_stream_damaged_partway_is_not_rewritten now checks the damaged stream itself and marks content before the damage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
1 parent
ebf9c7a3ce
commit
0a350f6809
8 files changed
+255
-49
No files matched your search
+33
-16
@@ -26,7 +26,7 @@ pub enum AltError {
|
||||
#[error("the page has more content than can be edited")]
|
||||
TooLarge,
|
||||
#[error(
|
||||
"the figure's content is, or may be, in a content stream damaged partway, which can't be rewritten without losing what follows the damage"
|
||||
"the figure's content is, or may be, in a content stream that is damaged partway or doesn't decode, or after the damage, where it can't be rewritten without losing or misreading what wasn't read"
|
||||
)]
|
||||
Damaged,
|
||||
#[error("{0}")]
|
||||
@@ -57,7 +57,7 @@ pub enum ArtifactError {
|
||||
#[error("the page has more content than can be edited")]
|
||||
TooLarge,
|
||||
#[error(
|
||||
"the tag's marked content is, or may be, in a content stream damaged partway, which can't be rewritten without losing what follows the damage"
|
||||
"the tag's marked content is, or may be, in a content stream that is damaged partway or doesn't decode, or after the damage, where it can't be rewritten without losing or misreading what wasn't read"
|
||||
)]
|
||||
Damaged,
|
||||
#[error(transparent)]
|
||||
@@ -380,9 +380,19 @@ fn content_entries(doc: &Document, page: &Dict) -> Vec<Object> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/Contents` entry and, for a stream, the stream, its decoded data and whether it is damaged
|
||||
/// partway (the data is then what decoded before the damage).
|
||||
type DecodedStream = (Object, Option<(Stream, Vec<u8>, bool)>);
|
||||
/// How much of a content stream decoded.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Read {
|
||||
Whole,
|
||||
/// Damaged partway: the data is what decoded before the damage. What follows it is unknown,
|
||||
/// the syntax the later streams start in too.
|
||||
Damaged,
|
||||
/// Not at all (a filter this reader lacks, say): the data is empty.
|
||||
Unread,
|
||||
}
|
||||
|
||||
/// A `/Contents` entry and, for a stream, the stream, its decoded data and how much of it decoded.
|
||||
type DecodedStream = (Object, Option<(Stream, Vec<u8>, Read)>);
|
||||
|
||||
/// Decode the page's content streams within the page decode budget. `None` when a stream is
|
||||
/// rejected because the budget is exhausted.
|
||||
@@ -393,14 +403,14 @@ fn decode_page_streams(doc: &Document, page: &Dict) -> Option<Vec<DecodedStream>
|
||||
for entry in entries {
|
||||
let decoded = match &*doc.resolve(&entry) {
|
||||
Object::Stream(s) => {
|
||||
let (data, damaged) = match budget.decode_with_damage(doc, s, usize::MAX) {
|
||||
Ok(read) => read,
|
||||
let (data, read) = match budget.decode_with_damage(doc, s, usize::MAX) {
|
||||
Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }),
|
||||
// Content past the budget may hold more of the tag: editing the rest
|
||||
// would leave that part marked as a tag the tree no longer has.
|
||||
Err(_) if budget.exhausted() => return None,
|
||||
Err(_) => (Vec::new(), false),
|
||||
Err(_) => (Vec::new(), Read::Unread),
|
||||
};
|
||||
Some((s.clone(), data, damaged))
|
||||
Some((s.clone(), data, read))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -420,8 +430,8 @@ enum Marked {
|
||||
Done,
|
||||
/// None of the content is on the page, or with `require_all` not all of it: nothing changed.
|
||||
NotFound,
|
||||
/// Some of it is in a stream damaged partway, which can't be rewritten without losing what
|
||||
/// follows the damage, or may be past the damage: nothing changed.
|
||||
/// Some of it is, or may be, in a stream damaged partway or one that doesn't decode, neither of
|
||||
/// which can be rewritten without losing what wasn't read, or after the damage: nothing changed.
|
||||
Damaged,
|
||||
}
|
||||
|
||||
@@ -436,27 +446,34 @@ fn artifact_marked_content(
|
||||
require_all: bool,
|
||||
) -> Result<Marked, pdfcraft_cos::CosError> {
|
||||
let props = page_properties(doc, &page.dict);
|
||||
// The pieces are the streams, in order.
|
||||
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes, _)| bytes.as_slice())).collect();
|
||||
let read: Vec<Read> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, _, read)| *read)).collect();
|
||||
let damage = read.iter().position(|r| *r == Read::Damaged);
|
||||
let pieces = pdfcraft_content::Pieces::join(&decoded);
|
||||
let mut found = HashSet::new();
|
||||
let mut edits = Vec::new();
|
||||
for op in pieces.parse().ops {
|
||||
if let Some(id) = marked_id(doc, &props, &op, ids) {
|
||||
// What follows the damage is unknown: a string or comment the damage left open runs
|
||||
// on into the next stream, so what reads as an operator after it may not be one.
|
||||
if damage.is_some_and(|d| pieces.piece_of(op.span.start) > d) {
|
||||
return Ok(Marked::Damaged);
|
||||
}
|
||||
found.insert(id);
|
||||
edits.push((op.span, b"/Artifact BMC".to_vec()));
|
||||
}
|
||||
}
|
||||
// Content not found may be past the damage in a damaged stream: whether it is on the page
|
||||
// isn't known.
|
||||
if found != *ids && streams.iter().any(|(_, s)| s.as_ref().is_some_and(|(_, _, damaged)| *damaged)) {
|
||||
// Content not found may be past the damage, or in a stream that doesn't decode: whether it
|
||||
// is on the page isn't known.
|
||||
if found != *ids && read.iter().any(|r| *r != Read::Whole) {
|
||||
return Ok(Marked::Damaged);
|
||||
}
|
||||
if if require_all { found != *ids } else { found.is_empty() } {
|
||||
return Ok(Marked::NotFound);
|
||||
}
|
||||
let spliced = pieces.splice(edits);
|
||||
// The pieces are the streams that decoded, in order.
|
||||
if streams.iter().filter_map(|(_, s)| s.as_ref()).zip(&spliced).any(|((_, old, damaged), new)| *damaged && old != new) {
|
||||
if read.iter().zip(&decoded).zip(&spliced).any(|((read, old), new)| *read != Read::Whole && *old != new.as_slice()) {
|
||||
return Ok(Marked::Damaged);
|
||||
}
|
||||
let mut spliced = spliced.into_iter();
|
||||
|
||||
@@ -1103,8 +1103,9 @@ fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream {
|
||||
}
|
||||
|
||||
/// A tag's content in a stream damaged partway stays tagged: rewriting the stream from what
|
||||
/// decoded would lose what follows the damage. Nothing changes, the structure tree included.
|
||||
/// Content in a whole stream beside a damaged one is marked, and the damaged one kept as stored.
|
||||
/// decoded would lose what follows the damage. Nothing changes, the structure tree and the stream
|
||||
/// included. Content in a whole stream before a damaged one is marked, and the damaged one kept
|
||||
/// as stored.
|
||||
#[test]
|
||||
fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() {
|
||||
use pdfcraft_cos::{ObjRef, Object};
|
||||
@@ -1113,20 +1114,69 @@ fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() {
|
||||
let mut doc = figures_doc();
|
||||
doc.set(ObjRef::new(4, 0), damaged.clone());
|
||||
let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
|
||||
let (before_contents, before_pt, before_kids) = (contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)));
|
||||
let state = |doc: &Document| (contents(doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)), doc.get(ObjRef::new(4, 0)));
|
||||
let before = state(&doc);
|
||||
assert_eq!(figures(&doc).len(), 2, "read up to the damage");
|
||||
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
|
||||
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
|
||||
assert_eq!((contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))), (before_contents, before_pt, before_kids));
|
||||
assert_eq!(state(&doc), before);
|
||||
|
||||
let mut doc = figures_doc();
|
||||
let other = doc.add(damaged_flate(b"0 g\n", b"0 0 1 rg 0 0 10 10 re f"));
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(other)])))
|
||||
.unwrap();
|
||||
mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap();
|
||||
let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) };
|
||||
assert_eq!(list.first(), Some(&Object::Ref(other)), "kept as stored");
|
||||
assert_ne!(list.get(1), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten");
|
||||
assert_ne!(list.first(), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten");
|
||||
assert_eq!(list.get(1), Some(&Object::Ref(other)), "kept as stored");
|
||||
}
|
||||
|
||||
/// What follows the damage is unknown, the syntax the next stream starts in too, so a tag's
|
||||
/// content in a stream after a damaged one stays tagged. Here a comment the damage leaves open
|
||||
/// runs on into the next stream, up to its first end of line, and a string starting there reads
|
||||
/// as operators, among them what looks like the figure's content: rewritten, it would change the
|
||||
/// string. Nothing changes, the structure tree included.
|
||||
#[test]
|
||||
fn a_tags_content_after_a_damaged_stream_is_not_rewritten() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
|
||||
let in_a_string = b"(a note\n/Figure << /MCID 0 >> BDC 0 g EMC\n) pop /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC";
|
||||
for (damaged, after) in [(b"0 g\n".as_slice(), None), (b"0 g % a comment".as_slice(), Some(in_a_string.as_slice()))] {
|
||||
let mut doc = figures_doc();
|
||||
if let Some(after) = after {
|
||||
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), after.to_vec()));
|
||||
}
|
||||
let first = doc.add(damaged_flate(damaged, b"\n0 0 1 rg 0 0 10 10 re f"));
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(4, 0))])))
|
||||
.unwrap();
|
||||
let state = |doc: &Document| {
|
||||
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
|
||||
};
|
||||
let before = state(&doc);
|
||||
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "{damaged:?}");
|
||||
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "{damaged:?}");
|
||||
assert_eq!(state(&doc), before, "{damaged:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A tag's content not found on the page may be in a stream that doesn't decode: it isn't known
|
||||
/// to be off the page, so neither marking changes anything, the structure tree included.
|
||||
#[test]
|
||||
fn a_tags_content_not_found_beside_a_stream_that_does_not_decode_is_not_taken_for_absent() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
|
||||
let mut doc = figures_doc();
|
||||
doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(2))).unwrap();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
|
||||
let other = doc.add(Object::Stream(Stream::from_raw(dict, b"/Figure << /MCID 2 >> BDC 0 g EMC".to_vec())));
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
|
||||
.unwrap();
|
||||
let state = |doc: &Document| {
|
||||
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
|
||||
};
|
||||
let before = state(&doc);
|
||||
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
|
||||
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
|
||||
assert_eq!(state(&doc), before);
|
||||
}
|
||||
|
||||
/// A tag's content not found before the damage in a damaged stream may be past it: it isn't known
|
||||
|
||||
@@ -1285,7 +1285,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] },
|
||||
"value": { "type": "string" },
|
||||
}), &["doc", "object", "generation", "property"])),
|
||||
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that is, or may be, in a content stream damaged partway are rejected. Undoable.")
|
||||
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that is, or may be, in a content stream damaged partway or that doesn't decode, or after the damage, are rejected. Undoable.")
|
||||
.with(schema(json!({
|
||||
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
|
||||
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
|
||||
|
||||
@@ -1428,13 +1428,14 @@ fn arrange_image(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStr
|
||||
};
|
||||
|
||||
if let Some(piece) = owned_destination {
|
||||
let insertion = piece.saturating_add(usize::from(insert_after));
|
||||
crate::text::drawn_before_damage(all, insertion)?;
|
||||
let changed = pieces.splice([(target_span, removal_replacement)]);
|
||||
let contents = crate::text::rewritten_contents(doc, all, changed)?;
|
||||
let mut list = match contents {
|
||||
Object::Array(list) => list,
|
||||
one => vec![one],
|
||||
};
|
||||
let insertion = piece.saturating_add(usize::from(insert_after));
|
||||
if insertion > list.len() {
|
||||
return Err(EditError::Invalid("the destination content stream is missing".into()));
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result<Vec<ContentStream>, Ed
|
||||
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
|
||||
}
|
||||
}
|
||||
streams.push(ContentStream { entry: object, data: bytes, damaged: false });
|
||||
streams.push(ContentStream { entry: object, data: bytes, read: crate::text::Read::Whole });
|
||||
}
|
||||
Ok(streams)
|
||||
}
|
||||
@@ -5172,8 +5172,10 @@ fn damaged_page() -> (Document, pdfcraft_cos::Stream) {
|
||||
}
|
||||
|
||||
/// An edit that would rewrite a content stream damaged partway is refused: written from what
|
||||
/// decoded, it would lose what follows the damage, which other readers may still read. An edit
|
||||
/// in the page's other stream goes on, and leaves the damaged one as it is stored.
|
||||
/// decoded, it would lose what follows the damage, which other readers may still read. So is an
|
||||
/// edit in a later stream: what the damage hides can change how that stream reads, a string or
|
||||
/// comment it leaves open, or the graphics state. An edit in a stream before the damage goes on,
|
||||
/// and leaves the damaged one as it is stored.
|
||||
#[test]
|
||||
fn an_edit_never_rewrites_a_content_stream_damaged_partway() {
|
||||
let (mut doc, damaged) = damaged_page();
|
||||
@@ -5183,21 +5185,64 @@ fn an_edit_never_rewrites_a_content_stream_damaged_partway() {
|
||||
let listed = contents(&doc);
|
||||
|
||||
let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err();
|
||||
assert!(refused.to_string().contains("damaged partway"), "{refused}");
|
||||
assert!(refused.to_string().contains("is damaged partway"), "{refused}");
|
||||
assert_eq!(contents(&doc), listed);
|
||||
let refused = text::rewrite_block(&mut doc, 0, 0, Some("Edited"), &text::BlockStyle::default()).unwrap_err();
|
||||
assert!(refused.to_string().contains("damaged partway"), "{refused}");
|
||||
assert!(refused.to_string().contains("is damaged partway"), "{refused}");
|
||||
assert_eq!(contents(&doc), listed);
|
||||
let refused = text::replace_line(&mut doc, 0, 1, "Edited").unwrap_err();
|
||||
assert!(refused.to_string().contains("comes after a content stream that is damaged partway"), "{refused}");
|
||||
assert_eq!(contents(&doc), listed);
|
||||
|
||||
text::replace_line(&mut doc, 0, 1, "Edited").unwrap();
|
||||
let mut doc = split_page("BT /F1 12 Tf 72 600 Td (Whole) Tj ET", "");
|
||||
doc.set(pdfcraft_cos::ObjRef::new(5, 0), damaged.clone());
|
||||
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(texts(&doc), ["Damaged", "Edited"]);
|
||||
assert_eq!(texts(&doc), ["Edited", "Damaged"]);
|
||||
let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) };
|
||||
assert_eq!(list.first(), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(4, 0))));
|
||||
let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(4, 0)) else { panic!("not a stream") };
|
||||
assert_eq!(list.get(1), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(5, 0))));
|
||||
let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(5, 0)) else { panic!("not a stream") };
|
||||
assert_eq!(kept.raw, damaged.raw, "stored as it was");
|
||||
}
|
||||
|
||||
/// A comment the damage leaves open runs on into the next stream, up to its first end of line,
|
||||
/// so a string starting there reads as operators: a line of text inside it. That line is in a
|
||||
/// stream after the damage, and isn't edited.
|
||||
#[test]
|
||||
fn text_a_damaged_stream_runs_on_into_is_not_edited() {
|
||||
let mut doc = split_page("", "(note\nBT /F1 12 Tf 72 600 Td (Inside a string) Tj ET\n) pop");
|
||||
doc.set(pdfcraft_cos::ObjRef::new(4, 0), damaged_flate(b"0 g % a comment", b"\n"));
|
||||
let texts = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
|
||||
assert_eq!(texts(&doc), ["Inside a string"], "the comment runs on into the next stream");
|
||||
let listed = pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").cloned();
|
||||
let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err();
|
||||
assert!(refused.to_string().contains("comes after"), "{refused}");
|
||||
assert_eq!(pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").cloned(), listed);
|
||||
}
|
||||
|
||||
/// A content stream that doesn't decode is never written over. A partial rewrite of the page's
|
||||
/// last paragraph closes its marker past the last operator read, in the page's last stream, here
|
||||
/// one that doesn't decode: the rewrite is refused, not that stream replaced by the close. An
|
||||
/// empty stream there takes the close.
|
||||
#[test]
|
||||
fn a_content_stream_that_does_not_decode_is_never_written_over() {
|
||||
let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET";
|
||||
let mut doc = split_page(src, "");
|
||||
let mut dict = pdfcraft_cos::Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
|
||||
doc.set(pdfcraft_cos::ObjRef::new(5, 0), pdfcraft_cos::Stream::from_raw(dict, b"0 0 1 rg 0 0 10 10 re f".to_vec()));
|
||||
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
|
||||
let listed = contents(&doc);
|
||||
let refused = text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap_err();
|
||||
assert!(refused.to_string().contains("can't be decoded"), "{refused}");
|
||||
assert_eq!(contents(&doc), listed);
|
||||
|
||||
let mut doc = split_page(src, "");
|
||||
text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap();
|
||||
let texts: Vec<_> = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["One. Alpha next row."]);
|
||||
}
|
||||
|
||||
/// A content stream that doesn't decode at all is no damage to write over: it is kept as stored,
|
||||
/// and the page's other streams are read and edited around it. A stream of a few MB is read for
|
||||
/// selecting objects.
|
||||
@@ -5310,6 +5355,52 @@ fn arrange_and_crop_image_in_second_content_stream_succeeds() {
|
||||
assert_eq!(after_crop[1].visible_rect, Some(after_crop[1].rect));
|
||||
}
|
||||
|
||||
/// What follows the damage in a content stream is unknown, the graphics state the later streams
|
||||
/// draw in too: here it hides a `cm` that doubles them. An image after the damage isn't moved, as
|
||||
/// the move is worked out without that `cm`, and one before it isn't arranged to after it, beside
|
||||
/// an added item there. An image before the damage is moved.
|
||||
#[test]
|
||||
fn images_after_a_content_stream_damaged_partway_are_not_moved() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Stream};
|
||||
let image = "q 100 0 0 100 200 200 cm /Im1 Do Q";
|
||||
let hidden_scale = || damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm");
|
||||
let shift = images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 0.0]);
|
||||
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
|
||||
|
||||
let mut doc = two_stream_image_page("", image);
|
||||
doc.set(ObjRef::new(4, 0), hidden_scale());
|
||||
let listed = contents(&doc);
|
||||
let refused = images::change_image(&mut doc, 0, 0, &shift).unwrap_err();
|
||||
assert!(refused.to_string().contains("comes after"), "{refused}");
|
||||
assert_eq!(contents(&doc), listed);
|
||||
|
||||
let mut doc = two_stream_image_page(image, "");
|
||||
let damaged = doc.add(hidden_scale());
|
||||
let mut added = Dict::new();
|
||||
added.set(b"PCAdded".to_vec(), Object::Dict(Dict::new()));
|
||||
let added = doc.add(Object::Stream(Stream::from_raw(added, b"q 0 0 1 rg 0 0 10 10 re f Q".to_vec())));
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| {
|
||||
p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(damaged), Object::Ref(added)]))
|
||||
})
|
||||
.unwrap();
|
||||
let listed = contents(&doc);
|
||||
let refused = images::change_image(&mut doc, 0, 0, &images::ImageChange::Arrange(images::ArrangeOrder::BringToFront)).unwrap_err();
|
||||
assert!(refused.to_string().contains("comes after"), "{refused}");
|
||||
assert_eq!(contents(&doc), listed);
|
||||
// Arranged within the content before the damage, it goes on.
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| {
|
||||
p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(added), Object::Ref(damaged)]))
|
||||
})
|
||||
.unwrap();
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Arrange(images::ArrangeOrder::BringToFront)).unwrap();
|
||||
|
||||
let mut doc = two_stream_image_page(image, "");
|
||||
doc.set(ObjRef::new(5, 0), hidden_scale());
|
||||
images::change_image(&mut doc, 0, 0, &shift).unwrap();
|
||||
let moved = images::page_images(&reopen(&doc), 0).unwrap();
|
||||
assert!(close(moved[0].rect, [210.0, 200.0, 310.0, 300.0]), "{:?}", moved[0].rect);
|
||||
}
|
||||
|
||||
/// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש,
|
||||
/// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`.
|
||||
fn hebrew_page(content: &str) -> Document {
|
||||
|
||||
@@ -56,7 +56,8 @@ pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
|
||||
use interp::{Origin, Paint, union};
|
||||
use lines::page_dict;
|
||||
pub(crate) use lines::{
|
||||
ContentStream, FormBudget, content_streams, content_streams_within, dict_in_place, form_call, joined, resource_dict, rewritten_contents, splice,
|
||||
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, form_call, joined, resource_dict,
|
||||
rewritten_contents, splice,
|
||||
};
|
||||
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
|
||||
pub use rewrite::{LineEdit, replace_line, rewrite_block};
|
||||
|
||||
@@ -67,9 +67,19 @@ pub(crate) struct ContentStream {
|
||||
pub(crate) entry: Object,
|
||||
/// Its decoded data, empty when it doesn't decode.
|
||||
pub(crate) data: Vec<u8>,
|
||||
/// The stream is damaged partway: `data` is what decoded before the damage, so the stream
|
||||
/// can't be written again from it without losing the rest ([`rewritten_contents`]).
|
||||
pub(crate) damaged: bool,
|
||||
/// How much of it decoded.
|
||||
pub(crate) read: Read,
|
||||
}
|
||||
|
||||
/// How much of a content stream decoded ([`rewritten_contents`] says what each allows).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Read {
|
||||
Whole,
|
||||
/// Damaged partway: the data is what decoded before the damage. What follows it is unknown,
|
||||
/// the graphics state and syntax the later streams start in too.
|
||||
Damaged,
|
||||
/// Not at all (a filter this reader lacks, say): the data is empty.
|
||||
Unread,
|
||||
}
|
||||
|
||||
/// The page's content streams as listed, each with its decoded data, within one page's
|
||||
@@ -91,15 +101,16 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D
|
||||
};
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for entry in list {
|
||||
let (data, damaged) = match &*doc.resolve(&entry) {
|
||||
let (data, read) = match &*doc.resolve(&entry) {
|
||||
Object::Stream(s) => match budget.decode_with_damage(doc, s, usize::MAX) {
|
||||
Ok(read) => read,
|
||||
Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }),
|
||||
Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))),
|
||||
Err(_) => (Vec::new(), false),
|
||||
Err(_) => (Vec::new(), Read::Unread),
|
||||
},
|
||||
_ => (Vec::new(), false),
|
||||
// Nothing to lose: a null or missing entry draws nothing.
|
||||
_ => (Vec::new(), Read::Whole),
|
||||
};
|
||||
out.push(ContentStream { entry, data, damaged });
|
||||
out.push(ContentStream { entry, data, read });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -136,15 +147,50 @@ pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) ->
|
||||
pieces.splice(edits)
|
||||
}
|
||||
|
||||
/// An error when content at `/Contents` index `at` (a stream there, or one inserted there) is drawn
|
||||
/// after a stream damaged partway: what the damage hides can change how it reads.
|
||||
pub(crate) fn drawn_before_damage(streams: &[ContentStream], at: usize) -> Result<(), EditError> {
|
||||
if at > first_damaged(streams) {
|
||||
return Err(EditError::Invalid(
|
||||
"this change comes after a content stream that is damaged partway, and what the damage hides can change how the rest of the page reads"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The index of the first stream damaged partway, or the number of streams.
|
||||
fn first_damaged(streams: &[ContentStream]) -> usize {
|
||||
streams.iter().position(|s| s.read == Read::Damaged).unwrap_or(streams.len())
|
||||
}
|
||||
|
||||
/// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams
|
||||
/// (keeping their dictionaries), the others stay as they were. An error, before anything is
|
||||
/// added, when a piece that changed is a damaged stream: written from what decoded, it would
|
||||
/// lose everything after the damage.
|
||||
/// added, when a piece that changed is a stream that didn't decode whole, or comes after one
|
||||
/// damaged partway. Written from what decoded, a damaged stream would lose everything after the
|
||||
/// damage, and one that doesn't decode all of it. What follows the damage is unknown, so a later
|
||||
/// stream may not read as it seems: a string or comment the damage left open runs on into it, and
|
||||
/// operators the damage hides can change the graphics state it draws in.
|
||||
pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[ContentStream], new: Vec<Vec<u8>>) -> Result<Object, EditError> {
|
||||
if streams.iter().zip(&new).any(|(s, data)| s.damaged && s.data != *data) {
|
||||
return Err(EditError::Invalid(
|
||||
"this change is in a content stream that is damaged partway, and rewriting it would lose what follows the damage".into(),
|
||||
));
|
||||
// The last stream that changed: when it is drawn before the damage, so are the others.
|
||||
if let Some(i) = streams.iter().zip(&new).rposition(|(s, data)| s.data != *data) {
|
||||
drawn_before_damage(streams, i)?;
|
||||
}
|
||||
for (s, data) in streams.iter().zip(&new) {
|
||||
if s.data == *data {
|
||||
continue;
|
||||
}
|
||||
match s.read {
|
||||
Read::Whole => {}
|
||||
Read::Damaged => {
|
||||
return Err(EditError::Invalid(
|
||||
"this change is in a content stream that is damaged partway, and rewriting it would lose what follows the damage".into(),
|
||||
));
|
||||
}
|
||||
Read::Unread => {
|
||||
return Err(EditError::Invalid("this change is in a content stream that can't be decoded, and rewriting it would lose it".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut contents: Vec<Object> = Vec::with_capacity(streams.len());
|
||||
for (s, data) in streams.iter().zip(new) {
|
||||
|
||||
Reference in new issue
Block a user