fix(automation): hide_text_block resolves its number like text_edit, and a merged paragraph is not followed (#2516)

page_render's hide_text_block used the paragraph number as a position in the page's current
paragraphs, so after an edit regrouped the page it hid a different paragraph from the one
text_edit would rewrite. It now resolves the number against the text_paragraphs listing and is
refused when the paragraph is gone.

text_edit also accepted any overlapping paragraph that contained the requested text as the
edited one. When the edit merged the paragraph into a neighbour, the merged paragraph contains
that text too, so the number followed it and a second edit by that number dropped the
neighbour's text. The edited paragraph must now show exactly the requested text; a merged one
leaves the number stale.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
hcchung1
2026-10-20 11:57:48 +08:00
parent 570e6c0564
commit acacf9485f
5 changed files with 68 additions and 14 deletions
+1 -1
View File
@@ -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.
+12 -8
View File
@@ -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::<String>().contains(&wanted)));
.filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::<String>() == 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()
+1 -1
View File
@@ -161,7 +161,7 @@ pub fn tools() -> Vec<ToolDef> {
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.")
+50 -1
View File
@@ -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]
+4 -3
View File
@@ -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