fix: compare the effective gs state, not the history of gs names, when grouping rows

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-18 22:32:20 +00:00
co-authored by Claude Haiku 5.5
parent 57c6e476db
commit ed6ccdaebc
2 changed files with 83 additions and 22 deletions
+17 -1
View File
@@ -4808,7 +4808,7 @@ fn row_state_page(content: &str) -> Document {
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /ExtGState /ca 0.3 /CA 0.3 >>".into(),
"<< /Type /OCG /Name (Hidden layer) >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources << /Font << /F1 3 0 R >> /ExtGState << /GS0 4 0 R >> /Properties << /L1 5 0 R >> >> >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources << /Font << /F1 3 0 R >> /ExtGState << /GS0 4 0 R /GS1 << /Type /ExtGState /ca 0.3 /CA 0.3 /BM /Normal >> /GS2 << /Type /ExtGState /ca 1 /CA 1 >> >> /Properties << /L1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
@@ -4889,3 +4889,19 @@ fn editing_a_row_keeps_the_layer_opacity_and_scale_of_the_rows_around_it() {
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "Hello world, this is the first line and the second");
}
/// What keeps rows apart is the graphics state they are drawn in, not the `gs` operators
/// before them: a repeated `gs` without q/Q, or another name with the same settings, is still one
/// paragraph, and a `gs` that changes the opacity or puts it back is not.
#[test]
fn rows_under_repeated_or_equal_gs_states_stay_one_paragraph() {
let row1 = "BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj ET\n";
let row2 = "BT /F1 12 Tf 72 686 Td (and the second) Tj ET";
let paragraphs = |content: String| text::text_blocks(&row_state_page(&content), 0).unwrap().len();
assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs {row2}")), 1, "the same name twice");
assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs /GS0 gs {row2}")), 1, "the same name three times");
assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS1 gs {row2}")), 1, "two names, the same settings");
assert_eq!(paragraphs(format!("/GS2 gs {row1}{row2}")), 1, "settings that are the defaults");
assert_eq!(paragraphs(format!("{row1}/GS0 gs {row2}")), 2, "another opacity");
assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS2 gs {row2}")), 2, "the opacity put back");
}
+66 -21
View File
@@ -231,17 +231,64 @@ impl TextState {
}
/// What a run is drawn in beyond its text state: the optional content groups of the `/OC`
/// marked-content sequences around it, and the graphics state parameter dictionaries (`gs`)
/// applied before it. A rewrite writes a paragraph in its first run's place, so runs that differ
/// here are never joined: one in a hidden layer or at another opacity would lose it.
/// marked-content sequences around it, and the effective graphics state parameters (`gs`) in
/// force. A rewrite writes a paragraph in its first run's place, so runs that differ here are
/// never joined: one in a hidden layer or at another opacity would lose it.
#[derive(Clone, Debug, Default, PartialEq)]
struct Paint {
layers: Vec<Vec<u8>>,
gs: Vec<Vec<u8>>,
gs: Gs,
}
/// How many `gs` operators a [`Paint`] records; the rest of a run of them is not told apart.
const MAX_PAINT_GS: usize = 32;
/// The transparency parameters of the graphics state: what the `gs` operators have set, resolved,
/// so a repeated `gs` or two names with the same settings leave it as it was.
#[derive(Clone, Debug, PartialEq)]
struct Gs {
/// `ca`, the fill opacity.
fill_alpha: f64,
/// `CA`, the stroke opacity.
stroke_alpha: f64,
/// `BM`, the blend mode (`Compatible` is `Normal`).
blend: Vec<u8>,
/// `SMask`, when it is not `None`: the reference of an indirect soft mask, else the dictionary.
soft_mask: Option<Object>,
}
impl Default for Gs {
fn default() -> Self {
Gs { fill_alpha: 1.0, stroke_alpha: 1.0, blend: b"Normal".to_vec(), soft_mask: None }
}
}
impl Gs {
/// Applies the parameters a graphics state parameter dictionary sets; the others stay.
fn apply(&mut self, doc: &Document, params: &Dict) {
let num = |key: &[u8]| params.get(key).and_then(|o| doc.resolve(o).as_f64()).filter(|v| v.is_finite());
if let Some(v) = num(b"ca") {
self.fill_alpha = v;
}
if let Some(v) = num(b"CA") {
self.stroke_alpha = v;
}
if let Some(bm) = params.get(b"BM").map(|o| doc.resolve(o)) {
let first = bm
.as_name()
.map(<[u8]>::to_vec)
.or_else(|| bm.as_array().and_then(|a| a.iter().find_map(|o| doc.resolve(o).as_name().map(<[u8]>::to_vec))));
if let Some(name) = first {
self.blend = if name == b"Compatible" { b"Normal".to_vec() } else { name };
}
}
if let Some(mask) = params.get(b"SMask") {
self.soft_mask = match (mask.as_ref(), &*doc.resolve(mask)) {
(_, Object::Name(_)) => None,
(Some(r), _) => Some(Object::Ref(r)),
(None, Object::Dict(d)) => Some(Object::Dict(d.clone())),
(None, _) => None,
};
}
}
}
#[derive(Clone, Debug, PartialEq)]
struct Origin {
@@ -534,8 +581,8 @@ struct Ts {
leading: f64,
rise: f64,
render_mode: i64,
/// The `gs` resource names applied so far (see [`Paint`]).
gs: Vec<Vec<u8>>,
/// The transparency parameters the `gs` operators have set so far (see [`Paint`]).
gs: Gs,
}
fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
@@ -745,7 +792,7 @@ impl Carry {
leading: 0.0,
rise: 0.0,
render_mode: 0,
gs: Vec::new(),
gs: Gs::default(),
};
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() }
}
@@ -757,12 +804,14 @@ type Invocation = (usize, Vec<u8>, Ts);
fn interpret(
doc: &Document,
ops: &[Op],
fonts_res: &Dict,
resources: &Dict,
cache: &mut HashMap<Vec<u8>, (Rc<Metrics>, Option<String>)>,
carry: &mut Carry,
mut invoked: Option<&mut Vec<Invocation>>,
logical_text: bool,
) -> Vec<Shown> {
let fonts_res = resources.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let states = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let mut out: Vec<Shown> = Vec::new();
let mut ts = carry.ts.clone();
let mut at_bt = (0usize, ts.clone());
@@ -865,10 +914,9 @@ fn interpret(
b"Ts" => ts.rise = op.num(0).unwrap_or(0.0),
b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1),
b"gs" => {
if let Some(name) = op.name(0)
&& ts.gs.len() < MAX_PAINT_GS
{
ts.gs.push(name.to_vec());
let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p));
if let Some(params) = params.as_deref().and_then(Object::as_dict) {
ts.gs.apply(doc, params);
}
}
b"Do" => {
@@ -1045,7 +1093,6 @@ fn interpret(
pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
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();
@@ -1053,7 +1100,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
// 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::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true);
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, true);
let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended
for mut s in shown {
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a
@@ -1296,10 +1343,9 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
}
/// `stream` gives the stream number of the operator at an index of `ops`.
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) {
let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut invoked = Vec::new();
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true);
let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), true);
let mut last = None;
let mut last_stream = None;
let mut forms = invoked.into_iter().peekable();
@@ -1925,11 +1971,10 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict);
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, false);
let mut patches = HashMap::new();
for s in shown.into_iter().filter(|s| drops.contains(&s.op)) {
if s.state.render_mode >= 4 {
@@ -2586,7 +2631,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, false);
let mut patches = HashMap::new();
// Keep full precision: a tiny rounded text-space change can be magnified by a CTM,
// and restoring rise/spacing must not alter following unselected text.