M7: check a shared text object through the tools (#2517)

Editing either end of one text object, then saving and editing again, has to leave the other paragraph in place. The library tests did not drive text_edit and doc_save.
This commit is contained in:
ihsan mokhlisse
2026-10-29 10:11:19 +01:00
parent 59513aa887
commit b282ec26be
3 changed files with 72 additions and 1 deletions
+40
View File
@@ -9612,3 +9612,43 @@ fn a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools() {
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." }));
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two.", "Neighbor stays."]);
}
/// Both ends of one text object, through open / text_edit / save / reopen / text_extract.
/// Editing the last paragraph must not take the one before it, and editing the first must
/// not take the one after it. Incremental and full saves.
#[test]
fn a_shared_text_object_keeps_both_paragraphs_through_tools() {
let dir = workdir("shared-text-object");
let mut a = auto(&dir);
let body =
"BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET";
std::fs::write(dir.join("last.pdf"), helvetica_page(body)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."]);
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nChanged." }));
assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]);
let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "last.pdf" }));
assert_eq!(incremental["incremental"], true, "{incremental}");
let reopened = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]);
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nEdited." }));
let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "last-full.pdf", "full": true }));
assert_eq!(full["incremental"], false, "{full}");
let again = ok(&mut a, "doc_open", json!({ "path": "last-full.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]);
let extracted = page_text(&mut a, again);
assert!(extracted[0].contains("Alpha first row.") && extracted[0].contains("Edited."), "{extracted:?}");
std::fs::write(dir.join("first.pdf"), helvetica_page(body)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "first.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays. Neighbor next."]);
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" }));
ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "first-full.pdf", "full": true }));
let again = ok(&mut a, "doc_open", json!({ "path": "first-full.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays. Neighbor next."]);
let extracted = page_text(&mut a, again);
assert!(extracted[0].contains("Four") && extracted[0].contains("Neighbor stays."), "{extracted:?}");
}
+31
View File
@@ -3007,6 +3007,37 @@ fn a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_befo
}
}
/// Three paragraphs share one text object. Rewriting the middle one must leave the paragraph
/// before it and the paragraph after it outside the marker.
#[test]
fn a_partial_rewrite_of_the_middle_paragraph_in_one_text_object_keeps_both_neighbours() {
let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj \
T* T* (Mid stays.) Tj T* (Mid next.) Tj T* T* (End stays.) Tj T* (End next.) Tj ET";
let mut doc = text_page(src);
let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::<Vec<_>>();
assert_eq!(
texts(&doc),
["Alpha first row. Alpha next row.", "Mid stays. Mid next.", "End stays. End next."],
"each blank line is a paragraph break"
);
text::replace_block(&mut doc, 0, 1, "Mid stays.\nChanged.").unwrap();
let content = page_content(&doc);
assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Mid stays. Changed.", "End stays. End next."], "{content}");
let alpha = content.find("(Alpha first row.)").expect("the preceding paragraph");
let open = content.find("/P BMC").expect("marker");
let end = content.find("(End stays.)").expect("the following paragraph");
assert!(alpha < open, "the marker swallowed the preceding paragraph: {content}");
assert!(open < end, "the following paragraph is inside the marker: {content}");
let close = content[open..].find("EMC").map(|at| open + at).expect("close");
assert!(close < end, "the marker closed after the following paragraph: {content}");
for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] {
assert_eq!(texts(&saved), ["Alpha first row. Alpha next row.", "Mid stays. Changed.", "End stays. End next."]);
let mut again = saved;
text::replace_block(&mut again, 0, 1, "Mid stays.\nEdited.").unwrap();
assert_eq!(texts(&reopen(&again)), ["Alpha first row. Alpha next row.", "Mid stays. Edited.", "End stays. End next."]);
}
}
/// #2516's published steps: adding a row must not make the next edit-by-number rewrite Charlie.
/// The issue also asks for a stable paragraph id. That is #2508, and this test does not add one.
#[test]
+1 -1
View File
@@ -1914,7 +1914,7 @@ 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::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "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/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_text_object_keeps_the_neighbour", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_before_it", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "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"]
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "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/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_text_object_keeps_the_neighbour", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_before_it", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_middle_paragraph_in_one_text_object_keeps_both_neighbours", "crates/automation/tests/it/automation.rs::a_shared_text_object_keeps_both_paragraphs_through_tools", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "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). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. When the next paragraph shares that text object, the marker opens after BT and closes before the neighbour's text. When the rewritten paragraph is the last one in that text object, the marker opens after the preceding paragraph and closes before the shared ET. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. 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). Two rows and no third step still cannot be told from a paragraph break when there is no TL."
[[feature]]