diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 5df9ebeb7c..a2fe8e03e8 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -763,14 +763,14 @@ impl ExtendedGStateTracker { } /// The drawing-state operators besides colour that a moved unit restores, in the order it writes them. -const DRAWING_PARAMS: [&str; 7] = ["w", "J", "j", "M", "d", "ri", "i"]; +const DRAWING_PARAMS: [&str; 14] = ["w", "J", "j", "M", "d", "ri", "i", "Tc", "Tw", "Tz", "TL", "Tr", "Ts", "Tf"]; #[derive(Clone, Default, Debug, PartialEq)] struct DrawingState { non_stroking_color: Option, stroking_color: Option, /// The last operator of each of [`DRAWING_PARAMS`]. - params: [Option>; 7], + params: [Option>; DRAWING_PARAMS.len()], } // Save/restore shares operand storage, including when only the current colour changes under q. @@ -822,7 +822,7 @@ impl DrawingStateTracker { } } -/// Initial line-style parameters and rendering intent (ISO 32000-2 Table 51). +/// Initial line style, rendering intent and text-state parameters. fn initial_param(name: &str) -> Option { use pdfcraft_content::num; let operands = match name { @@ -831,6 +831,8 @@ fn initial_param(name: &str) -> Option { "M" => vec![num(10.0)], "d" => vec![Object::Array(vec![]), num(0.0)], "ri" => vec![Object::name("RelativeColorimetric")], + "Tc" | "Tw" | "TL" | "Tr" | "Ts" => vec![num(0.0)], + "Tz" => vec![num(100.0)], _ => return None, }; Some(Op::new(name, operands)) @@ -851,13 +853,21 @@ fn compute_inherited_drawing_ops(source: &DrawingState, dest: &DrawingState, ext (false, None, Some(_)) => ops.push(Op::new(black, vec![pdfcraft_content::num(0.0)])), } } + let mut text_ops = Vec::new(); for ((name, source), dest) in DRAWING_PARAMS.iter().zip(&source.params).zip(&dest.params) { + let output = if matches!(*name, "Tc" | "Tw" | "Tz" | "TL" | "Tr" | "Ts" | "Tf") { &mut text_ops } else { &mut ops }; match (sets(&[name]), source, dest) { (true, _, _) | (false, None, None) => {} - (false, Some(op), _) => ops.push((**op).clone()), - (false, None, Some(_)) => ops.extend(initial_param(name)), + (false, Some(op), _) => output.push((**op).clone()), + (false, None, Some(_)) => output.extend(initial_param(name)), } } + if !text_ops.is_empty() { + // Forms inherit text state, even though each BT resets its text matrices. + ops.push(Op::new("BT", vec![])); + ops.extend(text_ops); + ops.push(Op::new("ET", vec![])); + } ops } @@ -1307,6 +1317,13 @@ fn arrange_image( state.apply(op)?; tracker.step(op, &ext_gstates, doc, state.placement.matrix)?; drawing_tracker.step(op)?; + if op.is("gs") + && op.name(0).and_then(|name| tracker.definitions.get(name)).and_then(|gs| gs.as_dict()).is_some_and(|gs| gs.get(b"Font").is_some()) + && let Some(font) = DRAWING_PARAMS.iter().position(|name| *name == "Tf").and_then(|index| drawing_tracker.current.params.get_mut(index)) + { + // The replayed gs selects its font; do not overwrite it with an older direct Tf. + *font = None; + } if op.is("gs") && let Some(intent) = &tracker.current_state.rendering_intent { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 72e7ec88b1..72e0a232f8 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -10176,6 +10176,26 @@ fn review_state_raster(doc: &Document) -> Vec { renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }).rgba.to_vec() } +#[test] +fn review_arrange_form_keeps_inherited_text_spacing() { + for (spacing, later_spacing) in [(0, false), (12, false), (0, true)] { + let initial = if later_spacing { String::new() } else { format!("BT {spacing} Tc ET") }; + let later = if later_spacing { "BT 12 Tc ET" } else { "" }; + let content = format!("q {initial} q 1 0 0 1 20 20 cm /Fm0 Do Q Q {later} q 0 0 1 rg 200 200 5 5 re f Q"); + let mut doc = form_page_with(&content); + let form = pdfcraft_cos::ObjRef::new(6, 0); + let dict = doc.get(form).as_dict().unwrap().clone(); + doc.set(form, Object::Stream(pdfcraft_cos::Stream::from_raw(dict, b"BT /F1 20 Tf 0 15 Td (ABC) Tj ET".to_vec()))); + let doc_before = reopen(&doc); + let before = review_state_raster(&doc_before); + assert!(before.as_chunks::<4>().0.iter().any(|p| p[0] == 0 && p[1] == 0 && p[2] == 0), "the Form actually paints text"); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let saved = reopen(&doc); + assert_eq!(images::page_images(&saved, 0).unwrap()[0].rect, images::page_images(&doc_before, 0).unwrap()[0].rect); + assert_eq!(review_state_raster(&saved), before, "inherited {spacing} Tc must survive arranging the Form and reopening"); + } +} + fn review_shared_mask_page(states: &str, luminosity_b: bool, mask_paint: &str) -> Document { let content = format!("q {states} q 100 0 0 100 20 20 cm /Im0 Do Q Q q 0 0 1 rg 200 200 5 5 re f Q"); let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());