diff --git a/ROADMAP.md b/ROADMAP.md index 90fe90f4e..fadacc4c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,7 +171,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log -- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit` and `text_edit_layout` find it again by its text and box, and refuse one that was merged into a neighbour instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508). +- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508). - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 2fc017088..e33b3242e 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -684,10 +684,12 @@ impl Automation { let wanted: String = text.split_whitespace().collect(); let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; let after = self.doc(&a)?.text_blocks(page); - // Only a paragraph that now shows the requested text is the edited one. A neighbour - // that overlaps the old box (overlaid text, a text layer) must not take its number. + // Only a paragraph that now shows exactly the requested text is the edited one. A + // neighbour that overlaps the old box (overlaid text, a text layer) must not take + // its number, and neither must a paragraph the edit merged it into: rewriting that + // one by this number would drop the neighbour's text. let edited = block_overlapping(&after, moved) - .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::().contains(&wanted))); + .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); if let Some(b) = edited.and_then(|i| after.get(i)) { out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font }); } @@ -1897,11 +1899,13 @@ impl Automation { return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); } let out = if let Some(block) = block { - let index = usize::try_from(block) - .ok() - .and_then(|n| n.checked_sub(1)) - .ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?; - self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + if block < 1 { + return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into())); + } + // The number names the paragraph text_paragraphs listed, as it does for text_edit. + let doc = self.doc(a)?; + let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?; + doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? } else if let Some(added) = added { let index = usize::try_from(added) .ok() diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 276b61cd3..aca072f45 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -161,7 +161,7 @@ pub fn tools() -> Vec { t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 }, - "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." }, + "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index de470b968..ef16e77d4 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -6923,7 +6923,7 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), ); - assert_eq!(first["stale_paragraphs"], json!([2]), "the third row regroups Alpha with Bravo: {first}"); + assert_eq!(first["stale_paragraphs"], json!([1, 2]), "the third row regroups Alpha with Bravo: {first}"); // Paragraph 2 of the earlier listing was Bravo, which is now part of Alpha's paragraph. let stale = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); @@ -6942,6 +6942,55 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { std::fs::remove_dir_all(dir).unwrap(); } +/// An edit that merges its paragraph into a neighbour leaves no paragraph of its own to follow: +/// the merged one holds the edited text too, but rewriting it by the edited paragraph's number +/// would drop the neighbour's text (#2516). +#[test] +fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() { + let dir = workdir("text-edit-merged-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + let first = ok( + &mut a, + "text_edit", + json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), + ); + assert!(first.get("paragraph").is_none(), "the result must not present the merged paragraph as the edited one: {first}"); + + // Paragraph 1 was Alpha. Alpha now shares a paragraph with Bravo, so the number is refused. + let again = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha only."})); + assert!(matches!(&again, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{again:?}"); + let text = page_text(&mut a, doc).join(" "); + assert!(text.contains("Bravo paragraph, a single row."), "Bravo is untouched: {text}"); + assert!(!text.contains("Alpha only."), "nothing was rewritten: {text}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The editor background preview takes the same paragraph number as `text_edit`, so it hides the +/// paragraph the number named in the listing and refuses a number an edit made stale (#2516). +#[test] +fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrouped() { + let dir = workdir("hide-text-block-stale-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + ok( + &mut a, + "text_edit", + json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), + ); + + // Paragraph 2 of the listing was Bravo, now inside Alpha's paragraph; position 2 is Charlie. + let stale = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2})); + assert!(matches!(&stale, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{:?}", stale.map(|_| ())); + // Paragraph 3 of the listing is Charlie, wherever it is now. + assert!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":3})).is_ok()); + std::fs::remove_dir_all(dir).unwrap(); +} + /// A number from the listing keeps naming its paragraph while other paragraphs are edited, and /// a paragraph can be edited twice in a row by the same number. #[test] diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index af213a6e5..b1f810b71 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -99,9 +99,10 @@ is included in the repository. A paragraph's number is its position in a list that is regrouped from geometry on every read, so an edit that merges two paragraphs, or splits one, moves the others. After `text_paragraphs` -the numbers name the paragraphs *that listing showed*: `text_edit` and `text_edit_layout` find -the paragraph again by its text and box, so editing paragraph 3 then paragraph 1 still edits the -two the listing showed. A paragraph an edit merged into a neighbour (or changed) is refused with +the numbers name the paragraphs *that listing showed*: `text_edit`, `text_edit_layout` and +`page_render`'s `hide_text_block` find the paragraph again by its text and box, so editing paragraph 3 then paragraph 1 still edits the +two the listing showed. A paragraph an edit merged into a neighbour (or changed) is refused, and so is the edited paragraph +itself when the edit merged it into one, with a message to list the page again, instead of rewriting whichever paragraph took its number (#2516). The result of an edit names the numbers that are gone in `stale_paragraphs`. Listing a page again renumbers it. Without a listing, a number is a position in the page's current