diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 607a1ec5c4..dfe083bec9 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -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 { } } -/// 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, 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, 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 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 { 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 = 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(); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 78a6d96589..7e719acabb 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -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 diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e69a57dc33..4f3b0c374e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1285,7 +1285,7 @@ pub fn tools() -> Vec { "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 }, diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 201612e5b2..5e5c44fab7 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -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())); } diff --git a/crates/edit/src/objects.rs b/crates/edit/src/objects.rs index c0a57c358a..6a64e3cfce 100644 --- a/crates/edit/src/objects.rs +++ b/crates/edit/src/objects.rs @@ -338,7 +338,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result, 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) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index df9bce657d..f3b4e3cc2f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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::>(); + 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 { diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 01c0acdb70..40675b4892 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -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}; diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index bf66cc0f8b..58b4ac6289 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -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, - /// 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>) -> Result { - 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 = Vec::with_capacity(streams.len()); for (s, data) in streams.iter().zip(new) {