fix(edit): reach the styled rewrite's rows at the step they are written

The styled path writes rows `line_spacing` (1.5 by default) times the
size apart when a size or spacing is set, but its Reach assumed 1.2, so
an image over only the lower rows of a larger rewrite was missed. The
pitch now comes from the row writer's own step (row_step, shared by
both), at the largest size among the paragraph's lines and ranges.

The landing test gains a six-row rewrite at twice the size over an image
covering only its last row; it fails with the 1.2 step.
A case with only line_spacing set covers the other branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76andClaude Opus 5.5 committed 2026-10-30 02:53:45 -04:00
1 parent 705d862328
commit 8fd151ff79
2 files changed
+22 -8

No files matched your search

+8 -1
View File
@@ -4577,6 +4577,8 @@ fn revealed_text_moves_on_top_only_when_painted_over() {
fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() {
let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q";
let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() };
let sized = text::BlockStyle { size: Some(24.0), ..Default::default() };
let spaced = text::BlockStyle { line_spacing: Some(3.0), ..Default::default() };
let cases = [
("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved),
("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved),
@@ -4584,7 +4586,12 @@ fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() {
("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there,\nthis line now runs well past the scan's left edge", &text::BlockStyle::default()),
("BT /F1 12 Tf 3 Tr 110 230 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &text::BlockStyle::default()),
];
for (content, edit, style) in cases {
let low = "q 200 0 0 30 100 300 cm /Im0 Do Q";
let cases = cases.into_iter().map(|(c, e, s)| (c, e, s, scan)).chain([
("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &sized, low),
("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &spaced, low),
]);
for (content, edit, style, scan) in cases {
let mut doc = image_page_with(&format!("{content} {scan}"));
let before = text::text_blocks(&doc, 0).unwrap().remove(0);
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
+14 -7
View File
@@ -402,6 +402,16 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB
Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0)))
}
/// How far below a row of `size` the next one is written: the source's leading unless a size
/// or line spacing is chosen.
fn row_step(style: &BlockStyle, size: f64, source_leading: f64) -> f64 {
if style.line_spacing.is_none() && style.size.is_none() {
source_leading
} else {
style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(size)
}
}
fn wrap_styled_rows(
chars: &[StyledChar],
width: f64,
@@ -812,8 +822,9 @@ pub(crate) fn rewrite_styled_block(
.unwrap_or(first.size * 1.5);
// Revealed OCR text that something is painted over goes on top of the page, where it starts
// from the initial state.
let largest = style.ranges.iter().filter_map(|r| r.size).chain(style.size).fold(first.size, f64::max);
let pitch = source_leading.max(largest * style.line_spacing.unwrap_or(1.2));
let largest =
block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max);
let pitch = source_leading.max(row_step(style, largest, source_leading));
let widest = measured.iter().copied().fold(width, f64::max);
// This path lays rows out along user-space baselines, so the paragraph is upright here.
let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true };
@@ -972,11 +983,7 @@ pub(crate) fn rewrite_styled_block(
x += segment_width;
pos = end;
}
baseline -= if style.line_spacing.is_none() && style.size.is_none() {
source_leading
} else {
style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size)
};
baseline -= row_step(style, anchor.size, source_leading);
}
block_ops.push(Op::new("Q", vec![]));
let on_top = crate::lift_on_top(over, &mut block_ops);