Edit text: the graphics state carries over between a page's content streams (#150)

A page's /Contents array is one content stream in pieces (ISO 32000-2
§7.8.2), so a cm, a q that is still open, the font and the colour set in
one piece still apply in the next. Edit text read every piece with a
fresh state. AutoCAD scales the whole page in its first stream
(0.12 0 0 0.12 0 0 cm) and draws in the following ones, so the text
boxes of its plans landed far off the page and no label could be edited.

Co-authored-by: Oliver Schiemann <228913879+Checkov23@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Checkov23
2026-10-07 21:12:59 -04:00
committed by GitHub
co-authored by Oliver Schiemann Brandon Thomas
parent 0636b9ca46
commit d63bc7ad98
2 changed files with 80 additions and 14 deletions
+48
View File
@@ -728,3 +728,51 @@ fn paragraphs_move_and_rewrap_to_a_new_width() {
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(0.0), ..Default::default() }).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap().iter().map(|b| b.text.split_whitespace().count()).sum::<usize>(), 12);
}
/// A page split into several content streams, as AutoCAD writes them: the first scales the
/// page (`0.12 0 0 0.12 0 0 cm`, no `q`), the text comes in a later one.
fn split_page(first: &str, second: &str) -> Document {
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] /Resources << /Font << /F1 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{first}\nendstream", first.len()),
format!("<< /Length {} >>\nstream\n{second}\nendstream", second.len()),
"<< /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()
}
#[test]
fn the_graphics_state_carries_over_between_content_streams() {
let close = |a: f64, b: f64| (a - b).abs() < 0.5;
// Text at (1000, 2000) in a space scaled by 0.12: on the page at (120, 240), 12 pt.
let mut doc = split_page("0.12 0 0 0.12 0 0 cm", "BT /F1 100 Tf 1000 2000 Td (Site plan) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1, "{lines:?}");
let l = &lines[0];
assert!(close(l.rect[0], 120.0) && l.rect[1] > 230.0 && l.rect[3] < 260.0, "{:?}", l.rect);
assert!(close(l.size, 12.0), "{}", l.size);
// Editing keeps the paragraph where it was, at its size; moving it moves it in page space.
text::rewrite_block(&mut doc, 0, 0, Some("Site plan, rev. B"), &text::BlockStyle { offset: Some([10.0, -20.0]), ..Default::default() }).unwrap();
let after = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(after[0].text, "Site plan, rev. B");
assert!(close(after[0].rect[0], 130.0) && close(after[0].rect[1], l.rect[1] - 20.0), "{:?}", after[0].rect);
assert!(close(after[0].size, 12.0), "{}", after[0].size);
// A `q` left open in one stream and closed in the next still restores the state.
let doc = split_page("q 0.5 0 0 0.5 0 0 cm", "Q BT /F1 10 Tf 100 100 Td (Footpath) Tj ET");
let l = &text::text_lines(&doc, 0).unwrap()[0];
assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}");
}
+32 -14
View File
@@ -219,21 +219,36 @@ struct Shown {
decodable: bool,
}
fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap<Vec<u8>, Rc<Metrics>>) -> Vec<Shown> {
/// The graphics state carried from one of a page's content streams to the next: the streams
/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
struct Carry {
ts: Ts,
stack: Vec<Ts>,
}
impl Carry {
fn new() -> Self {
let ts = Ts {
ctm: Matrix::IDENTITY,
fill: Vec::new(),
font: None,
size: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
scale: 1.0,
leading: 0.0,
rise: 0.0,
};
Carry { ts, stack: Vec::new() }
}
}
fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap<Vec<u8>, Rc<Metrics>>, carry: &mut Carry) -> Vec<Shown> {
let mut out = Vec::new();
let mut ts = Ts {
ctm: Matrix::IDENTITY,
fill: Vec::new(),
font: None,
size: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
scale: 1.0,
leading: 0.0,
rise: 0.0,
};
let mut ts = carry.ts.clone();
let mut at_bt = (0usize, ts.clone());
let mut stack: Vec<Ts> = Vec::new();
let mut stack: Vec<Ts> = std::mem::take(&mut carry.stack);
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
let mut bt = 0usize;
for (i, op) in ops.iter().enumerate() {
@@ -380,6 +395,8 @@ fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap<V
_ => {}
}
}
carry.ts = ts;
carry.stack = stack;
out
}
@@ -390,9 +407,10 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
let fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut lines: Vec<TextLine> = 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);
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)| {