M7: keep an explicit leading, and a grown paragraph's neighbour (#2517)

The join limit follows the text object's TL when it has one, checked against the measured baseline gap. A rewrite of every row is marked so a new row one leading above the next paragraph does not absorb it.
This commit is contained in:
ihsan mokhlisse
2026-10-29 10:11:18 +01:00
parent e228e0edfc
commit 53e6d832af
7 changed files with 124 additions and 17 deletions
+1 -1
View File
@@ -204,6 +204,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is checked against the measured baseline gap: rows at that gap stay one paragraph, and a gap of twice the `TL` does not. Growing the paragraph so the new row is one leading above the next paragraph does not absorb it on a later edit. Estimates unchanged.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
@@ -223,7 +224,6 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. Rows join while the gap is within the paragraph's own leading, or within about twice the font size when the paragraph has only one row. Editing the first paragraph leaves the next one in place, including after the first paragraph grows by a row. Estimates unchanged.
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
+17 -5
View File
@@ -9458,9 +9458,10 @@ fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() {
assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]);
}
/// #2517, through the same tools as the report: one blank line is two paragraphs, and editing
/// the first leaves the second. Growing the first by a row does not absorb a neighbour that
/// started three sizes below.
/// #2517, through the same tools as the report. One blank line is two paragraphs, and editing
/// the first leaves the second. The neighbour at y=656.8 is one blank line below (two 14.4 pt
/// leadings under the second row). Growing the first paragraph, saving, reopening and editing
/// again leaves that neighbour in place. An explicit TL of 27.6 pt stays one paragraph.
#[test]
fn a_blank_line_stays_a_separate_paragraph_through_tools() {
let dir = workdir("blank-line");
@@ -9478,12 +9479,23 @@ fn a_blank_line_stays_a_separate_paragraph_through_tools() {
assert!(second.contains("Second paragraph"), "{second}");
let grown = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \
BT /F1 12 Tf 1 0 0 1 72 649.6 Tm (Neighbour) Tj ET";
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbour) Tj ET";
std::fs::write(dir.join("grown.pdf"), helvetica_page(grown)).unwrap();
let grown_doc = ok(&mut a, "doc_open", json!({ "path": "grown.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 }))["count"], 2);
ok(&mut a, "text_edit", json!({ "doc": grown_doc, "page": 1, "paragraph": 1, "text": "One\nTwo\nThree" }));
let after = ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 }));
ok(&mut a, "doc_save", json!({ "doc": grown_doc, "path": "grown-out.pdf" }));
let saved = ok(&mut a, "doc_open", json!({ "path": "grown-out.pdf" }))["doc"].as_u64().unwrap();
let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }));
assert_eq!(after["count"], 2, "{after}");
assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}");
ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "text": "One\nTwo\nFour" }));
let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }));
assert_eq!(after["count"], 2, "{after}");
assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}");
let spaced = "BT /F1 12 Tf 27.6 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET";
std::fs::write(dir.join("spaced.pdf"), helvetica_page(spaced)).unwrap();
let spaced_doc = ok(&mut a, "doc_open", json!({ "path": "spaced.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1);
}
+81
View File
@@ -2802,7 +2802,17 @@ fn a_blank_line_keeps_the_next_paragraph() {
text::replace_block(&mut doc, 0, 0, "First paragraph, edited row.\nFirst paragraph, second row.").unwrap();
let after = text::text_blocks(&doc, 0).unwrap();
assert_eq!(after.len(), 2, "{:?}", after.iter().map(|b| b.text.as_str()).collect::<Vec<_>>());
assert!(after[0].text.contains("edited row") && after[0].text.contains("second row"), "{:?}", after[0].text);
assert!(after[1].text.contains("Second paragraph"), "{:?}", after[1].text);
// The second row is left in the source stream. A marker around only the rewritten row
// would split the paragraph; either there is no marker, or it covers both rows and not the next paragraph.
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
if let Some(start) = content.find("/P BMC") {
let end = start + content[start..].find("EMC").unwrap_or(content.len() - start);
let marked = &content[start..end];
assert!(marked.contains("edited row") && marked.contains("second row"), "marker split the paragraph: {content}");
assert!(!marked.contains("Second paragraph"), "marker swallowed the next paragraph: {content}");
}
}
/// #2517: adding a row must not pull in a paragraph that was 3 × the font size below.
@@ -2818,6 +2828,77 @@ fn a_grown_paragraph_does_not_swallow_the_next_one() {
assert!(after[1].text.contains("Neighbour"), "{:?}", after[1].text);
}
/// An explicit `TL` is the leading `T*` moves by. Rows at that gap are one paragraph, including
/// when the gap is larger than twice the font size. A following paragraph that starts two
/// leadings lower stays separate. The numbers are the gaps under test, not a claim about how a
/// word processor derives them: this repo's Helvetica box is 0.925 em (ascent 0.718, descent
/// 0.207), so twice that box at 12 pt is 22.2 pt, while 27.6 pt is 12 × 2.3.
#[test]
fn explicit_leading_is_the_measured_baseline_gap() {
for tl in [14.4_f64, 18.0, 22.2, 24.0, 27.6, 28.8] {
let src = format!("BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET");
let doc = text_page(&src);
let lines = text::text_lines(&doc, 0).unwrap();
let gaps: Vec<f64> = lines.windows(2).map(|w| w[0].origin_baseline() - w[1].origin_baseline()).collect();
assert!(gaps.iter().all(|gap| (gap - tl).abs() < 0.05), "TL {tl} did not move the baselines: {gaps:?}");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1, "TL {tl}, measured gaps {gaps:?}: {:?}", blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>());
}
// Two rows at the TL, then a paragraph whose first baseline is two TLs below the second row.
let tl = 14.4_f64;
let next = 700.0 - tl - 2.0 * tl;
let blank = format!(
"BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \
BT /F1 12 Tf {tl} TL 1 0 0 1 72 {next} Tm (Neighbour) Tj ET"
);
let doc = text_page(&blank);
let lines = text::text_lines(&doc, 0).unwrap();
let between = lines[1].origin_baseline() - lines[2].origin_baseline();
assert!((between - 2.0 * tl).abs() < 0.05, "the blank line measured {between}, not {}", 2.0 * tl);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 2, "{:?}", blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>());
}
/// The neighbour starts one blank line below (two leadings under the second row). Adding a row
/// puts the new baseline one leading above that neighbour, inside the join window. The neighbour
/// still has to survive the edit, both saves, and a second edit.
#[test]
fn growing_into_a_blank_line_keeps_the_neighbour_after_save() {
let tl = 14.4_f64;
let src = format!(
"BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \
BT /F1 12 Tf {tl} TL 1 0 0 1 72 {} Tm (Neighbour) Tj ET",
700.0 - 3.0 * tl
);
let mut doc = text_page(&src);
let before = text::text_lines(&doc, 0).unwrap();
let opening = before[1].origin_baseline() - before[2].origin_baseline();
assert!((opening - 2.0 * tl).abs() < 0.05, "fixture gap {opening} is not a blank line");
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
text::replace_block(&mut doc, 0, 0, "One\nTwo\nThree").unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
let bases: Vec<f64> = lines.iter().map(|l| l.origin_baseline()).collect();
assert_eq!(bases.len(), 4, "{bases:?}");
let grown = bases[2] - bases[3];
assert!((grown - tl).abs() < 0.05, "the new row is {grown} pt above the neighbour, not one leading");
assert!(grown < tl * 1.5, "this gap is outside the join window, so the test would pass without the marker");
let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::<Vec<_>>();
assert_eq!(texts(&doc), ["One Two Three", "Neighbour"]);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
let marked = content.find("/P BMC").and_then(|start| content[start..].find("EMC").map(|end| content[start..start + end].to_owned()));
let marked = marked.unwrap_or_else(|| panic!("a full rewrite did not mark its rows: {content}"));
for row in ["(One)", "(Two)", "(Three)"] {
assert!(marked.contains(row), "{row} is outside the marker: {content}");
}
assert!(!marked.contains("Neighbour"), "the neighbour was pulled into the marker: {content}");
for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] {
assert_eq!(texts(&saved), ["One Two Three", "Neighbour"]);
let mut again = saved;
text::replace_block(&mut again, 0, 0, "One\nTwo\nFour").unwrap();
assert_eq!(texts(&reopen(&again)), ["One Two Four", "Neighbour"]);
}
}
#[test]
fn paragraphs_are_found_and_rewrapped() {
let mut doc = text_page(
+8 -8
View File
@@ -253,11 +253,12 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|| ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol
|| (pr[2] - frame[2]).abs() < tol
|| hanging;
// Once a paragraph has two rows, its own leading is known. A blank line is about
// twice that, so a wider gap is the next paragraph (#2517). A single row has no
// leading yet: ordinary leading (about 1.2 × the size) and double spacing (2 × the
// size) still join, and a blank line (2.4 × the size) does not. The old cap of
// 2.5 × the size joined the blank line too.
// Once a paragraph has two rows, its own leading is the measured baseline gap. A
// blank line is about twice that, so a wider gap is the next paragraph (#2517). A
// single row uses the text object's TL when it has one: a gap equal to that TL
// joins, and a gap of twice the TL does not. Without a TL, ordinary leading still
// joins and a gap of 2.4 × the size does not. The old cap of 2.5 × the size joined
// the blank line too.
let own_leading = block.rows.len().checked_sub(2).and_then(|i| {
let above = block.rows.get(i)?.first().and_then(|n| lines.get(*n));
let below = prev_row.first().and_then(|n| lines.get(*n));
@@ -266,9 +267,8 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
_ => None,
}
});
// 2.05 keeps a double-spaced row (gap == 2 × size) in the paragraph and still
// rejects a blank line, which is 2.4 × the size.
let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).map_or(tol * 2.05, |lead| lead * 1.5);
let stated = (prev.origin.state.leading > 0.0).then(|| prev.wrap_leading()).filter(|lead| lead.is_finite() && *lead > tol * 0.4);
let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).map_or(tol * 2.05, |lead| lead * 1.5);
// OCR lines stay apart: the scan fixes where each one is, so they never rewrap together.
(!mixed || !(invisible(prev) || invisible(first)))
&& (mixed || same_style(prev, first))
+11
View File
@@ -796,6 +796,14 @@ pub(crate) fn rewrite_styled_block(
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
let (tx, ty) = back.apply(dx, dy);
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)]));
// A new row can land one leading above the next paragraph and look like the same
// paragraph. When every row is in this rewrite, a paragraph marker keeps them in one
// scope so the neighbour stays out (#2517). A row left in the source stream cannot share
// that marker, so it is omitted and geometry groups those rows as before.
let marked = kept.iter().all(|row| !*row);
if marked {
block_ops.push(Op::new("BMC", vec![Object::name("P")]));
}
let crop = p.crop(doc);
let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size);
let source_leading = block
@@ -966,6 +974,9 @@ pub(crate) fn rewrite_styled_block(
style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size)
};
}
if marked {
block_ops.push(Op::new("EMC", vec![]));
}
block_ops.push(Op::new("Q", vec![]));
block_ops.extend(after.ops());
if split {
+5 -2
View File
@@ -101,8 +101,11 @@ update the numbers.
Legacy non-SFNT previews, mixed-font/style contextual runs, structure-tree fidelity and reliable
semantic grouping on every untagged PDF remain open. Replacing one line or paragraph no longer
removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same
baseline is still replaced. A blank line at ordinary leading stays a paragraph break, so editing
the first paragraph leaves the next one in place (#2517). Estimates are unchanged.
baseline is still replaced. A blank line stays a paragraph break. An explicit `TL` is the
measured baseline gap, and growing a paragraph so its new row is one leading above the next
one does not absorb that paragraph (#2517). A heading and smaller body lines (#1756), a
hanging-indent continuation (#2564), and cells in different columns (#1680, #2478) are still
grouped by the same geometry. Estimates are unchanged.
- **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the
needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping
+1 -1
View File
@@ -1915,7 +1915,7 @@ status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_paragraphs"]
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"]
notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517); hyphenated line ends are joined; a row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it"
notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading: the measured baseline gap equals it, a gap of that size stays one paragraph even above twice the font size, and a gap of twice the TL is the next paragraph. A rewrite of every row is marked, so a new row one leading above the next paragraph does not absorb it on a later edit. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478)"
[[feature]]
id = "edit.font-reuse-substitution"