forked from 1b-agent/opencadstudio-upstream
Add cross-platform SystemSpeller abstraction with three backends: - Windows: direct COM vtable calls to ISpellChecker (Win32 Spell API), no external crate — uses the same hand-rolled FFI pattern as file_association.rs. Handles ALL-CAPS words by also testing lowercase. - macOS: NSSpellChecker via objc2/objc2-app-kit (already in deps). - Linux: Hunspell .dic file reader + hunspell CLI pipe fallback. text_audit gains three new parameters: system_spellcheck: true — activate OS spell-checker language: en-US — BCP-47 language tag (default: OS default) suggest: true — include spelling suggestions The agent dictionary and system speller work as a layered pipeline: words matching the agent dictionary are accepted immediately; only remaining words are forwarded to the OS checker. Response includes a system_speller object (enabled, available, backend, language) and an unrecognized_words array with handle, position, and optional suggestions. Integration test: text_audit_system_spellcheck_flags_misspelled_words (20/20 p1_tests pass). Release build verified.
1719 lines
65 KiB
Rust
1719 lines
65 KiB
Rust
//! High-performance text search and replacement operations for OpenCADStudio.
|
||
//!
|
||
//! Provides headless, control-bridge, and MCP-accessible text querying and batch
|
||
//! replacement across Text, MText, Dimension, AttributeDefinition, and Insert entities.
|
||
//! Safely preserves MText inline formatting codes (fonts, colors, heights, braces)
|
||
//! and handles DXF Unicode escapes (`\U+XXXX`) and special character codes (`%%d`, `%%p`, etc.).
|
||
|
||
use super::{failure, OpenCADStudio};
|
||
use crate::app::Message;
|
||
use codec::{CadDocument, EntityType, Handle};
|
||
use iced::Task;
|
||
use serde_json::{json, Value};
|
||
|
||
/// Decodes DXF special character codes (`%%d`, `%%p`, `%%c`, `%%nnn`) and
|
||
/// DXF Unicode escape sequences (`\U+XXXX` / `\u+XXXX`) into UTF-8 characters.
|
||
pub fn decode_dxf_escapes(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
let mut chars = s.chars().peekable();
|
||
|
||
while let Some(c) = chars.next() {
|
||
if c == '%' && chars.peek() == Some(&'%') {
|
||
chars.next(); // consume second '%'
|
||
match chars.peek().map(|ch| ch.to_ascii_lowercase()) {
|
||
Some('d') => {
|
||
chars.next();
|
||
out.push('°');
|
||
}
|
||
Some('p') => {
|
||
chars.next();
|
||
out.push('±');
|
||
}
|
||
Some('c') => {
|
||
chars.next();
|
||
out.push('∅');
|
||
}
|
||
Some('u') | Some('o') => {
|
||
chars.next(); // toggle codes — strip silently
|
||
}
|
||
Some('%') => {
|
||
chars.next();
|
||
if chars.peek() == Some(&'%') {
|
||
chars.next();
|
||
}
|
||
out.push('%');
|
||
}
|
||
Some(d) if d.is_ascii_digit() => {
|
||
let mut digits = String::with_capacity(3);
|
||
for _ in 0..3 {
|
||
match chars.peek() {
|
||
Some(&ch) if ch.is_ascii_digit() => {
|
||
digits.push(chars.next().unwrap());
|
||
}
|
||
_ => break,
|
||
}
|
||
}
|
||
if digits.len() == 3 {
|
||
if let Ok(n) = digits.parse::<u32>() {
|
||
let ch_opt = match n {
|
||
128 => Some('€'),
|
||
130 => Some('‚'),
|
||
131 => Some('ƒ'),
|
||
132 => Some('„'),
|
||
133 => Some('…'),
|
||
134 => Some('†'),
|
||
135 => Some('‡'),
|
||
136 => Some('ˆ'),
|
||
137 => Some('‰'),
|
||
138 => Some('Š'),
|
||
139 => Some('‹'),
|
||
140 => Some('Œ'),
|
||
142 => Some('Ž'),
|
||
145 => Some('‘'),
|
||
146 => Some('’'),
|
||
147 => Some('“'),
|
||
148 => Some('”'),
|
||
149 => Some('•'),
|
||
150 => Some('–'),
|
||
151 => Some('—'),
|
||
152 => Some('˜'),
|
||
153 => Some('™'),
|
||
154 => Some('š'),
|
||
155 => Some('›'),
|
||
156 => Some('œ'),
|
||
158 => Some('ž'),
|
||
159 => Some('Ÿ'),
|
||
160..=255 => char::from_u32(n),
|
||
_ => char::from_u32(n),
|
||
};
|
||
if let Some(ch) = ch_opt {
|
||
out.push(ch);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
out.push('%');
|
||
out.push('%');
|
||
out.push_str(&digits);
|
||
}
|
||
_ => {
|
||
out.push('%');
|
||
out.push('%');
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// DXF Unicode escape: \U+XXXX or \u+XXXX
|
||
if c == '\\' && (chars.peek() == Some(&'U') || chars.peek() == Some(&'u')) {
|
||
let mut clone = chars.clone();
|
||
clone.next(); // consume U/u
|
||
if clone.peek() == Some(&'+') {
|
||
clone.next(); // consume '+'
|
||
let mut hex = String::with_capacity(4);
|
||
for _ in 0..4 {
|
||
match clone.peek() {
|
||
Some(&ch) if ch.is_ascii_hexdigit() => {
|
||
hex.push(clone.next().unwrap());
|
||
}
|
||
_ => break,
|
||
}
|
||
}
|
||
if hex.len() == 4 {
|
||
if let Ok(codepoint) = u32::from_str_radix(&hex, 16) {
|
||
if let Some(ch) = char::from_u32(codepoint) {
|
||
out.push(ch);
|
||
chars = clone; // advance chars
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
out.push(c);
|
||
}
|
||
|
||
out
|
||
}
|
||
|
||
/// Strips common Latin diacritics/accents into their plain ASCII equivalents (e.g. 'é' -> 'e', 'À' -> 'A').
|
||
pub fn remove_diacritics(s: &str) -> String {
|
||
s.chars()
|
||
.map(|c| match c {
|
||
'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => 'a',
|
||
'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => 'A',
|
||
'è' | 'é' | 'ê' | 'ë' => 'e',
|
||
'È' | 'É' | 'Ê' | 'Ë' => 'E',
|
||
'ì' | 'í' | 'î' | 'ï' => 'i',
|
||
'Ì' | 'Í' | 'Î' | 'Ï' => 'I',
|
||
'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o',
|
||
'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' => 'O',
|
||
'ù' | 'ú' | 'û' | 'ü' => 'u',
|
||
'Ù' | 'Ú' | 'Û' | 'Ü' => 'U',
|
||
'ý' | 'ÿ' => 'y',
|
||
'Ý' => 'Y',
|
||
'ç' => 'c',
|
||
'Ç' => 'C',
|
||
'ñ' => 'n',
|
||
'Ñ' => 'N',
|
||
other => other,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Converts LF / CRLF newlines into DXF MText paragraph breaks (`\P`).
|
||
pub fn to_raw_mtext_replacement(s: &str) -> String {
|
||
s.replace("\r\n", "\\P").replace('\n', "\\P")
|
||
}
|
||
|
||
/// Converts raw DXF MText paragraph breaks (`\P` / `\p`) into plain text newlines (`\n`).
|
||
pub fn to_plain_text_search(s: &str) -> String {
|
||
s.replace("\\P", "\n").replace("\\p", "\n")
|
||
}
|
||
|
||
/// Converts newlines (`\r\n` / `\n`) into raw DXF MText paragraph breaks (`\P`).
|
||
pub fn to_raw_mtext_search(s: &str) -> String {
|
||
s.replace("\r\n", "\\P").replace('\n', "\\P")
|
||
}
|
||
|
||
/// Pattern matcher supporting case-sensitive/case-insensitive, whole-word,
|
||
/// and accent/diacritic-insensitive matching across Unicode and ASCII strings.
|
||
#[derive(Debug, Clone)]
|
||
pub struct TextMatcher {
|
||
pub search: String,
|
||
pub match_case: bool,
|
||
pub whole_word: bool,
|
||
pub ignore_accents: bool,
|
||
}
|
||
|
||
impl TextMatcher {
|
||
pub fn new(
|
||
search: String,
|
||
match_case: bool,
|
||
whole_word: bool,
|
||
ignore_accents: bool,
|
||
) -> Self {
|
||
Self {
|
||
search,
|
||
match_case,
|
||
whole_word,
|
||
ignore_accents,
|
||
}
|
||
}
|
||
|
||
pub fn with_search(&self, search: String) -> Self {
|
||
Self {
|
||
search,
|
||
match_case: self.match_case,
|
||
whole_word: self.whole_word,
|
||
ignore_accents: self.ignore_accents,
|
||
}
|
||
}
|
||
|
||
pub fn matches(&self, hay: &str) -> bool {
|
||
!self.find_all_in(hay).is_empty()
|
||
}
|
||
|
||
/// Matches against both plain display text and raw CAD string, normalizing
|
||
/// newlines and MText paragraph break escapes (`\n` <-> `\P`).
|
||
pub fn matches_text_or_raw(&self, plain_text: &str, raw_val: &str) -> bool {
|
||
if self.matches(plain_text) || self.matches(raw_val) {
|
||
return true;
|
||
}
|
||
if self.search.contains('\n') || self.search.contains("\r\n") {
|
||
let p_matcher = self.with_search(to_raw_mtext_search(&self.search));
|
||
if p_matcher.matches(raw_val) {
|
||
return true;
|
||
}
|
||
}
|
||
if self.search.contains("\\P") || self.search.contains("\\p") {
|
||
let lf_matcher = self.with_search(to_plain_text_search(&self.search));
|
||
if lf_matcher.matches(plain_text) {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Finds all non-overlapping match slices `(start_byte, end_byte)` in `hay`.
|
||
pub fn find_all_in(&self, hay: &str) -> Vec<(usize, usize)> {
|
||
if self.search.is_empty() || hay.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
// Fast path: exact case-sensitive, accent-sensitive substring search
|
||
if self.match_case && !self.ignore_accents && !self.whole_word {
|
||
let mut matches = Vec::new();
|
||
let mut cursor = 0;
|
||
while cursor < hay.len() {
|
||
if let Some(idx) = hay[cursor..].find(&self.search) {
|
||
let start = cursor + idx;
|
||
let end = start + self.search.len();
|
||
matches.push((start, end));
|
||
cursor = end;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
return matches;
|
||
}
|
||
|
||
// Character-indexed search for case-insensitive, whole-word, and/or accent-insensitive matching
|
||
let hay_chars: Vec<char> = hay.chars().collect();
|
||
let mut byte_offsets = Vec::with_capacity(hay_chars.len() + 1);
|
||
let mut cur_byte = 0;
|
||
for &c in &hay_chars {
|
||
byte_offsets.push(cur_byte);
|
||
cur_byte += c.len_utf8();
|
||
}
|
||
byte_offsets.push(cur_byte);
|
||
|
||
let needle_norm: Vec<char> = {
|
||
let s = if self.ignore_accents {
|
||
remove_diacritics(&self.search)
|
||
} else {
|
||
self.search.clone()
|
||
};
|
||
if self.match_case {
|
||
s.chars().collect()
|
||
} else {
|
||
s.to_lowercase().chars().collect()
|
||
}
|
||
};
|
||
|
||
let hay_norm: Vec<char> = {
|
||
let s = if self.ignore_accents {
|
||
remove_diacritics(hay)
|
||
} else {
|
||
hay.to_string()
|
||
};
|
||
if self.match_case {
|
||
s.chars().collect()
|
||
} else {
|
||
s.to_lowercase().chars().collect()
|
||
}
|
||
};
|
||
|
||
let mut matches = Vec::new();
|
||
let n_len = needle_norm.len();
|
||
if n_len > 0 && n_len <= hay_norm.len() {
|
||
let mut i = 0;
|
||
while i + n_len <= hay_norm.len() {
|
||
if hay_norm[i..i + n_len] == needle_norm[..] {
|
||
let start_byte = byte_offsets[i];
|
||
let end_byte = byte_offsets[i + n_len];
|
||
|
||
let is_word_boundary = if self.whole_word {
|
||
let before_ok = if i == 0 {
|
||
true
|
||
} else {
|
||
!is_word_char(hay_chars[i - 1])
|
||
};
|
||
let after_ok = if i + n_len >= hay_chars.len() {
|
||
true
|
||
} else {
|
||
!is_word_char(hay_chars[i + n_len])
|
||
};
|
||
before_ok && after_ok
|
||
} else {
|
||
true
|
||
};
|
||
|
||
if is_word_boundary {
|
||
matches.push((start_byte, end_byte));
|
||
i += n_len;
|
||
continue;
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
}
|
||
|
||
matches
|
||
}
|
||
|
||
/// Replaces occurrences in `hay` with `replacement`.
|
||
pub fn replace_all_in(
|
||
&self,
|
||
hay: &str,
|
||
replacement: &str,
|
||
replace_all: bool,
|
||
) -> (String, usize) {
|
||
let matches = self.find_all_in(hay);
|
||
if matches.is_empty() {
|
||
return (hay.to_string(), 0);
|
||
}
|
||
|
||
let mut result = String::with_capacity(hay.len());
|
||
let mut cursor = 0;
|
||
let mut count = 0;
|
||
|
||
for (start, end) in matches {
|
||
result.push_str(&hay[cursor..start]);
|
||
result.push_str(replacement);
|
||
cursor = end;
|
||
count += 1;
|
||
if !replace_all {
|
||
break;
|
||
}
|
||
}
|
||
result.push_str(&hay[cursor..]);
|
||
(result, count)
|
||
}
|
||
}
|
||
|
||
fn is_word_char(c: char) -> bool {
|
||
c.is_alphanumeric() || c == '_'
|
||
}
|
||
|
||
fn has_trailing_paragraph_break(raw: &str) -> bool {
|
||
let s = raw.trim_end();
|
||
let s = s.strip_suffix('}').unwrap_or(s);
|
||
match s.strip_suffix('P') {
|
||
Some(rest) => rest.chars().rev().take_while(|&c| c == '\\').count() % 2 == 1,
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
/// Safely replaces text within an MText string by modifying text runs inside
|
||
/// the parsed MText structure, preserving all font tags, colors, and line breaks.
|
||
/// Supports both single-span replacements and multiline replacements across
|
||
/// paragraph breaks (`\P` / `\n`).
|
||
pub fn replace_mtext_safe(
|
||
raw: &str,
|
||
matcher: &TextMatcher,
|
||
replacement: &str,
|
||
replace_all: bool,
|
||
) -> (String, usize) {
|
||
if raw.is_empty() {
|
||
return (raw.to_string(), 0);
|
||
}
|
||
|
||
// Convert replacement newlines to \P for MText compatibility
|
||
let raw_replacement = to_raw_mtext_replacement(replacement);
|
||
|
||
let mut doc = codec::entities::mtext_format::parse_mtext(raw, true);
|
||
let mut total_replaced = 0;
|
||
|
||
// First attempt: replace within individual parsed spans (preserves fine-grained formatting)
|
||
for para in &mut doc.paragraphs {
|
||
for span in &mut para.spans {
|
||
if span.stacking.is_none() {
|
||
let (new_text, count) =
|
||
matcher.replace_all_in(&span.text, &raw_replacement, replace_all);
|
||
if count > 0 {
|
||
span.text = new_text;
|
||
total_replaced += count;
|
||
if !replace_all {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !replace_all && total_replaced > 0 {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if total_replaced > 0 {
|
||
if has_trailing_paragraph_break(raw) {
|
||
doc.paragraphs
|
||
.push(codec::entities::mtext_format::MTextParagraph::new());
|
||
}
|
||
(doc.to_mtext_string(), total_replaced)
|
||
} else {
|
||
// Second attempt: raw string replacement
|
||
// Handles multiline text where the search pattern spans across paragraph breaks (\P / \n)
|
||
replace_mtext_raw_flexible(raw, matcher, &raw_replacement, replace_all)
|
||
}
|
||
}
|
||
|
||
/// Helper for flexible raw MText replacement handling newlines and paragraph breaks.
|
||
fn replace_mtext_raw_flexible(
|
||
raw: &str,
|
||
matcher: &TextMatcher,
|
||
raw_replacement: &str,
|
||
replace_all: bool,
|
||
) -> (String, usize) {
|
||
// 1. Try direct raw replacement with current matcher
|
||
let (direct_res, direct_count) = matcher.replace_all_in(raw, raw_replacement, replace_all);
|
||
if direct_count > 0 {
|
||
return (direct_res, direct_count);
|
||
}
|
||
|
||
// 2. If search pattern contains newlines (\r\n or \n), convert them to \P and try
|
||
if matcher.search.contains('\n') || matcher.search.contains("\r\n") {
|
||
let p_search = to_raw_mtext_search(&matcher.search);
|
||
let p_matcher = matcher.with_search(p_search);
|
||
let (p_res, p_count) = p_matcher.replace_all_in(raw, raw_replacement, replace_all);
|
||
if p_count > 0 {
|
||
return (p_res, p_count);
|
||
}
|
||
|
||
// 3. Multiline paragraph-boundary matching with flexible whitespace:
|
||
// Handles cases where raw has spaces around \P, or paragraph formatting like \pxqc;
|
||
let lines: Vec<&str> = matcher
|
||
.search
|
||
.split('\n')
|
||
.map(|l| l.strip_suffix('\r').unwrap_or(l))
|
||
.collect();
|
||
|
||
if lines.len() >= 2 {
|
||
let (flex_res, flex_count) =
|
||
replace_raw_lines(raw, &lines, matcher, raw_replacement, replace_all);
|
||
if flex_count > 0 {
|
||
return (flex_res, flex_count);
|
||
}
|
||
}
|
||
} else if matcher.search.contains("\\P") || matcher.search.contains("\\p") {
|
||
// Search contained literal \P; try with \n in case raw has raw LF
|
||
let lf_search = to_plain_text_search(&matcher.search);
|
||
let lf_matcher = matcher.with_search(lf_search);
|
||
let (lf_res, lf_count) = lf_matcher.replace_all_in(raw, raw_replacement, replace_all);
|
||
if lf_count > 0 {
|
||
return (lf_res, lf_count);
|
||
}
|
||
}
|
||
|
||
(raw.to_string(), 0)
|
||
}
|
||
|
||
/// Helper to match line sequences across raw MText paragraph breaks (\P / \p / tags / spaces).
|
||
fn replace_raw_lines(
|
||
raw: &str,
|
||
lines: &[&str],
|
||
matcher: &TextMatcher,
|
||
raw_replacement: &str,
|
||
replace_all: bool,
|
||
) -> (String, usize) {
|
||
if lines.len() < 2 || raw.is_empty() {
|
||
return (raw.to_string(), 0);
|
||
}
|
||
|
||
let line0 = lines[0].trim_end();
|
||
let line_last = lines[lines.len() - 1].trim_start();
|
||
let middle_lines: Vec<&str> = lines[1..lines.len() - 1].iter().map(|l| l.trim()).collect();
|
||
|
||
let m0 = matcher.with_search(line0.to_string());
|
||
let m_last = matcher.with_search(line_last.to_string());
|
||
let m_mids: Vec<TextMatcher> = middle_lines
|
||
.iter()
|
||
.map(|l| matcher.with_search(l.to_string()))
|
||
.collect();
|
||
|
||
let matches0 = m0.find_all_in(raw);
|
||
if matches0.is_empty() {
|
||
return (raw.to_string(), 0);
|
||
}
|
||
|
||
let mut result = String::with_capacity(raw.len());
|
||
let mut cursor = 0;
|
||
let mut count = 0;
|
||
|
||
for (start0, end0) in matches0 {
|
||
if start0 < cursor {
|
||
continue; // already replaced
|
||
}
|
||
|
||
// Try to verify subsequent lines separated by MText paragraph breaks
|
||
let mut check_pos = end0;
|
||
let mut matched_all = true;
|
||
|
||
for m_mid in &m_mids {
|
||
if let Some(next_pos) = skip_mtext_paragraph_break(raw, check_pos) {
|
||
let rest = &raw[next_pos..];
|
||
let mid_matches = m_mid.find_all_in(rest);
|
||
if let Some(&(m_s, m_e)) = mid_matches.first() {
|
||
if m_s == 0 {
|
||
check_pos = next_pos + m_e;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
matched_all = false;
|
||
break;
|
||
}
|
||
|
||
if !matched_all {
|
||
continue;
|
||
}
|
||
|
||
if let Some(next_pos) = skip_mtext_paragraph_break(raw, check_pos) {
|
||
let rest = &raw[next_pos..];
|
||
let last_matches = m_last.find_all_in(rest);
|
||
if let Some(&(l_s, l_e)) = last_matches.first() {
|
||
if l_s == 0 {
|
||
let end_pos = next_pos + l_e;
|
||
result.push_str(&raw[cursor..start0]);
|
||
result.push_str(raw_replacement);
|
||
cursor = end_pos;
|
||
count += 1;
|
||
if !replace_all {
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if count > 0 {
|
||
result.push_str(&raw[cursor..]);
|
||
(result, count)
|
||
} else {
|
||
(raw.to_string(), 0)
|
||
}
|
||
}
|
||
|
||
/// Skips over an MText paragraph break: optional whitespace, `\P` or `\p`, optional format tags like `\pxqc;`, optional whitespace.
|
||
fn skip_mtext_paragraph_break(raw: &str, from: usize) -> Option<usize> {
|
||
if from >= raw.len() {
|
||
return None;
|
||
}
|
||
let bytes = raw.as_bytes();
|
||
let mut i = from;
|
||
|
||
// Skip whitespace before \P
|
||
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
|
||
i += 1;
|
||
}
|
||
|
||
// Must have \P or \p
|
||
if i + 1 < bytes.len() && bytes[i] == b'\\' && (bytes[i + 1] == b'P' || bytes[i + 1] == b'p') {
|
||
i += 2;
|
||
} else {
|
||
return None;
|
||
}
|
||
|
||
// Skip optional paragraph format tags like \pxqc; or \pi...;
|
||
if i < bytes.len() && bytes[i] == b'\\' {
|
||
let mut tag_end = i + 1;
|
||
while tag_end < bytes.len()
|
||
&& bytes[tag_end] != b';'
|
||
&& bytes[tag_end] != b'\\'
|
||
&& bytes[tag_end] != b'{'
|
||
&& bytes[tag_end] != b'}'
|
||
{
|
||
tag_end += 1;
|
||
}
|
||
if tag_end < bytes.len() && bytes[tag_end] == b';' {
|
||
i = tag_end + 1;
|
||
}
|
||
}
|
||
|
||
// Skip whitespace after \P
|
||
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
|
||
i += 1;
|
||
}
|
||
|
||
Some(i)
|
||
}
|
||
|
||
/// Extracts the plain display text and position for an entity.
|
||
pub fn entity_text_info(
|
||
entity: &EntityType,
|
||
document: &CadDocument,
|
||
) -> Option<(String, String, [f64; 3])> {
|
||
// Returns (raw_value, plain_text, [x, y, z])
|
||
match entity {
|
||
EntityType::Text(t) => {
|
||
let raw = t.value.clone();
|
||
let plain = decode_dxf_escapes(&raw);
|
||
let pos = [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z];
|
||
Some((raw, plain, pos))
|
||
}
|
||
EntityType::MText(t) => {
|
||
let raw = t.value.clone();
|
||
let plain_raw = codec::entities::mtext_format::parse_mtext(&raw, true).to_plain_text();
|
||
let plain = decode_dxf_escapes(&plain_raw);
|
||
let pos = [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z];
|
||
Some((raw, plain, pos))
|
||
}
|
||
EntityType::AttributeDefinition(ad) => {
|
||
let raw = ad.default_value.clone();
|
||
let plain = decode_dxf_escapes(&raw);
|
||
let pos = [ad.insertion_point.x, ad.insertion_point.y, ad.insertion_point.z];
|
||
Some((raw, plain, pos))
|
||
}
|
||
EntityType::AttributeEntity(ae) => {
|
||
let raw = ae.get_value().to_string();
|
||
let plain = decode_dxf_escapes(&raw);
|
||
let pos = [ae.insertion_point.x, ae.insertion_point.y, ae.insertion_point.z];
|
||
Some((raw, plain, pos))
|
||
}
|
||
EntityType::Dimension(dim) => {
|
||
let text = dim.base().text.clone();
|
||
if text.is_empty() {
|
||
None
|
||
} else {
|
||
let plain = decode_dxf_escapes(&text);
|
||
let p = dim.base().text_middle_point;
|
||
Some((text, plain, [p.x, p.y, p.z]))
|
||
}
|
||
}
|
||
_ => {
|
||
let _ = document;
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
fn parse_bounds_filter(req: &Value) -> Result<Option<[f64; 4]>, Value> {
|
||
let Some(bounds_val) = req.get("bounds") else {
|
||
return Ok(None);
|
||
};
|
||
let bounds = bounds_val.as_array().and_then(|values| {
|
||
if values.len() != 4 {
|
||
return None;
|
||
}
|
||
let min_x = values[0].as_f64()?;
|
||
let min_y = values[1].as_f64()?;
|
||
let max_x = values[2].as_f64()?;
|
||
let max_y = values[3].as_f64()?;
|
||
Some([min_x, min_y, max_x, max_y])
|
||
});
|
||
let Some(bounds) = bounds else {
|
||
return Err(failure(
|
||
"invalid_bounds",
|
||
"bounds expects [min_x, min_y, max_x, max_y]",
|
||
));
|
||
};
|
||
if !bounds.iter().all(|v| v.is_finite()) || bounds[0] > bounds[2] || bounds[1] > bounds[3] {
|
||
return Err(failure(
|
||
"invalid_bounds",
|
||
"bounds must be finite with min <= max",
|
||
));
|
||
}
|
||
Ok(Some(bounds))
|
||
}
|
||
|
||
fn parse_handles_filter(req: &Value) -> Option<Vec<Handle>> {
|
||
if let Some(h) = req["handle"].as_str() {
|
||
u64::from_str_radix(h.trim_start_matches("0x"), 16)
|
||
.ok()
|
||
.map(|val| vec![Handle::new(val)])
|
||
} else if let Some(arr) = req["handles"].as_array() {
|
||
let handles: Vec<_> = arr
|
||
.iter()
|
||
.filter_map(|v| {
|
||
let s = v.as_str()?;
|
||
u64::from_str_radix(s.trim_start_matches("0x"), 16)
|
||
.ok()
|
||
.map(Handle::new)
|
||
})
|
||
.collect();
|
||
(!handles.is_empty()).then_some(handles)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn parse_types_filter(req: &Value) -> Option<Vec<String>> {
|
||
if let Some(t) = req["type"].as_str() {
|
||
Some(vec![t.to_string()])
|
||
} else if let Some(arr) = req["types"].as_array() {
|
||
let types: Vec<_> = arr
|
||
.iter()
|
||
.filter_map(|v| v.as_str().map(str::to_string))
|
||
.collect();
|
||
(!types.is_empty()).then_some(types)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn entity_block_name(doc: &CadDocument, entity_handle: Handle, owner: Handle) -> Option<String> {
|
||
if let Some(record) = doc.block_records.iter().find(|r| r.handle == owner) {
|
||
return Some(record.name.clone());
|
||
}
|
||
for record in doc.block_records.iter() {
|
||
if record.entity_handles.contains(&entity_handle) {
|
||
return Some(record.name.clone());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
impl OpenCADStudio {
|
||
/// `text_search` — find text occurrences across active space entities, inserts,
|
||
/// and block definitions with detailed machine-readable metadata.
|
||
pub(crate) fn control_text_search(&self, req: &Value) -> Result<Value, Value> {
|
||
let find = req["find"]
|
||
.as_str()
|
||
.or_else(|| req["search"].as_str())
|
||
.unwrap_or("");
|
||
if find.is_empty() {
|
||
return Err(failure("missing_find", "Supply 'find' parameter"));
|
||
}
|
||
|
||
let match_case = req["match_case"].as_bool().unwrap_or(false);
|
||
let whole_word = req["whole_word"].as_bool().unwrap_or(false);
|
||
let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case);
|
||
let scope = req["scope"].as_str().unwrap_or("all");
|
||
let layer_filter = req["layer"].as_str();
|
||
let handles_filter = parse_handles_filter(req);
|
||
let types_filter = parse_types_filter(req);
|
||
let bounds_filter = parse_bounds_filter(req)?;
|
||
let limit = req["limit"].as_u64().unwrap_or(500).min(5000) as usize;
|
||
|
||
let matcher = TextMatcher::new(find.to_string(), match_case, whole_word, ignore_accents);
|
||
let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) {
|
||
self.tabs
|
||
.iter()
|
||
.position(|t| t.id == doc_id)
|
||
.ok_or_else(|| failure("document_not_found", "Specified document_id not found"))?
|
||
} else {
|
||
self.active_tab
|
||
};
|
||
let scene = &self.tabs[i].scene;
|
||
let doc = &scene.document;
|
||
|
||
let mut matches = Vec::new();
|
||
|
||
for entity in doc.entities() {
|
||
let common = entity.common();
|
||
let handle = common.handle;
|
||
|
||
// Scope filter
|
||
let in_active = scene.entity_belongs_to_active_space(handle);
|
||
match scope {
|
||
"active_space" | "model_space" if !in_active => continue,
|
||
"blocks" if in_active => continue,
|
||
_ => {}
|
||
}
|
||
|
||
// Layer filter
|
||
if let Some(l) = layer_filter {
|
||
if !common.layer.eq_ignore_ascii_case(l) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Handle filter
|
||
if let Some(ref h_list) = handles_filter {
|
||
if !h_list.contains(&handle) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Types filter
|
||
if let Some(ref t_list) = types_filter {
|
||
if !t_list
|
||
.iter()
|
||
.any(|t| crate::app::automation::entity_type_matches(entity, t))
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Bounds filter
|
||
if let Some(b) = bounds_filter {
|
||
let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity);
|
||
if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
let block_name = entity_block_name(doc, handle, common.owner_handle);
|
||
|
||
// Check standard text entities
|
||
if let Some((raw_val, plain_text, pos)) = entity_text_info(entity, doc) {
|
||
if matcher.matches_text_or_raw(&plain_text, &raw_val) {
|
||
let matched_text = find.to_string();
|
||
matches.push(json!({
|
||
"handle": format!("{:X}", handle.value()),
|
||
"type": crate::entities::names::ui_name(entity),
|
||
"layer": common.layer,
|
||
"space": if in_active { "active" } else { "definition" },
|
||
"block": block_name,
|
||
"position": pos,
|
||
"raw_value": raw_val,
|
||
"plain_text": plain_text,
|
||
"match_text": matched_text,
|
||
}));
|
||
if matches.len() >= limit {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check Insert attributes
|
||
if let EntityType::Insert(insert) = entity {
|
||
for (idx, attr) in insert.attributes.iter().enumerate() {
|
||
let raw = attr.get_value();
|
||
let plain = decode_dxf_escapes(raw);
|
||
if matcher.matches_text_or_raw(&plain, raw) {
|
||
matches.push(json!({
|
||
"handle": format!("{:X}", handle.value()),
|
||
"type": "Insert",
|
||
"layer": common.layer,
|
||
"space": if in_active { "active" } else { "definition" },
|
||
"block": insert.block_name,
|
||
"attribute_tag": attr.tag,
|
||
"attribute_index": idx,
|
||
"position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z],
|
||
"raw_value": raw,
|
||
"plain_text": plain,
|
||
"match_text": find,
|
||
}));
|
||
if matches.len() >= limit {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if matches.len() >= limit {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
let truncated = matches.len() >= limit;
|
||
Ok(json!({
|
||
"ok": true,
|
||
"count": matches.len(),
|
||
"truncated": truncated,
|
||
"matches": matches,
|
||
}))
|
||
}
|
||
|
||
/// `text_audit` — comprehensive text quality check, spell-check, and replacement dry-run.
|
||
pub(crate) fn control_text_audit(&self, req: &Value) -> Result<Value, Value> {
|
||
let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) {
|
||
self.tabs
|
||
.iter()
|
||
.position(|t| t.id == doc_id)
|
||
.ok_or_else(|| failure("document_not_found", "Specified document_id not found"))?
|
||
} else {
|
||
self.active_tab
|
||
};
|
||
|
||
let match_case = req["match_case"].as_bool().unwrap_or(false);
|
||
let whole_word = req["whole_word"].as_bool().unwrap_or(false);
|
||
let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case);
|
||
let scope = req["scope"].as_str().unwrap_or("all");
|
||
let layer_filter = req["layer"].as_str();
|
||
let handles_filter = parse_handles_filter(req);
|
||
let types_filter = parse_types_filter(req);
|
||
let bounds_filter = parse_bounds_filter(req)?;
|
||
let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize;
|
||
|
||
// Parse dry-run replacement pairs if supplied
|
||
let mut pairs: Vec<(String, String)> = Vec::new();
|
||
let pairs_val = req.get("pairs").or_else(|| req.get("dry_run_pairs"));
|
||
if let Some(pair_array) = pairs_val.and_then(|v| v.as_array()) {
|
||
for item in pair_array {
|
||
let find = item["find"].as_str().unwrap_or("");
|
||
let replace = item["replace"].as_str().unwrap_or("");
|
||
if !find.is_empty() {
|
||
pairs.push((find.to_string(), replace.to_string()));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parse check terms if supplied
|
||
let mut check_terms: Vec<String> = Vec::new();
|
||
if let Some(terms_array) = req.get("check_terms").and_then(|v| v.as_array()) {
|
||
for item in terms_array {
|
||
if let Some(t) = item.as_str() {
|
||
if !t.is_empty() {
|
||
check_terms.push(t.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parse dictionary if supplied
|
||
let mut dictionary: Vec<String> = Vec::new();
|
||
if let Some(dict_array) = req.get("dictionary").and_then(|v| v.as_array()) {
|
||
for item in dict_array {
|
||
if let Some(w) = item.as_str() {
|
||
if !w.is_empty() {
|
||
dictionary.push(w.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let pair_matchers: Vec<(TextMatcher, String)> = pairs
|
||
.iter()
|
||
.map(|(f, r)| {
|
||
(
|
||
TextMatcher::new(f.clone(), match_case, whole_word, ignore_accents),
|
||
r.clone(),
|
||
)
|
||
})
|
||
.collect();
|
||
|
||
let check_matchers: Vec<TextMatcher> = check_terms
|
||
.iter()
|
||
.map(|t| TextMatcher::new(t.clone(), match_case, whole_word, ignore_accents))
|
||
.collect();
|
||
|
||
let dict_matchers: Vec<TextMatcher> = dictionary
|
||
.iter()
|
||
.map(|w| TextMatcher::new(w.clone(), false, true, true))
|
||
.collect();
|
||
|
||
let use_system_speller = req["system_spellcheck"]
|
||
.as_bool()
|
||
.or_else(|| req["use_system_speller"].as_bool())
|
||
.or_else(|| req["system_speller"].as_bool())
|
||
.unwrap_or(false);
|
||
let language_param = req["language"].as_str();
|
||
let want_suggestions = req["suggest"].as_bool().unwrap_or(true);
|
||
|
||
let system_speller = if use_system_speller {
|
||
Some(super::spellcheck::SystemSpeller::new(language_param))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let mut pair_hit_counts = vec![0usize; pair_matchers.len()];
|
||
let mut check_hit_counts = vec![0usize; check_matchers.len()];
|
||
|
||
let scene = &self.tabs[i].scene;
|
||
let doc = &scene.document;
|
||
|
||
let mut entities_scanned = 0usize;
|
||
let mut entities_matched = 0usize;
|
||
let mut total_occurrences = 0usize;
|
||
let mut simulated_changes = Vec::new();
|
||
let mut suspect_matches = Vec::new();
|
||
let mut unrecognized_words = Vec::new();
|
||
|
||
for entity in doc.entities() {
|
||
let common = entity.common();
|
||
let handle = common.handle;
|
||
|
||
let in_active = scene.entity_belongs_to_active_space(handle);
|
||
match scope {
|
||
"active_space" | "model_space" if !in_active => continue,
|
||
"blocks" if in_active => continue,
|
||
_ => {}
|
||
}
|
||
|
||
if let Some(l) = layer_filter {
|
||
if !common.layer.eq_ignore_ascii_case(l) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if let Some(ref h_list) = handles_filter {
|
||
if !h_list.contains(&handle) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if let Some(ref t_list) = types_filter {
|
||
if !t_list
|
||
.iter()
|
||
.any(|t| crate::app::automation::entity_type_matches(entity, t))
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if let Some(b) = bounds_filter {
|
||
let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity);
|
||
if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
let Some((raw_val, plain_text, pos)) = entity_text_info(entity, doc) else {
|
||
continue;
|
||
};
|
||
|
||
entities_scanned += 1;
|
||
let mut entity_had_match = false;
|
||
|
||
// 1. Dry run pairs simulation
|
||
if !pair_matchers.is_empty() {
|
||
match entity {
|
||
EntityType::MText(_) => {
|
||
let mut cur_raw = raw_val.clone();
|
||
for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() {
|
||
let (new_raw, count) =
|
||
replace_mtext_safe(&cur_raw, matcher, replacement, true);
|
||
if count > 0 {
|
||
pair_hit_counts[idx] += count;
|
||
total_occurrences += count;
|
||
entity_had_match = true;
|
||
if simulated_changes.len() < limit {
|
||
simulated_changes.push(json!({
|
||
"handle": format!("{:X}", handle.value()),
|
||
"type": "MText",
|
||
"layer": common.layer,
|
||
"position": pos,
|
||
"find": pairs[idx].0,
|
||
"replace": pairs[idx].1,
|
||
"before": cur_raw,
|
||
"after": new_raw.clone(),
|
||
"replaced": count,
|
||
}));
|
||
}
|
||
cur_raw = new_raw;
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
let mut cur_text = plain_text.clone();
|
||
for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() {
|
||
let (new_text, count) =
|
||
matcher.replace_all_in(&cur_text, replacement, true);
|
||
if count > 0 {
|
||
pair_hit_counts[idx] += count;
|
||
total_occurrences += count;
|
||
entity_had_match = true;
|
||
if simulated_changes.len() < limit {
|
||
simulated_changes.push(json!({
|
||
"handle": format!("{:X}", handle.value()),
|
||
"type": crate::entities::names::ui_name(entity),
|
||
"layer": common.layer,
|
||
"position": pos,
|
||
"find": pairs[idx].0,
|
||
"replace": pairs[idx].1,
|
||
"before": cur_text,
|
||
"after": new_text.clone(),
|
||
"replaced": count,
|
||
}));
|
||
}
|
||
cur_text = new_text;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Check terms
|
||
for (idx, matcher) in check_matchers.iter().enumerate() {
|
||
if matcher.matches_text_or_raw(&plain_text, &raw_val) {
|
||
check_hit_counts[idx] += 1;
|
||
entity_had_match = true;
|
||
if suspect_matches.len() < limit {
|
||
suspect_matches.push(json!({
|
||
"handle": format!("{:X}", handle.value()),
|
||
"type": crate::entities::names::ui_name(entity),
|
||
"layer": common.layer,
|
||
"position": pos,
|
||
"term": check_terms[idx],
|
||
"plain_text": plain_text,
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Dictionary & System Spell-check
|
||
if !dict_matchers.is_empty() || system_speller.is_some() {
|
||
for word in plain_text.split(|c: char| !c.is_alphabetic()) {
|
||
let w = word.trim();
|
||
if w.len() >= 3 {
|
||
let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w));
|
||
if in_agent_dict {
|
||
continue;
|
||
}
|
||
|
||
let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller {
|
||
speller.check_word(w, want_suggestions)
|
||
} else {
|
||
(false, Vec::new())
|
||
};
|
||
|
||
if !is_system_valid {
|
||
entity_had_match = true;
|
||
if unrecognized_words.len() < limit
|
||
&& !unrecognized_words
|
||
.iter()
|
||
.any(|item: &Value| item["word"].as_str() == Some(w))
|
||
{
|
||
let mut item = json!({
|
||
"word": w,
|
||
"handle": format!("{:X}", handle.value()),
|
||
"position": pos,
|
||
});
|
||
if !suggestions.is_empty() {
|
||
item["suggestions"] = json!(suggestions);
|
||
}
|
||
unrecognized_words.push(item);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check Insert attributes
|
||
if let EntityType::Insert(insert) = entity {
|
||
for attr in &insert.attributes {
|
||
let raw = attr.get_value();
|
||
let plain = decode_dxf_escapes(raw);
|
||
|
||
for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() {
|
||
let (new_text, count) =
|
||
matcher.replace_all_in(&plain, replacement, true);
|
||
if count > 0 {
|
||
pair_hit_counts[idx] += count;
|
||
total_occurrences += count;
|
||
entity_had_match = true;
|
||
if simulated_changes.len() < limit {
|
||
simulated_changes.push(json!({
|
||
"handle": format!("{:X}", handle.value()),
|
||
"type": "Insert",
|
||
"attribute_tag": attr.tag,
|
||
"layer": common.layer,
|
||
"position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z],
|
||
"find": pairs[idx].0,
|
||
"replace": pairs[idx].1,
|
||
"before": plain.clone(),
|
||
"after": new_text,
|
||
"replaced": count,
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
for (idx, matcher) in check_matchers.iter().enumerate() {
|
||
if matcher.matches_text_or_raw(&plain, raw) {
|
||
check_hit_counts[idx] += 1;
|
||
entity_had_match = true;
|
||
if suspect_matches.len() < limit {
|
||
suspect_matches.push(json!({
|
||
"handle": format!("{:X}", handle.value()),
|
||
"type": "Insert",
|
||
"attribute_tag": attr.tag,
|
||
"layer": common.layer,
|
||
"position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z],
|
||
"term": check_terms[idx],
|
||
"plain_text": plain,
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
if !dict_matchers.is_empty() || system_speller.is_some() {
|
||
for word in plain.split(|c: char| !c.is_alphabetic()) {
|
||
let w = word.trim();
|
||
if w.len() >= 3 {
|
||
let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w));
|
||
if in_agent_dict {
|
||
continue;
|
||
}
|
||
|
||
let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller {
|
||
speller.check_word(w, want_suggestions)
|
||
} else {
|
||
(false, Vec::new())
|
||
};
|
||
|
||
if !is_system_valid {
|
||
entity_had_match = true;
|
||
if unrecognized_words.len() < limit
|
||
&& !unrecognized_words
|
||
.iter()
|
||
.any(|item: &Value| item["word"].as_str() == Some(w))
|
||
{
|
||
let mut item = json!({
|
||
"word": w,
|
||
"handle": format!("{:X}", handle.value()),
|
||
"attribute_tag": attr.tag,
|
||
"position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z],
|
||
});
|
||
if !suggestions.is_empty() {
|
||
item["suggestions"] = json!(suggestions);
|
||
}
|
||
unrecognized_words.push(item);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if entity_had_match {
|
||
entities_matched += 1;
|
||
}
|
||
}
|
||
|
||
// Unmatched pairs / queries
|
||
let unmatched_pairs: Vec<String> = pair_hit_counts
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, &count)| count == 0)
|
||
.map(|(idx, _)| pairs[idx].0.clone())
|
||
.collect();
|
||
|
||
let unmatched_check_terms: Vec<String> = check_hit_counts
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, &count)| count == 0)
|
||
.map(|(idx, _)| check_terms[idx].clone())
|
||
.collect();
|
||
|
||
let speller_info = if let Some(ref speller) = system_speller {
|
||
json!({
|
||
"enabled": true,
|
||
"available": speller.is_available(),
|
||
"backend": speller.backend_name(),
|
||
"language": speller.language(),
|
||
})
|
||
} else {
|
||
json!({
|
||
"enabled": false,
|
||
"available": false,
|
||
"backend": "none",
|
||
"language": null,
|
||
})
|
||
};
|
||
|
||
Ok(json!({
|
||
"ok": true,
|
||
"summary": {
|
||
"entities_scanned": entities_scanned,
|
||
"entities_matched": entities_matched,
|
||
"occurrences_matched": total_occurrences,
|
||
"pairs_total": pairs.len(),
|
||
"pairs_unmatched": unmatched_pairs.len(),
|
||
"check_terms_total": check_terms.len(),
|
||
"check_terms_found": check_terms.len() - unmatched_check_terms.len(),
|
||
"unrecognized_words_count": unrecognized_words.len(),
|
||
},
|
||
"system_speller": speller_info,
|
||
"unmatched_pairs": unmatched_pairs,
|
||
"unmatched_check_terms": unmatched_check_terms,
|
||
"simulated_changes": simulated_changes,
|
||
"suspect_matches": suspect_matches,
|
||
"unrecognized_words": unrecognized_words,
|
||
}))
|
||
}
|
||
|
||
/// Execute text replace across candidates with single-transaction undo support.
|
||
pub(crate) fn execute_text_replace(
|
||
&mut self,
|
||
req: &Value,
|
||
push_undo: bool,
|
||
) -> Result<Value, Value> {
|
||
let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) {
|
||
self.tabs
|
||
.iter()
|
||
.position(|t| t.id == doc_id)
|
||
.ok_or_else(|| failure("document_not_found", "Specified document_id not found"))?
|
||
} else {
|
||
self.active_tab
|
||
};
|
||
|
||
// Parse search/replace pairs
|
||
let mut pairs: Vec<(String, String)> = Vec::new();
|
||
if let Some(pair_array) = req["pairs"].as_array() {
|
||
for item in pair_array {
|
||
let find = item["find"].as_str().unwrap_or("");
|
||
let replace = item["replace"].as_str().unwrap_or("");
|
||
if !find.is_empty() {
|
||
pairs.push((find.to_string(), replace.to_string()));
|
||
}
|
||
}
|
||
} else if let Some(find) = req["find"].as_str() {
|
||
if !find.is_empty() {
|
||
let replace = req["replace"].as_str().unwrap_or("");
|
||
pairs.push((find.to_string(), replace.to_string()));
|
||
}
|
||
}
|
||
|
||
if pairs.is_empty() {
|
||
return Err(failure(
|
||
"missing_find",
|
||
"Supply 'find'/'replace' or 'pairs' for text_replace",
|
||
));
|
||
}
|
||
|
||
let match_case = req["match_case"].as_bool().unwrap_or(false);
|
||
let whole_word = req["whole_word"].as_bool().unwrap_or(false);
|
||
let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case);
|
||
let dry_run = req["dry_run"].as_bool().unwrap_or(false);
|
||
let replace_all = req["replace_all"].as_bool().unwrap_or(true);
|
||
let scope = req["scope"].as_str().unwrap_or("all");
|
||
let layer_filter = req["layer"].as_str();
|
||
let handles_filter = parse_handles_filter(req);
|
||
let types_filter = parse_types_filter(req);
|
||
let bounds_filter = parse_bounds_filter(req)?;
|
||
|
||
let matchers: Vec<(TextMatcher, String)> = pairs
|
||
.into_iter()
|
||
.map(|(f, r)| (TextMatcher::new(f, match_case, whole_word, ignore_accents), r))
|
||
.collect();
|
||
|
||
if push_undo && !dry_run {
|
||
self.push_undo_snapshot(i, "TEXT REPLACE");
|
||
}
|
||
|
||
let mut total_replaced = 0usize;
|
||
let mut changed_handles = Vec::new();
|
||
let mut changes_log = Vec::new();
|
||
|
||
// Collect all entity handles that match filters
|
||
let candidate_handles: Vec<Handle> = {
|
||
let scene = &self.tabs[i].scene;
|
||
let doc = &scene.document;
|
||
let mut candidates = Vec::new();
|
||
for entity in doc.entities() {
|
||
let common = entity.common();
|
||
let handle = common.handle;
|
||
|
||
let in_active = scene.entity_belongs_to_active_space(handle);
|
||
match scope {
|
||
"active_space" | "model_space" if !in_active => continue,
|
||
"blocks" if in_active => continue,
|
||
_ => {}
|
||
}
|
||
|
||
if let Some(l) = layer_filter {
|
||
if !common.layer.eq_ignore_ascii_case(l) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if let Some(ref h_list) = handles_filter {
|
||
if !h_list.contains(&handle) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if let Some(ref t_list) = types_filter {
|
||
if !t_list
|
||
.iter()
|
||
.any(|t| crate::app::automation::entity_type_matches(entity, t))
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if let Some(b) = bounds_filter {
|
||
let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity);
|
||
if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if scene.is_layer_locked(handle) {
|
||
continue;
|
||
}
|
||
|
||
candidates.push(handle);
|
||
}
|
||
candidates
|
||
};
|
||
|
||
// Mutate matching entities
|
||
let doc = &mut self.tabs[i].scene.document;
|
||
for handle in candidate_handles {
|
||
let Some(entity) = doc.get_entity_mut(handle) else {
|
||
continue;
|
||
};
|
||
|
||
let mut entity_replaced = 0;
|
||
let mut before_val = None;
|
||
let mut after_val = None;
|
||
|
||
match entity {
|
||
EntityType::Text(text) => {
|
||
for (matcher, replacement) in &matchers {
|
||
let (new_val, count) =
|
||
matcher.replace_all_in(&text.value, replacement, replace_all);
|
||
if count > 0 {
|
||
if before_val.is_none() {
|
||
before_val = Some(text.value.clone());
|
||
}
|
||
if !dry_run {
|
||
text.value = new_val.clone();
|
||
}
|
||
entity_replaced += count;
|
||
after_val = Some(new_val);
|
||
} else {
|
||
// Check if matching decoded DXF escapes
|
||
let decoded = decode_dxf_escapes(&text.value);
|
||
if matcher.matches(&decoded) {
|
||
let (new_val, c) =
|
||
matcher.replace_all_in(&decoded, replacement, replace_all);
|
||
if c > 0 {
|
||
if before_val.is_none() {
|
||
before_val = Some(text.value.clone());
|
||
}
|
||
if !dry_run {
|
||
text.value = new_val.clone();
|
||
}
|
||
entity_replaced += c;
|
||
after_val = Some(new_val);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
EntityType::MText(mtext) => {
|
||
for (matcher, replacement) in &matchers {
|
||
let (new_val, count) =
|
||
replace_mtext_safe(&mtext.value, matcher, replacement, replace_all);
|
||
if count > 0 {
|
||
if before_val.is_none() {
|
||
before_val = Some(mtext.value.clone());
|
||
}
|
||
if !dry_run {
|
||
mtext.value = new_val.clone();
|
||
}
|
||
entity_replaced += count;
|
||
after_val = Some(new_val);
|
||
}
|
||
}
|
||
}
|
||
EntityType::AttributeDefinition(ad) => {
|
||
for (matcher, replacement) in &matchers {
|
||
let (new_val, count) =
|
||
matcher.replace_all_in(&ad.default_value, replacement, replace_all);
|
||
if count > 0 {
|
||
if before_val.is_none() {
|
||
before_val = Some(ad.default_value.clone());
|
||
}
|
||
if !dry_run {
|
||
ad.default_value = new_val.clone();
|
||
}
|
||
entity_replaced += count;
|
||
after_val = Some(new_val);
|
||
}
|
||
}
|
||
}
|
||
EntityType::AttributeEntity(ae) => {
|
||
for (matcher, replacement) in &matchers {
|
||
let (new_val, count) =
|
||
matcher.replace_all_in(ae.get_value(), replacement, replace_all);
|
||
if count > 0 {
|
||
if before_val.is_none() {
|
||
before_val = Some(ae.get_value().to_string());
|
||
}
|
||
if !dry_run {
|
||
ae.set_value(new_val.clone());
|
||
}
|
||
entity_replaced += count;
|
||
after_val = Some(new_val);
|
||
}
|
||
}
|
||
}
|
||
EntityType::Dimension(dim) => {
|
||
let base = dim.base_mut();
|
||
if !base.text.is_empty() {
|
||
for (matcher, replacement) in &matchers {
|
||
let (new_val, count) =
|
||
matcher.replace_all_in(&base.text, replacement, replace_all);
|
||
if count > 0 {
|
||
if before_val.is_none() {
|
||
before_val = Some(base.text.clone());
|
||
}
|
||
if !dry_run {
|
||
base.text = new_val.clone();
|
||
}
|
||
entity_replaced += count;
|
||
after_val = Some(new_val);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
EntityType::Insert(insert) => {
|
||
for attr in &mut insert.attributes {
|
||
for (matcher, replacement) in &matchers {
|
||
let (new_val, count) =
|
||
matcher.replace_all_in(attr.get_value(), replacement, replace_all);
|
||
if count > 0 {
|
||
let old_val = attr.get_value().to_string();
|
||
if !dry_run {
|
||
attr.set_value(new_val.clone());
|
||
}
|
||
total_replaced += count;
|
||
changes_log.push(json!({
|
||
"handle": format!("{:X}", handle.value()),
|
||
"type": "Insert",
|
||
"attribute_tag": attr.tag,
|
||
"before": old_val,
|
||
"after": new_val,
|
||
"replaced": count,
|
||
}));
|
||
if !changed_handles.contains(&handle) {
|
||
changed_handles.push(handle);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
if entity_replaced > 0 {
|
||
total_replaced += entity_replaced;
|
||
changes_log.push(json!({
|
||
"handle": format!("{:X}", handle.value()),
|
||
"type": crate::entities::names::ui_name(entity),
|
||
"before": before_val,
|
||
"after": after_val,
|
||
"replaced": entity_replaced,
|
||
}));
|
||
if !changed_handles.contains(&handle) {
|
||
changed_handles.push(handle);
|
||
}
|
||
}
|
||
}
|
||
|
||
if total_replaced == 0 {
|
||
if push_undo && !dry_run {
|
||
self.discard_last_undo_entry(i);
|
||
}
|
||
return Ok(json!({
|
||
"ok": true,
|
||
"dry_run": dry_run,
|
||
"replaced": 0,
|
||
"entities_changed": 0,
|
||
"changes": [],
|
||
}));
|
||
}
|
||
|
||
if !dry_run {
|
||
// Refresh scene caches and dirty flags
|
||
self.invalidate_property_targets(i, &changed_handles);
|
||
self.tabs[i].scene.bump_geometry();
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_properties();
|
||
|
||
self.command_line.push_output(
|
||
format!(
|
||
"TEXT REPLACE: replaced {total_replaced} occurrence(s) in {} object(s).",
|
||
changed_handles.len()
|
||
)
|
||
.as_str(),
|
||
);
|
||
}
|
||
|
||
Ok(json!({
|
||
"ok": true,
|
||
"dry_run": dry_run,
|
||
"replaced": total_replaced,
|
||
"entities_changed": changed_handles.len(),
|
||
"changes": changes_log,
|
||
}))
|
||
}
|
||
|
||
/// Bridge for `control_request` mutating op `"text_replace"`.
|
||
pub(super) fn control_text_replace(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
||
let result = self.execute_text_replace(req, true)?;
|
||
self.set_control_result(result);
|
||
Ok(Task::none())
|
||
}
|
||
|
||
/// Headless automation text search.
|
||
pub(crate) fn automation_text_search(&self, req: &Value) -> Value {
|
||
self.control_text_search(req).unwrap_or_else(|e| e)
|
||
}
|
||
|
||
/// Headless automation text audit.
|
||
pub(crate) fn automation_text_audit(&self, req: &Value) -> Value {
|
||
self.control_text_audit(req).unwrap_or_else(|e| e)
|
||
}
|
||
|
||
/// Headless automation text replace.
|
||
pub(crate) fn automation_text_replace(&mut self, req: &Value) -> Value {
|
||
self.execute_text_replace(req, true).unwrap_or_else(|e| e)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_decode_dxf_escapes() {
|
||
assert_eq!(decode_dxf_escapes("CIRCUITS"), "CIRCUITS");
|
||
assert_eq!(decode_dxf_escapes("R\\U+00C9SERVOIR"), "RÉSERVOIR");
|
||
assert_eq!(decode_dxf_escapes("r\\u+00e9servoir"), "réservoir");
|
||
assert_eq!(decode_dxf_escapes("Angle %%d"), "Angle °");
|
||
assert_eq!(decode_dxf_escapes("Tolerance %%p0.05"), "Tolerance ±0.05");
|
||
assert_eq!(decode_dxf_escapes("Diameter %%c50"), "Diameter ∅50");
|
||
assert_eq!(decode_dxf_escapes("100%%%%"), "100%");
|
||
// Windows-1252 / ISO-8859-1 code decoding
|
||
assert_eq!(decode_dxf_escapes("Chaudi%%232re"), "Chaudière");
|
||
assert_eq!(decode_dxf_escapes("St%%233 EVERWASH"), "Sté EVERWASH");
|
||
}
|
||
|
||
#[test]
|
||
fn test_text_matcher_case_and_word_boundaries() {
|
||
let tm_ci = TextMatcher::new("bar".into(), false, false, false);
|
||
assert!(tm_ci.matches("BAROMETRE"));
|
||
assert!(tm_ci.matches("bar"));
|
||
assert!(tm_ci.matches("BAR"));
|
||
|
||
let tm_ww = TextMatcher::new("bar".into(), false, true, false);
|
||
assert!(!tm_ww.matches("BAROMETRE"));
|
||
assert!(!tm_ww.matches("MINIBAR"));
|
||
assert!(tm_ww.matches("BAR"));
|
||
assert!(tm_ww.matches("P = 10 BAR"));
|
||
assert!(tm_ww.matches("BAR;"));
|
||
|
||
let tm_cs = TextMatcher::new("Bar".into(), true, false, false);
|
||
assert!(!tm_cs.matches("BAR"));
|
||
assert!(tm_cs.matches("Barometer"));
|
||
|
||
let (replaced, count) = tm_ww.replace_all_in("10 BAR ET 20 BAROMETRE", "PSI", true);
|
||
assert_eq!(count, 1);
|
||
assert_eq!(replaced, "10 PSI ET 20 BAROMETRE");
|
||
}
|
||
|
||
#[test]
|
||
fn test_text_matcher_accent_insensitive() {
|
||
let tm_acc = TextMatcher::new("reservoir".into(), false, false, true);
|
||
assert!(tm_acc.matches("RÉSERVOIR"));
|
||
assert!(tm_acc.matches("Réservoir"));
|
||
assert!(tm_acc.matches("reservoir"));
|
||
|
||
let tm_ch = TextMatcher::new("chaudiere".into(), false, false, true);
|
||
assert!(tm_ch.matches("Chaudière 1"));
|
||
let (replaced, count) = tm_ch.replace_all_in("Chaudière 1 et Chaudière 2", "Boiler", true);
|
||
assert_eq!(count, 2);
|
||
assert_eq!(replaced, "Boiler 1 et Boiler 2");
|
||
|
||
let tm_bache = TextMatcher::new("bache a eau".into(), false, false, true);
|
||
assert!(tm_bache.matches("Bâche à eau 2"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_replace_mtext_safe_preserves_formatting() {
|
||
let raw = "{\\fCentury Gothic|b0|i0|c0|p34;V15-CUVE EAU NON TRAITE}";
|
||
let matcher = TextMatcher::new("NON TRAITE".into(), false, false, false);
|
||
let (replaced, count) = replace_mtext_safe(raw, &matcher, "NON TRAITÉE", true);
|
||
assert_eq!(count, 1);
|
||
assert!(replaced.contains("NON TRAITÉE"));
|
||
assert!(replaced.contains("Century Gothic"));
|
||
assert!(!replaced.contains("NON TRAITE}"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_replace_mtext_multiline() {
|
||
// Test 1: Plain LF query matching raw \P
|
||
let raw1 = "Loge \\Pgardien";
|
||
let matcher1 = TextMatcher::new("Loge \ngardien".into(), false, false, true);
|
||
let (replaced1, count1) = replace_mtext_safe(raw1, &matcher1, "Security \nGuard Booth", true);
|
||
assert_eq!(count1, 1);
|
||
assert_eq!(replaced1, "Security \\PGuard Booth");
|
||
|
||
// Test 2: Multiline with complex formatting codes preserved
|
||
let raw2 = "\\pxqc;{\\fCentury Gothic|b1|i0|c0|p34;ACCÈS ET SORTIE\\PMARCHANDISES}";
|
||
let matcher2 = TextMatcher::new("ACCÈS ET SORTIE\nMARCHANDISES".into(), false, false, true);
|
||
let (replaced2, count2) = replace_mtext_safe(raw2, &matcher2, "GOODS ACCESS AND EXIT", true);
|
||
assert_eq!(count2, 1);
|
||
assert!(replaced2.contains("GOODS ACCESS AND EXIT"));
|
||
assert!(replaced2.contains("\\pxqc;"));
|
||
assert!(replaced2.contains("Century Gothic"));
|
||
|
||
// Test 3: Multiline with paragraph properties and flexible whitespace
|
||
let raw3 = "{\\pqc;Office \\PWAREHOUSE}";
|
||
let matcher3 = TextMatcher::new("Office \nWAREHOUSE".into(), false, false, true);
|
||
let (replaced3, count3) = replace_mtext_safe(raw3, &matcher3, "WAREHOUSE OFFICE", true);
|
||
assert_eq!(count3, 1);
|
||
assert_eq!(replaced3, "{\\pqc;WAREHOUSE OFFICE}");
|
||
}
|
||
}
|
||
|