diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 582e7a470..a8fcb6f7e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1288,6 +1288,48 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +/// One page whose content is one stream in three pieces (#155): a `TJ` array ends the middle +/// piece and its operator starts the last one. +fn split_streams_pdf() -> Vec { + let piece = |s: &str| format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len()); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R 6 0 R] /Resources << /Font << /F1 7 0 R >> >> >>".into(), + piece("/P << /MCID 0"), + piece(">> BDC BT /F1 12 Tf 72 700 Td (Target) Tj 0 -20 Td [(After) -20 (wards)]"), + piece("TJ ET EMC"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn a_line_split_across_content_streams_is_listed_and_edited() { + let dir = workdir("split-streams"); + std::fs::write(dir.join("split.pdf"), split_streams_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "split.pdf" }))["doc"].as_u64().unwrap(); + let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })); + let texts: Vec<&str> = lines["lines"].as_array().unwrap().iter().filter_map(|l| l["text"].as_str()).collect(); + assert_eq!(texts, ["Target", "Afterwards"], "{lines}"); + let r = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "line": 2, "text": "Later" })); + assert_eq!(r["line"]["text"], "Later"); + assert_eq!(page_text(&mut a, doc)[0].split_whitespace().collect::>(), ["Target", "Later"]); +} + #[test] fn paragraph_bold_without_font_keeps_the_source_family() { let dir = workdir("paragraph-bold"); diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 70e173d3a..a56c952d4 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -208,6 +208,100 @@ pub fn serialize_ops(ops: &[Op]) -> Vec { out } +/// A page's content streams joined into the one stream they are. Writers may split it between +/// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its +/// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back +/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Pieces { + data: Vec, + /// Where each piece ends in `data`, after its separator. + ends: Vec, +} + +impl Pieces { + pub fn join>(pieces: &[D]) -> Self { + let mut data = Vec::new(); + let mut ends = Vec::with_capacity(pieces.len()); + for p in pieces { + data.extend_from_slice(p.as_ref()); + data.push(b'\n'); + ends.push(data.len()); + } + Pieces { data, ends } + } + + /// The joined bytes (the spans of [`Pieces::parse`]'s operators index into them). + pub fn data(&self) -> &[u8] { + &self.data + } + + /// How many pieces there are. + pub fn len(&self) -> usize { + self.ends.len() + } + + pub fn is_empty(&self) -> bool { + self.ends.is_empty() + } + + /// Parse the pieces as one stream. + pub fn parse(&self) -> Parsed { + parse(&self.data) + } + + /// The piece holding joined position `at` (the last piece for positions past the end). + pub fn piece_of(&self, at: usize) -> usize { + self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1)) + } + + /// The pieces an operator spans: (the one its first token is in, the one its keyword is in). + pub fn pieces_of(&self, op: &Op) -> (usize, usize) { + (self.piece_of(op.span.start), self.piece_of(op.span.end.saturating_sub(1))) + } + + /// The joined range of piece `i`, without its separator. + fn range(&self, i: usize) -> std::ops::Range { + let start = match i.checked_sub(1) { + None => 0, + Some(p) => self.ends.get(p).copied().unwrap_or(self.data.len()), + }; + let end = self.ends.get(i).map_or(self.data.len(), |e| e.saturating_sub(1)).max(start); + start..end + } + + /// Rebuild every piece after replacing joined byte ranges: `edits` are (range, bytes) in + /// ascending, non-overlapping order. A replacement goes into the piece its range starts in, + /// and the rest of a range that runs on into later pieces is removed from them, so an + /// operator split across pieces is replaced or removed whole. Everything else is copied byte + /// for byte. Returns every piece's new bytes (unchanged pieces come back equal). + pub fn splice(&self, edits: impl IntoIterator, Vec)>) -> Vec> { + let mut out: Vec> = (0..self.len()).map(|i| Vec::with_capacity(self.range(i).len())).collect(); + let copy = |out: &mut Vec>, from: usize, to: usize| { + for (i, piece) in out.iter_mut().enumerate() { + let r = self.range(i); + let (a, b) = (from.max(r.start), to.min(r.end)); + if a < b { + piece.extend_from_slice(self.data.get(a..b).unwrap_or_default()); + } + } + }; + let mut at = 0; + for (range, bytes) in edits { + let start = range.start.clamp(at, self.data.len()); + copy(&mut out, at, start); + if !bytes.is_empty() + && let Some(piece) = out.get_mut(self.piece_of(start)) + { + piece.extend_from_slice(&bytes); + } + at = range.end.clamp(start, self.data.len()); + } + copy(&mut out, at, self.data.len()); + out + } +} + /// A number operand, written as an integer when it is one. pub fn num(v: f64) -> Object { if v.fract() == 0.0 && v.abs() < 1e15 { Object::Int(v as i64) } else { Object::Real((v * 10_000.0).round() / 10_000.0) } diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 7a4a680f3..e3c6d8d31 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -63,3 +63,28 @@ fn matrices_compose_and_invert() { assert!(overlaps([0.0, 0.0, 10.0, 10.0], [5.0, 5.0, 20.0, 20.0], 0.0)); assert!(!overlaps([0.0, 0.0, 10.0, 10.0], [10.0, 0.0, 20.0, 10.0], 0.0)); } + +#[test] +fn pieces_parse_as_one_stream_and_splice_back() { + // An operator's operands end one piece and its keyword starts the next (§7.8.2). + let pieces = Pieces::join(&[&b"BT 72 700 Td [(After) -20 (wards)]"[..], b"TJ ET", b"q Q"]); + assert_eq!(pieces.len(), 3); + let ops = pieces.parse().ops; + let names: Vec<&[u8]> = ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"BT"[..], b"Td", b"TJ", b"ET", b"q", b"Q"]); + assert_eq!(pieces.pieces_of(&ops[2]), (0, 1)); + assert_eq!(pieces.pieces_of(&ops[3]), (1, 1)); + assert_eq!(pieces.piece_of(usize::MAX), 2); + // Nothing changed: the pieces come back byte for byte. + assert_eq!(pieces.splice([]), [&b"BT 72 700 Td [(After) -20 (wards)]"[..], b"TJ ET", b"q Q"]); + // Replacing the split TJ rewrites both pieces it spans; the third is untouched. + let out = pieces.splice([(ops[2].span.clone(), b"(New) Tj".to_vec())]); + assert_eq!(out, [&b"BT 72 700 Td (New) Tj"[..], b" ET", b"q Q"]); + // Removing it leaves no stray operands or keyword. + let out = pieces.splice([(ops[2].span.clone(), Vec::new())]); + assert_eq!(out, [&b"BT 72 700 Td "[..], b" ET", b"q Q"]); + // Out-of-range or reversed edits are clamped, never a panic. + let out = pieces.splice([(std::ops::Range { start: 10, end: 5 }, b"x".to_vec()), (1000..2000, Vec::new())]); + assert_eq!(out.len(), 3); + assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty()); +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e0e69f3a2..c324aad61 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -790,10 +790,58 @@ fn editing_text_keeps_the_tokens_a_stream_shares_with_its_neighbours() { assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Target"); text::replace_line(&mut doc, 0, 0, "Edited").unwrap(); assert_split_tokens_kept(&reopen(&doc), "Edited"); - // A paragraph rewrite rebuilds the same piece. + // A paragraph rewrite: "Target" and the line after it (split across pieces) are one + // paragraph, rewritten whole without stray tokens. let mut doc = split_streams_page(); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1); text::replace_block(&mut doc, 0, 0, "Rewrapped").unwrap(); - assert_split_tokens_kept(&reopen(&doc), "Rewrapped"); + let (joined, ops) = joined_ops(&reopen(&doc)); + assert!(joined.contains("Rewrapped") && !joined.contains("Target") && !joined.contains("wards"), "{joined}"); + assert!(ops.iter().find(|o| o.is("BDC")).is_some_and(|o| o.operands.len() == 2), "{joined}"); +} + +/// The operators of the page's pieces joined back into one stream, each checked for operands. +fn joined_ops(doc: &Document) -> (String, Vec) { + let joined = streams(doc, 0).join("\n"); + let ops = pdfcraft_content::parse(joined.as_bytes()); + assert_eq!(ops.skipped, 0, "stray tokens: {joined}"); + (joined, ops.ops) +} + +#[test] +fn a_line_whose_operator_starts_the_next_stream_can_be_edited() { + // #155: `[(After) -20 (wards)]` ends one piece and its `TJ` starts the next. + let doc = split_streams_page(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["Target", "Afterwards"]); + assert!(lines[1].rect[1] < lines[0].rect[1] && lines[1].rect[2] > lines[1].rect[0] + 40.0, "{:?}", lines[1].rect); + + // Retyping it replaces the operator whole, in both pieces it spans. + let mut doc = split_streams_page(); + text::replace_line(&mut doc, 0, 1, "Later").unwrap(); + let doc = reopen(&doc); + let (joined, ops) = joined_ops(&doc); + assert!(!joined.contains("After") && !joined.contains("wards") && joined.contains("Target"), "{joined}"); + assert!(ops.iter().filter(|o| o.is("TJ")).count() == 0, "{joined}"); + assert!(ops.iter().find(|o| o.is("BDC")).is_some_and(|o| o.operands.len() == 2), "{joined}"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["Target", "Later"]); + // The pieces stay pieces: the first is untouched, the marked content still closes. + let pieces = streams(&doc, 0); + assert_eq!(pieces.len(), 3); + assert_eq!(pieces[0].trim(), "/P << /MCID 0"); + assert!(pieces[2].contains("EMC") && !pieces[2].contains("TJ"), "{pieces:?}"); + + // As a paragraph with the line before it: both lines are rewritten. + let mut doc = split_streams_page(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let last = blocks.iter().position(|b| b.text.contains("Afterwards")).unwrap(); + text::replace_block(&mut doc, 0, last, "Rewrapped").unwrap(); + let doc = reopen(&doc); + let (joined, ops) = joined_ops(&doc); + assert!(!joined.contains("wards") && joined.contains("Rewrapped"), "{joined}"); + assert!(ops.iter().find(|o| o.is("BDC")).is_some_and(|o| o.operands.len() == 2), "{joined}"); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text == "Rewrapped")); } #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2fe05ef64..449af0220 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use std::rc::Rc; -use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; +use pdfcraft_content::{Matrix, Op, Pieces, serialize_ops}; use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; @@ -38,6 +38,8 @@ pub struct TextLine { /// Whether new text in this font can only be shown by substituting another font (no /// Unicode mapping for the line, so nothing could be reused). pub decodable: bool, + /// The content stream piece the line starts in, and its operators (indices into the page's + /// streams parsed joined). stream: usize, ops: Vec, /// Where the line starts: text matrix (text space), the state there, and its `BT`. @@ -167,32 +169,53 @@ fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { .collect() } -/// Rewrite one of a page's content streams: `edit(i)` gives the operators to insert before -/// operator `i` of `ops` (parsed from `data`) and whether to keep it. Everything else is copied -/// byte for byte. A page's streams are one stream in pieces, split between any two tokens -/// (ISO 32000-2 §7.8.2), so a piece can end with operands whose operator starts the next one, -/// or start by closing a dictionary the previous one opened; those tokens belong to no operator -/// parsed here and must stay where they are. -fn splice(data: &[u8], ops: &[Op], mut edit: impl FnMut(usize) -> (Vec, bool)) -> Vec { - let mut out = Vec::with_capacity(data.len()); - let mut at = 0; +/// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators +/// to insert before operator `i` of `ops` (parsed from the joined pieces) and whether to keep it. +/// Everything else is copied byte for byte. A page's streams are one stream in pieces, split +/// between any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and +/// its keyword start the next; removing or replacing it rewrites every piece it spans. Returns +/// each piece's new bytes. +fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec, bool)) -> Vec> { + let data = pieces.data(); + let mut edits = Vec::new(); for (i, op) in ops.iter().enumerate() { let (insert, keep) = edit(i); if insert.is_empty() && keep { continue; } - let start = op.span.start.clamp(at, data.len()); - out.extend_from_slice(data.get(at..start).unwrap_or_default()); + let mut bytes = Vec::new(); if !insert.is_empty() { - if out.last().is_some_and(|b| !b.is_ascii_whitespace()) { - out.push(b'\n'); + if op.span.start.checked_sub(1).and_then(|p| data.get(p)).is_some_and(|b| !b.is_ascii_whitespace()) { + bytes.push(b'\n'); } - out.extend_from_slice(&serialize_ops(&insert)); + bytes.extend_from_slice(&serialize_ops(&insert)); } - at = if keep { start } else { op.span.end.clamp(start, data.len()) }; + let end = if keep { op.span.start } else { op.span.end }; + edits.push((op.span.start..end, bytes)); + } + pieces.splice(edits) +} + +/// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams +/// (keeping their dictionaries), the others stay as they were. +fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec)], new: Vec>) -> Object { + let mut contents: Vec = Vec::with_capacity(streams.len()); + for ((obj, old), data) in streams.iter().zip(new) { + if *old == data { + contents.push(obj.clone()); + continue; + } + let mut dict = match &*doc.resolve(obj) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + contents.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &data))))); + } + match <[Object; 1]>::try_from(contents) { + Ok([one]) => one, + Err(many) => Object::Array(many), } - out.extend_from_slice(data.get(at..).unwrap_or_default()); - out } fn page_dict(doc: &Document, page: usize) -> Result { @@ -436,59 +459,64 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let mut cache = HashMap::new(); let mut lines: Vec = Vec::new(); let mut carry = Carry::new(); - for (si, (_, data)) in content_streams(doc, &p.dict).into_iter().enumerate() { - let ops = parse(&data).ops; - let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry); - let mut last: Option<(usize, f64, f64, f64)> = None; // (bt, baseline, end_x, size) - for s in shown { - let joins = last.is_some_and(|(bt, base, end, size)| { - bt == s.bt - && lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice()) - && lines.last().is_some_and(|l| { - (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color - }) - && (s.baseline - base).abs() < size * 0.3 - && s.start_x > end - size - && s.start_x - end < size * 3.0 - }); - if joins && let Some(l) = lines.last_mut() { - let gap = s.start_x - last.map_or(s.start_x, |x| x.2); - if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { - l.text.push(' '); - } - l.text.push_str(&s.text); - l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; - l.ops.push(s.op); - l.decodable &= s.decodable; - l.color = fill_color(&s.state.fill); - } else { - lines.push(TextLine { - text: s.text.clone(), - rect: s.rect, - font: String::from_utf8_lossy(&s.font).into_owned(), - base_font: s.base_font.clone(), - size: s.size, - bold: s.bold, - italic: s.italic, - color: fill_color(&s.state.fill), - decodable: s.decodable, - stream: si, - ops: vec![s.op], - origin: Origin { - tm: s.tm.0, - tlm: s.tlm.0, - k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(), - ctm: s.state.ctm.0, - baseline: s.baseline, - x: s.start_x, - state: TextState::of(&s.state), - bt_op: s.bt_op, - bt_state: TextState::of(&s.bt_state), - }, - }); + // The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them + // joined, so an operator whose operands end one piece still has them. + let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); + let ops = pieces.parse().ops; + let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry); + let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size) + for s in shown { + // A line belongs to the piece its first operator starts in (lines in different pieces + // aren't joined or grouped, as before; editing rewrites every piece an edit touches). + let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0); + let joins = last.is_some_and(|(piece, bt, base, end, size)| { + piece == si + && bt == s.bt + && lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice()) + && lines.last().is_some_and(|l| { + (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color + }) + && (s.baseline - base).abs() < size * 0.3 + && s.start_x > end - size + && s.start_x - end < size * 3.0 + }); + if joins && let Some(l) = lines.last_mut() { + let gap = s.start_x - last.map_or(s.start_x, |x| x.3); + if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + l.text.push(' '); } - last = Some((s.bt, s.baseline, s.end_x, s.size.max(1.0))); + l.text.push_str(&s.text); + l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.ops.push(s.op); + l.decodable &= s.decodable; + l.color = fill_color(&s.state.fill); + } else { + lines.push(TextLine { + text: s.text.clone(), + rect: s.rect, + font: String::from_utf8_lossy(&s.font).into_owned(), + base_font: s.base_font.clone(), + size: s.size, + bold: s.bold, + italic: s.italic, + color: fill_color(&s.state.fill), + decodable: s.decodable, + stream: si, + ops: vec![s.op], + origin: Origin { + tm: s.tm.0, + tlm: s.tlm.0, + k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(), + ctm: s.state.ctm.0, + baseline: s.baseline, + x: s.start_x, + state: TextState::of(&s.state), + bt_op: s.bt_op, + bt_state: TextState::of(&s.bt_state), + }, + }); } + last = Some((si, s.bt, s.baseline, s.end_x, s.size.max(1.0))); } // Lines of only spaces aren't editable text. lines.retain(|l| !l.text.trim().is_empty()); @@ -695,9 +723,9 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let streams = content_streams(doc, &p.dict); - let (stream_obj, data) = streams.get(target.stream).cloned().ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; - let ops = parse(&data).ops; - let first = target.ops[0]; + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; // The line's own font, when it can show every character. let font = fonts_res.get(target.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); let reused = font.as_ref().and_then(|m| m.encode(&text)); @@ -753,22 +781,14 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the // line drawn in the same area go too, so the replacement is all that shows. let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect)); - let new_data = splice(&data, &ops, |i| { - if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.get(&target.stream).is_some_and(|d| d.contains(&i))) } - }); - let mut dict = match &*doc.resolve(&stream_obj) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - let new = doc.add(Object::Stream(Stream::flate(dict, &new_data))); - let contents: Vec = streams.iter().enumerate().map(|(i, (o, _))| if i == target.stream { Object::Ref(new) } else { o.clone() }).collect(); + let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); + let contents = rewritten_contents(doc, &streams, new_data); let page_ref = p.obj; if substituted.is_some() { res.set(b"Font".to_vec(), Object::Dict(fonts_res)); } doc.update_dict(page_ref, |d| { - d.set(b"Contents".to_vec(), if contents.len() == 1 { contents[0].clone() } else { Object::Array(contents) }); + d.set(b"Contents".to_vec(), contents); if substituted.is_some() { d.set(b"Resources".to_vec(), Object::Dict(res)); } @@ -781,13 +801,13 @@ fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) } -/// The operators of every line drawn in the same area as one of `rects`, grouped by content -/// stream. Documents sometimes draw a line more than once (fake bold, an invisible text layer), -/// and a surviving copy would show the old text under the replaced line. Copies overlap a -/// member's rect by more than half of the smaller rect; neighbouring lines share no area. -fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashMap> { +/// The operators of every line drawn in the same area as one of `rects`. Documents sometimes +/// draw a line more than once (fake bold, an invisible text layer), and a surviving copy would +/// show the old text under the replaced line. Copies overlap a member's rect by more than half of +/// the smaller rect; neighbouring lines share no area. +fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashSet { let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); - let mut drop = std::collections::HashMap::new(); + let mut drop = std::collections::HashSet::new(); for l in lines { let covered = rects.iter().any(|m| { let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0); @@ -796,7 +816,7 @@ fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::H small > 0.0 && ix * iy / small > 0.5 }); if covered { - drop.entry(l.stream).or_insert_with(std::collections::HashSet::new).extend(l.ops.iter().copied()); + drop.extend(l.ops.iter().copied()); } } drop @@ -935,8 +955,8 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let streams = content_streams(doc, &p.dict); - let (stream_obj, data) = streams.get(first.stream).cloned().ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; - let ops = parse(&data).ops; + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; let text = text.unwrap_or(&b.text).split(paragraph_separator).filter(|word| !word.is_empty()).collect::>().join(" "); let o = &first.origin; let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; @@ -1117,18 +1137,18 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option block_ops.push(Op::new("Q", vec![])); // The paragraph's operators go, and so does anything drawn in the same area (a fake-bold // second copy, an invisible text layer): the new text is all that may show. - let mut drop: std::collections::HashMap> = - members.iter().flat_map(|l| [(l.stream, l.ops.iter().copied().collect::>())]).collect(); let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect(); - for (stream, ops) in coincident_ops(&lines, &member_rects) { - drop.entry(stream).or_default().extend(ops); - } + let mut drop = coincident_ops(&lines, &member_rects); + drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); // Text shown earlier in the same text object stays first: the paragraph then goes where it // was, between two halves of the text object, so it keeps its place in reading order. let start = first.ops.first().copied().unwrap_or(o.bt_op); - let split = ops.get(o.bt_op..start).unwrap_or_default().iter().enumerate().any(|(i, op)| { - matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.get(&first.stream).is_some_and(|d| d.contains(&(o.bt_op + i))) - }); + let split = ops + .get(o.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); // What the text after the paragraph expects: the state at its BT (or where it was split). let after = if split { &o.state } else { &o.bt_state }; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); @@ -1138,23 +1158,17 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); } let at = if split { start } else { o.bt_op }; - let new_data = splice(&data, &ops, |i| { + let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; - (insert, drop.get(&first.stream).is_none_or(|d| !d.contains(&i))) + (insert, !drop.contains(&i)) }); - let mut dict = match &*doc.resolve(&stream_obj) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - let new = doc.add(Object::Stream(Stream::flate(dict, &new_data))); - let contents: Vec = streams.iter().enumerate().map(|(i, (o, _))| if i == first.stream { Object::Ref(new) } else { o.clone() }).collect(); + let contents = rewritten_contents(doc, &streams, new_data); if new_font { res.set(b"Font".to_vec(), Object::Dict(fonts_res)); } let page_ref = p.obj; doc.update_dict(page_ref, |d| { - d.set(b"Contents".to_vec(), if contents.len() == 1 { contents[0].clone() } else { Object::Array(contents) }); + d.set(b"Contents".to_vec(), contents); if new_font { d.set(b"Resources".to_vec(), Object::Dict(res)); } diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 3876c6861..4a607dddf 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -20,7 +20,7 @@ use std::collections::HashMap; use std::rc::Rc; -use pdfcraft_content::{Matrix, Op, contains, num, overlaps, parse, serialize_ops, string}; +use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; use crate::{Report, image}; @@ -210,15 +210,8 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< // The streams are one content stream in pieces, which may be split between any two tokens // (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the // next. Parse them joined, then give each operator back to the piece its keyword is in. - let mut joined: Vec = Vec::new(); - let mut ends: Vec = Vec::with_capacity(streams.len()); - for data in streams { - joined.extend_from_slice(data); - joined.push(b'\n'); - ends.push(joined.len()); - } - let piece_of = |at: usize| ends.iter().position(|&e| at < e).unwrap_or(ends.len().saturating_sub(1)); - let mut parsed = parse(&joined); + let joined = Pieces::join(streams); + let mut parsed = joined.parse(); if !scope.hidden_layers.is_empty() { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -231,7 +224,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< let mut pieces: Vec> = streams.iter().map(|_| Vec::new()).collect(); let mut rewrite: Vec = vec![forced_change; streams.len()]; for op in parsed.ops { - let (first, last) = (piece_of(op.span.start), piece_of(op.span.end.saturating_sub(1))); + let (first, last) = joined.pieces_of(&op); // An operator split across pieces is written whole into its keyword's piece, so every // piece it spans is rewritten. if first < last { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3a1e4f2ec..ba32039e8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1806,8 +1806,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] -notes = "click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited"] +notes = "click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next" [[feature]] id = "edit.text-reflow"