fix(edit): restore inherited text state when arranging Forms

Track the text-state operators with saved drawing state, restoring inherited spacing, scaling, leading, rendering mode, rise and known font before relocated Form artwork. Emit restoration inside BT/ET, and leave a font selected by a replayed ExtGState authoritative over an older direct Tf. The exact inherited 12 Tc fixture failed its saved-PDF raster comparison before this fix; zero-spacing and destination-state controls now preserve rendering. Scoped Linux native, wasm, clippy and formatting checks pass.

Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
This commit is contained in:
FredAppsandCodex GPT-6.1 committed 2026-11-01 17:41:13 +01:00
1 parent 8669aa48aa
commit e677c009b3
2 files changed
+42 -5

No files matched your search

+22 -5
View File
@@ -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<SharedColorOps>,
stroking_color: Option<SharedColorOps>,
/// The last operator of each of [`DRAWING_PARAMS`].
params: [Option<std::rc::Rc<Op>>; 7],
params: [Option<std::rc::Rc<Op>>; 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<Op> {
use pdfcraft_content::num;
let operands = match name {
@@ -831,6 +831,8 @@ fn initial_param(name: &str) -> Option<Op> {
"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
{
+20
View File
@@ -10176,6 +10176,26 @@ fn review_state_raster(doc: &Document) -> Vec<u8> {
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());