feat(mcp): add text_search and text_replace with multiline MText and accent-insensitive matching

- Add text_search (read) and text_replace (mutating) operations to control bridge, automation, and MCP
- Support single and batch find/replace pairs under atomic undo transactions
- Safely parse and preserve MText formatting tags, inline fonts, colors, and margins
- Add flexible multiline MText matching and replacement across paragraph breaks (\n <-> \P)
- Add accent/diacritic-insensitive matching across Unicode and ASCII character sets
- Support Windows-1252 / ISO-8859-1 code decoding (%%nnn), DXF Unicode escapes (\U+XXXX), and special character codes
- Expose ignore_accents parameter across ocs_read and ocs_execute MCP tool schemas
This commit is contained in:
Karim Jerbi committed 2026-09-29 12:42:00 +01:00
1 parent ea91315ba8
commit 88169c1d7a
6 files changed
+1394 -2

No files matched your search

+4
View File
@@ -471,6 +471,7 @@ impl OpenCADStudio {
| "xdata_get"
| "get_selection"
| "audit"
| "text_search"
);
if !query && !self.control.enabled {
return (
@@ -729,6 +730,7 @@ impl OpenCADStudio {
}
"xdata_get" => self.xdata_read(&req),
"get_selection" => self.control_get_selection(),
"text_search" => self.control_text_search(&req).unwrap_or_else(|e| e),
"history" => {
json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::<Vec<_>>()})
}
@@ -975,6 +977,7 @@ impl OpenCADStudio {
"entities_delete" => self.control_entities_delete(req)?,
"entities_transform" => self.control_entities_transform(req)?,
"entities_copy_to" => self.control_entities_copy_to(req)?,
"text_replace" => self.control_text_replace(req)?,
"xdata_set" => self.control_xdata_set(req)?,
"block_define" => self.control_block_define(req)?,
"block_delete" => self.control_block_delete(req)?,
@@ -1340,6 +1343,7 @@ pub(crate) mod http_bridge;
mod interactive;
mod sheets;
pub(crate) use sheets::new_guid_v4;
pub(crate) mod text_ops;
#[cfg(test)]
mod p1_tests;
+89
View File
@@ -566,3 +566,92 @@ fn state_reports_the_hand_seed() {
let after = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap();
assert!(after > before, "issuing a handle advances the seed");
}
#[test]
fn text_search_op_queries_text_and_attributes() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"ts_c1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"},
{"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;POMPE A EAU}","layer":"0"},
{"type":"Text","position":[20,0,0],"value":"BAROMETRE","layer":"0"},
{"type":"Text","position":[30,0,0],"value":"10 BAR","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Case-insensitive search finds CIRTUITS
let res = app.automation_op(r#"{"op":"text_search","find":"cirtuits"}"#);
assert_eq!(res["ok"], true);
assert_eq!(res["count"], 1);
assert_eq!(res["matches"][0]["match_text"], "cirtuits");
assert_eq!(res["matches"][0]["plain_text"], "CIRTUITS 28-09-2026");
// Case-sensitive search with wrong case finds nothing
let res_cs = app.automation_op(r#"{"op":"text_search","find":"cirtuits","match_case":true}"#);
assert_eq!(res_cs["ok"], true);
assert_eq!(res_cs["count"], 0);
// Whole-word search: "bar" matches "10 BAR" but not "BAROMETRE"
let res_ww = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":true}"#);
assert_eq!(res_ww["ok"], true);
assert_eq!(res_ww["count"], 1);
assert_eq!(res_ww["matches"][0]["plain_text"], "10 BAR");
// Non-whole-word search finds both "BAROMETRE" and "10 BAR"
let res_all = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":false}"#);
assert_eq!(res_all["ok"], true);
assert_eq!(res_all["count"], 2);
}
#[test]
fn text_replace_op_batch_replaces_preserves_formatting_and_undoes() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"tr_c1","document_id":{doc},"entities":[
{"type":"Text","position":[0,0,0],"value":"VANNE A ARRET","layer":"0"},
{"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic|b0|i0;V15-CUVE EAU NON TRAITE}","layer":"0"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
// Execute batch replace with 2 pairs in one atomic step
let rep = mutate(
&mut app,
r#"{"protocol":1,"op":"text_replace","request_id":"tr_r1","document_id":{doc},"pairs":[
{"find":"NON TRAITE","replace":"NON TRAITÉE"},
{"find":"VANNE A ARRET","replace":"VANNE D'ARRÊT"}
]}"#,
);
assert_eq!(rep["ok"], true, "{}", rep["error"]);
assert_eq!(rep["result"]["replaced"], 2);
assert_eq!(rep["result"]["entities_changed"], 2);
// Verify MText formatting codes were preserved
let q = app.automation_op(r#"{"op":"text_search","find":"NON TRAITÉE"}"#);
assert_eq!(q["ok"], true);
assert_eq!(q["count"], 1);
let raw = q["matches"][0]["raw_value"].as_str().unwrap();
assert!(raw.contains("Century Gothic"), "raw MText should keep font format code: {raw}");
assert!(raw.contains("NON TRAITÉE"), "raw MText should contain replaced text: {raw}");
// Verify Text was replaced
let q2 = app.automation_op(r#"{"op":"text_search","find":"VANNE D'ARRÊT"}"#);
assert_eq!(q2["ok"], true);
assert_eq!(q2["count"], 1);
// Undo restores the original text in one step
app.automation_op(r#"{"op":"undo"}"#);
let q_orig = app.automation_op(r#"{"op":"text_search","find":"NON TRAITE"}"#);
assert_eq!(q_orig["ok"], true);
assert_eq!(q_orig["count"], 1);
let q_orig2 = app.automation_op(r#"{"op":"text_search","find":"VANNE A ARRET"}"#);
assert_eq!(q_orig2["ok"], true);
assert_eq!(q_orig2["count"], 1);
}
File diff suppressed because it is too large. Load diff