From e228e0edfc0d14c8f9bf60fbe94d001ac41c6c56 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:58:15 +0200 Subject: [PATCH] M7: keep a blank line as a paragraph break (#2517) A gap of 2.4 times the font size was still under the 2.5 times join limit, so two paragraphs became one and editing the first deleted the second. Once a paragraph has two rows, a wider gap than its own leading stays the next paragraph. --- ROADMAP.md | 1 + crates/automation/tests/it/automation.rs | 30 ++++++++++++++++++++++++ crates/edit/src/tests.rs | 28 ++++++++++++++++++++++ crates/edit/src/text_regions/grouping.rs | 18 +++++++++++++- docs/gaps.md | 5 +++- docs/roadmap.md | 3 ++- parity/acrobat-features.toml | 4 ++-- 7 files changed, 84 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e1..19bad63491 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -223,6 +223,7 @@ 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. diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 01d0d1eb24..202f204628 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9457,3 +9457,33 @@ fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() { let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone(); 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. +#[test] +fn a_blank_line_stays_a_separate_paragraph_through_tools() { + let dir = workdir("blank-line"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET"; + std::fs::write(dir.join("a.pdf"), helvetica_page(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 })); + assert_eq!(before["count"], 2, "{before}"); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "First paragraph, edited row.\nFirst paragraph, second row." })); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + let second = after["paragraphs"][1]["text"].as_str().unwrap(); + 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"; + 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 })); + assert_eq!(after["count"], 2, "{after}"); + assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}"); +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 359155015f..bb752149d4 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2790,6 +2790,34 @@ fn a_bold_stand_in_font_leaves_an_earlier_regular_one_alone() { assert_eq!(font_of("TENSÃO").as_deref(), Some("Times-Bold"), "{lines:?}"); } +/// #2517: one blank line at ordinary leading (2 × 1.2 × the font size) is a paragraph break. +/// Editing the first paragraph must leave the second one in place. +#[test] +fn a_blank_line_keeps_the_next_paragraph() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET"; + let mut doc = text_page(src); + let before = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(before.len(), 2, "{:?}", before.iter().map(|b| b.text.as_str()).collect::>()); + 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::>()); + assert!(after[1].text.contains("Second paragraph"), "{:?}", after[1].text); +} + +/// #2517: adding a row must not pull in a paragraph that was 3 × the font size below. +#[test] +fn a_grown_paragraph_does_not_swallow_the_next_one() { + let src = "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"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One\nTwo\nThree").unwrap(); + let after = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "{:?}", after.iter().map(|b| b.text.as_str()).collect::>()); + assert!(after[1].text.contains("Neighbour"), "{:?}", after[1].text); +} + #[test] fn paragraphs_are_found_and_rewrapped() { let mut doc = text_page( diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index b8a5abca1e..9d3e5eaa9d 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -253,6 +253,22 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { || ((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. + 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)); + match (above, below) { + (Some(above), Some(below)) => Some((above.frame_origin().1 - below.frame_origin().1).abs()), + _ => 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); // 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)) @@ -262,7 +278,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { && aligned && first.size.min(prev.size) >= first.size.max(prev.size) * 0.4 && gap > first.size.min(prev.size) * 0.8 - && gap < first.size.max(prev.size) * 2.5 + && gap < max_gap }); if !joins { blocks.push(TextBlock { diff --git a/docs/gaps.md b/docs/gaps.md index 56ec3ae748..e74daadbee 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,7 @@ # Where PdfCraft falls short of Acrobat Pro > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 3: #2517 a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -100,7 +101,8 @@ 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. Estimates are unchanged. + 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. - **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 @@ -404,6 +406,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. | +| 2026-10-15 | minor | Gap 3: #2517 a blank line at ordinary leading stays a paragraph break. Estimates unchanged. | | 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. | | 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h | | 2026-10-20 | minor | Gap 6 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; negative and indirect-reference regressions and a read-only automation test passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate. Scores and estimates unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 25205e92c1..d90c65a67c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,7 +9,7 @@ > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7 #2517: a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -265,6 +265,7 @@ Estimates unchanged. | 2026-10-21 | minor | M3: on GNOME/Wayland, a second resize by an edge or move by the tab strip works without a click in between (the compositor kept the button's release from egui). Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | +| 2026-10-15 | minor | M7 #2517: a blank line between paragraphs stays a paragraph break, and growing the first paragraph does not absorb the next one. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. | | 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..3bed5a6db3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1914,8 +1914,8 @@ milestone = "M7" 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::text_lines_are_found_and_replaced_in_place", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "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; 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" +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" [[feature]] id = "edit.font-reuse-substitution"