mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
Edit text: keep the tokens a content stream shares with its neighbours (#154)
A page's /Contents array is one content stream in pieces, and a writer may split it between any two tokens (ISO 32000-2 §7.8.2): a TJ array can end one piece with its operator starting the next, and a marked-content dictionary can close at the start of the next piece. Replacing a line or rewriting a paragraph re-serialized the parsed operators of the line's piece, and the parser keeps neither operands left at the end of a piece nor a dictionary's closing >> at its start. The edit dropped them: the text after the edited line vanished, and the page was left with an operator without operands (TJ, BDC), which other readers reject. The piece is now rewritten by splicing: only the replaced, inserted and removed operators change, and every other byte is copied as it was. Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
EthanSuquet
Claude Opus 5.5
Brandon Thomas
parent
d63bc7ad98
commit
ff1a8b85dc
@@ -384,6 +384,62 @@ fn text_lines_are_found_and_replaced_in_place() {
|
||||
assert_eq!(lines[1].rect, second);
|
||||
}
|
||||
|
||||
/// A page whose content is one stream in three pieces, split between tokens: a marked-content
|
||||
/// dictionary closes at the start of the middle piece, and the `TJ` array that ends it is shown
|
||||
/// by the operator that starts the last one.
|
||||
fn split_streams_page() -> Document {
|
||||
let piece = |s: &str| format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len());
|
||||
let objs: Vec<String> = 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 offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// The page's pieces joined back into one stream: every operator must still have its operands.
|
||||
fn assert_split_tokens_kept(doc: &Document, new_text: &str) {
|
||||
let joined = streams(doc, 0).join("\n");
|
||||
assert!(joined.contains(new_text) && !joined.contains("Target"), "{joined}");
|
||||
assert!(joined.contains("[(After) -20 (wards)]"), "the text after the line is gone: {joined}");
|
||||
for op in pdfcraft_content::parse(joined.as_bytes()).ops {
|
||||
let want = match op.op.as_slice() {
|
||||
b"BDC" => 2,
|
||||
b"TJ" => 1,
|
||||
_ => continue,
|
||||
};
|
||||
assert_eq!(op.operands.len(), want, "{} lost its operands: {joined}", String::from_utf8_lossy(&op.op));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_text_keeps_the_tokens_a_stream_shares_with_its_neighbours() {
|
||||
let mut doc = split_streams_page();
|
||||
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.
|
||||
let mut doc = split_streams_page();
|
||||
text::replace_block(&mut doc, 0, 0, "Rewrapped").unwrap();
|
||||
assert_split_tokens_kept(&reopen(&doc), "Rewrapped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
|
||||
+38
-21
@@ -167,6 +167,34 @@ fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
|
||||
.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<Op>, bool)) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut at = 0;
|
||||
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());
|
||||
if !insert.is_empty() {
|
||||
if out.last().is_some_and(|b| !b.is_ascii_whitespace()) {
|
||||
out.push(b'\n');
|
||||
}
|
||||
out.extend_from_slice(&serialize_ops(&insert));
|
||||
}
|
||||
at = if keep { start } else { op.span.end.clamp(start, data.len()) };
|
||||
}
|
||||
out.extend_from_slice(data.get(at..).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
fn page_dict(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
|
||||
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
|
||||
}
|
||||
@@ -621,7 +649,7 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
|
||||
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 mut ops = parse(&data).ops;
|
||||
let ops = parse(&data).ops;
|
||||
let first = target.ops[0];
|
||||
// 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));
|
||||
@@ -678,20 +706,15 @@ 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 mut new_ops = Vec::with_capacity(ops.len() + replacement.len());
|
||||
for (i, op) in ops.drain(..).enumerate() {
|
||||
if i == first {
|
||||
new_ops.append(&mut replacement);
|
||||
} else if !drops.get(&target.stream).is_some_and(|d| d.contains(&i)) {
|
||||
new_ops.push(op);
|
||||
}
|
||||
}
|
||||
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, &serialize_ops(&new_ops))));
|
||||
let new = doc.add(Object::Stream(Stream::flate(dict, &new_data)));
|
||||
let contents: Vec<Object> = streams.iter().enumerate().map(|(i, (o, _))| if i == target.stream { Object::Ref(new) } else { o.clone() }).collect();
|
||||
let page_ref = p.obj;
|
||||
if substituted.is_some() {
|
||||
@@ -1059,22 +1082,16 @@ 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 mut new_ops = Vec::with_capacity(ops.len() + block_ops.len());
|
||||
for (i, op) in ops.into_iter().enumerate() {
|
||||
if i == at {
|
||||
new_ops.append(&mut block_ops);
|
||||
}
|
||||
let keep = drop.get(&first.stream).is_none_or(|d| !d.contains(&i));
|
||||
if keep {
|
||||
new_ops.push(op);
|
||||
}
|
||||
}
|
||||
let new_data = splice(&data, &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)))
|
||||
});
|
||||
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, &serialize_ops(&new_ops))));
|
||||
let new = doc.add(Object::Stream(Stream::flate(dict, &new_data)));
|
||||
let contents: Vec<Object> = streams.iter().enumerate().map(|(i, (o, _))| if i == first.stream { Object::Ref(new) } else { o.clone() }).collect();
|
||||
if new_font {
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts_res));
|
||||
|
||||
Reference in New Issue
Block a user