Skip to main content

easyofd_reader/keyword/
keyword_extractor.rs

1//! 关键字抽取器。
2//!
3//! 对应 Java: org.ofdrw.reader.keyword.KeywordExtractor
4//!
5//! 支持两种搜索模式:
6//! - [`KeywordExtractor::get_keyword_positions`]: 简化模式,基于 `OfdPage` 模型。
7//! - [`KeywordExtractor::get_keyword_positions_from_text_codes`][]: 完整模式,
8//!   支持跨 TextCode 边界的关键字定位,对齐 Java 版行为。
9//!
10//! ## 跨 TextCode 匹配算法
11//!
12//! 当关键字被 TextCode 边界切断时(如"电子印章"分属两个 TextCode),算法会
13//! 拼接相邻 TextCode 的文本内容来定位完整关键字:
14//!
15//! 1. **普通匹配**:关键字完整包含在单个 TextCode 中。
16//! 2. **前缀匹配**:当前 TextCode 的内容是关键字的前缀,向后拼接后续 TextCode。
17//! 3. **后缀匹配**:当前 TextCode 的末尾匹配关键字的开头,向后拼接。
18//!
19//! 坐标计算使用 DeltaX/DeltaY 逐字符偏移,对齐 Java `POINT_PER_MM` 语义。
20
21use super::KeywordPosition;
22use easyofd_core::{OfdPage, ST_Box};
23
24/// 每毫米的 point 单位(72pt / 25.4mm)。
25const POINT_PER_MM: f64 = 72.0 / 25.4;
26
27/// 带上下文的 TextCode 条目,用于跨 TextCode 边界的关键字搜索。
28///
29/// 对应 Java: `TextCode` + `KeywordResource`(`boundaryMapping` 条目)
30///
31/// 将 OFD 文字定位信息(TextCode)与所属文本对象属性(边界框、字号)绑定。
32/// `content`、`x`、`y`、`delta_x`、`delta_y` 来自 OFD TextCode 元素;
33/// `page`、`boundary`、`font_size` 来自父 TextObject 及其所属页面。
34#[derive(Debug, Clone)]
35pub struct TextCodeEntry {
36    /// 文字内容。
37    pub content: String,
38    /// 文本起始 X 坐标(mm,相对于父 TextObject 边界)。None 表示继承前一个 TextCode 位置。
39    pub x: Option<f64>,
40    /// 文本起始 Y 坐标(mm)。None 表示继承前一个 TextCode 位置。
41    pub y: Option<f64>,
42    /// X 方向逐字符偏移(单位 mm,已展开压缩格式)。
43    pub delta_x: Vec<f64>,
44    /// Y 方向逐字符偏移(单位 mm,已展开压缩格式)。
45    pub delta_y: Vec<f64>,
46    /// 所在页码(从 1 开始)。
47    pub page: usize,
48    /// 父文本对象边界框(mm)。
49    pub boundary: ST_Box,
50    /// 字号(mm,对应 OFD CT_Text 的 Size 属性)。
51    pub font_size: f64,
52    /// 仿射变换矩阵(可选),对应 OFD CT_Text 的 CTM 属性。
53    ///
54    /// 6 元素 `[a, b, c, d, e, f]`,变换公式:
55    /// - `x' = a * x + c * y + e`
56    /// - `y' = b * x + d * y + f`
57    ///
58    /// 对应 Java: `CT_Text.getCTM()` + `KeywordExtractor#getCtmKeywordPosition`
59    pub ctm: Option<[f64; 6]>,
60}
61
62impl TextCodeEntry {
63    /// 创建新的 TextCode 条目。
64    #[must_use]
65    pub fn new(content: impl Into<String>, page: usize, boundary: ST_Box, font_size: f64) -> Self {
66        Self {
67            content: content.into(),
68            x: None,
69            y: None,
70            delta_x: Vec::new(),
71            delta_y: Vec::new(),
72            page,
73            boundary,
74            font_size,
75            ctm: None,
76        }
77    }
78
79    /// 设置坐标。
80    #[must_use]
81    pub fn coordinate(mut self, x: f64, y: f64) -> Self {
82        self.x = Some(x);
83        self.y = Some(y);
84        self
85    }
86
87    /// 设置 X 方向偏移。
88    #[must_use]
89    pub fn delta_x(mut self, deltas: Vec<f64>) -> Self {
90        self.delta_x = deltas;
91        self
92    }
93
94    /// 设置 Y 方向偏移。
95    #[must_use]
96    pub fn delta_y(mut self, deltas: Vec<f64>) -> Self {
97        self.delta_y = deltas;
98        self
99    }
100
101    /// 设置仿射变换矩阵(CTM)。
102    ///
103    /// 对应 Java: `CT_Text.getCTM()` 在 `KeywordExtractor#getCtmKeywordPosition` 中的使用。
104    ///
105    /// 6 元素 `[a, b, c, d, e, f]`,变换公式:
106    /// - `x' = a * x + c * y + e`
107    /// - `y' = b * x + d * y + f`
108    #[must_use]
109    pub fn ctm(mut self, ctm: [f64; 6]) -> Self {
110        self.ctm = Some(ctm);
111        self
112    }
113}
114
115// ── 辅助函数 ────────────────────────────────────────────────────────────────
116
117/// 将字符索引转换为字节索引。
118fn char_to_byte_offset(s: &str, char_offset: usize) -> usize {
119    s.char_indices()
120        .nth(char_offset)
121        .map_or(s.len(), |(i, _)| i)
122}
123
124/// 确保 delta 数组长度至少为 `len`,不足时用末值补齐。
125///
126/// 对应 Java: `DeltaTool.getDelta(ST_Array, int)` 的补齐逻辑。
127/// 若 delta 为空则返回空(表示无偏移信息)。
128fn pad_delta(deltas: &[f64], content_len: usize) -> Vec<f64> {
129    if deltas.is_empty() || content_len == 0 {
130        return Vec::new();
131    }
132    if deltas.len() >= content_len {
133        return deltas.to_vec();
134    }
135    let mut result = deltas.to_vec();
136    let last = *result.last().expect("deltas 非空");
137    result.resize(content_len, last);
138    result
139}
140
141/// 在字符切片中从 `from` 位置开始查找子序列。
142///
143/// 对应 Java: `String.indexOf(String, int)`
144fn char_find_from(haystack: &[char], needle: &[char], from: usize) -> Option<usize> {
145    if needle.is_empty() || from > haystack.len() {
146        return None;
147    }
148    let end = haystack.len().saturating_sub(needle.len());
149    for i in from..=end {
150        if haystack[i..].starts_with(needle) {
151            return Some(i);
152        }
153    }
154    None
155}
156
157/// 检查后缀匹配:content 的尾部是否为 keyword 的前缀。
158///
159/// 对应 Java: `KeywordExtractor#checkPostfixMatch`
160///
161/// 查找 content 中最后一个 keyword 首字符的位置,然后验证从该位置到
162/// content 末尾的字符序列是否匹配 keyword 的开头。
163fn check_postfix_match(content_chars: &[char], keyword_chars: &[char]) -> Option<usize> {
164    if keyword_chars.is_empty() || content_chars.is_empty() {
165        return None;
166    }
167
168    let first_char = keyword_chars[0];
169    // 对应 Java: content.lastIndexOf(keyword.charAt(0))
170    let start_index = content_chars.iter().rposition(|&c| c == first_char)?;
171
172    // 对应 Java: for (j = startIndex, k = 0; j < content.length(); j++, k++)
173    for (k, &ch) in content_chars[start_index..].iter().enumerate() {
174        if k >= keyword_chars.len() || ch != keyword_chars[k] {
175            return None;
176        }
177    }
178
179    Some(start_index)
180}
181
182/// 检索后续 TextCode 条目,拼接文本直到匹配关键字或失配。
183///
184/// 对应 Java: `KeywordExtractor#searchNextText`
185///
186/// 从 `start_index + 1` 开始遍历同页条目,逐步拼接内容。当拼接结果
187/// 等于或以关键字开头时停止(完全匹配);当关键字以拼接结果开头时继续
188/// (部分匹配);否则失配停止。`merge_indices` 在调用前已包含起始条目索引。
189fn search_next_text(
190    entries: &[TextCodeEntry],
191    start_index: usize,
192    keyword: &str,
193    first_match_content: &str,
194) -> Vec<usize> {
195    let mut merge_indices = vec![start_index];
196    let mut merge_text = String::from(first_match_content);
197    let current_page = entries[start_index].page;
198
199    for j in (start_index + 1)..entries.len() {
200        let next = &entries[j];
201        // 对应 Java: "".equals(next.getContent().trim()) → continue
202        if next.content.trim().is_empty() {
203            continue;
204        }
205        // 对应 Java: currentPage != nextKr.getPage() → break
206        if next.page != current_page {
207            break;
208        }
209
210        merge_text.push_str(&next.content);
211
212        // 对应 Java: mergeTextString.equals(keyword) || mergeTextString.startsWith(keyword)
213        if merge_text == keyword || merge_text.starts_with(keyword) {
214            merge_indices.push(j);
215            break;
216        }
217        // 对应 Java: keyword.startsWith(mergeTextString)
218        if keyword.starts_with(&merge_text) {
219            merge_indices.push(j);
220        } else {
221            break;
222        }
223    }
224
225    merge_indices
226}
227
228/// 计算从 boundary 左上角偏移后的基准坐标。
229///
230/// 对应 Java: `KeywordExtractor#getLeftBottomPos`
231fn get_base_xy(
232    entry: &TextCodeEntry,
233    delta_x: &[f64],
234    delta_y: &[f64],
235    char_offset: usize,
236) -> (f64, f64) {
237    let mut x = entry.boundary.top_left_x + entry.x.unwrap_or(0.0);
238    let mut y = entry.boundary.top_left_y + entry.y.unwrap_or(0.0);
239    for i in 0..char_offset {
240        if i < delta_x.len() {
241            x += delta_x[i];
242        }
243        if i < delta_y.len() {
244            y += delta_y[i];
245        }
246    }
247    (x, y)
248}
249
250/// 获取文本子串宽度。
251///
252/// 对应 Java: `KeywordExtractor#getStringWidth`
253///
254/// 宽度 = 字号 + 从 `start_char` 起 `char_count - 1` 个 delta 偏移之和。
255fn get_string_width(start_char: usize, char_count: usize, delta_x: &[f64], font_size: f64) -> f64 {
256    if char_count == 0 {
257        return 0.0;
258    }
259    let mut width = font_size;
260    for i in start_char..(start_char + char_count - 1) {
261        if i < delta_x.len() {
262            width += delta_x[i];
263        }
264    }
265    width
266}
267
268/// 合并多个边界框为一个包含所有框的最小外接矩形。
269///
270/// 对应 Java: `KeywordExtractor#mergeBox`
271fn merge_boxes(boxes: &[ST_Box]) -> ST_Box {
272    if boxes.is_empty() {
273        return ST_Box::new(0.0, 0.0, 0.0, 0.0);
274    }
275    let mut min_x = f64::INFINITY;
276    let mut min_y = f64::INFINITY;
277    let mut max_x = f64::NEG_INFINITY;
278    let mut max_y = f64::NEG_INFINITY;
279
280    for b in boxes {
281        min_x = min_x.min(b.top_left_x);
282        min_y = min_y.min(b.top_left_y);
283        max_x = max_x.max(b.top_left_x + b.width);
284        max_y = max_y.max(b.top_left_y + b.height);
285    }
286
287    ST_Box::new(min_x, min_y, max_x - min_x, max_y - min_y)
288}
289
290/// 对坐标应用 CTM 仿射变换。
291///
292/// 对应 Java: `KeywordExtractor#transform`
293///
294/// 变换公式(OFD CTM 语义,行优先仿射矩阵 `[a b c d e f]`):
295/// - `x' = a * sx + c * sy + e`
296/// - `y' = b * sx + d * sy + f`
297///
298/// 与 Java 版 `transform` 方法保持一致的乘法顺序。
299fn ctm_transform(matrix: &[f64; 6], sx: f64, sy: f64) -> (f64, f64) {
300    let x = matrix[0] * sx + matrix[2] * sy + matrix[4];
301    let y = matrix[1] * sx + matrix[3] * sy + matrix[5];
302    (x, y)
303}
304
305/// 合并多个坐标点为最小外接矩形。
306///
307/// 对应 Java: `KeywordExtractor#mergePos`
308fn merge_positions(positions: &[(f64, f64)]) -> ST_Box {
309    let mut min_x = f64::INFINITY;
310    let mut min_y = f64::INFINITY;
311    let mut max_x = f64::NEG_INFINITY;
312    let mut max_y = f64::NEG_INFINITY;
313
314    for &(px, py) in positions {
315        min_x = min_x.min(px);
316        min_y = min_y.min(py);
317        max_x = max_x.max(px);
318        max_y = max_y.max(py);
319    }
320
321    ST_Box::new(min_x, min_y, max_x - min_x, max_y - min_y)
322}
323
324/// 计算单个 TextCode 中关键字的边界框。
325///
326/// 对应 Java: `KeywordExtractor#getKeywordPosition` / `KeywordExtractor#getCtmKeywordPosition`
327///
328/// 从 TextCode 基准位置开始,沿 DeltaX/DeltaY 逐字符行走,记录关键字
329/// 字符区间内的最小/最大坐标,最终生成包含所有关键字字符的外接矩形。
330///
331/// 当 [`TextCodeEntry::ctm`] 存在时,对关键字区域的四个角应用仿射变换,
332/// 再合并为外接矩形——对应 Java 版 `getCtmKeywordPosition` 的行为。
333fn compute_keyword_box(
334    entry: &TextCodeEntry,
335    content_chars: &[char],
336    text_index: usize,
337    keyword_len: usize,
338) -> ST_Box {
339    let base_x = entry.x.unwrap_or(0.0);
340    let base_y = entry.y.unwrap_or(0.0);
341    let font_size = entry.font_size;
342
343    if keyword_len == 0 || content_chars.is_empty() || text_index >= content_chars.len() {
344        return ST_Box::new(
345            entry.boundary.top_left_x + base_x,
346            entry.boundary.top_left_y + base_y - font_size,
347            font_size,
348            font_size,
349        );
350    }
351
352    let delta_x = pad_delta(&entry.delta_x, content_chars.len());
353    let delta_y = pad_delta(&entry.delta_y, content_chars.len());
354
355    // ── CTM 分支:对应 Java `KeywordExtractor#getCtmKeywordPosition` ──
356    if let Some(matrix) = entry.ctm {
357        // 步骤 1:沿 Delta 行走到关键字起始位置(TextCode 局部坐标)
358        let mut x = base_x;
359        let mut y = base_y;
360        for i in 0..text_index {
361            if i < delta_x.len() {
362                x += delta_x[i];
363            }
364            if i < delta_y.len() {
365                y += delta_y[i];
366            }
367        }
368
369        // 步骤 2:计算关键字宽度
370        let string_width = get_string_width(text_index, keyword_len, &delta_x, font_size);
371        // 高度:用 font_size 近似(Java 用 strHeight 即 AWT FontRenderContext,
372        // Rust 无 AWT,用 font_size 作为合理近似)
373        let height = font_size;
374
375        // 步骤 3:对关键字区域的四个角应用 CTM 仿射变换
376        // 对应 Java: transform(matrix, x, y - height) 等
377        let left_top = ctm_transform(&matrix, x, y - height);
378        let left_bottom = ctm_transform(&matrix, x, y);
379        let right_top = ctm_transform(&matrix, x + string_width, y - height);
380        let right_bottom = ctm_transform(&matrix, x + string_width, y);
381
382        // 步骤 4:合并四个变换后的点为外接矩形
383        let mut ctm_box = merge_positions(&[left_top, left_bottom, right_top, right_bottom]);
384
385        // 步骤 5:偏移到 boundary 左上角(Java: ctmBox += ctText.getBoundary().getTopLeftPos())
386        ctm_box.top_left_x += entry.boundary.top_left_x;
387        ctm_box.top_left_y += entry.boundary.top_left_y;
388
389        return ctm_box;
390    }
391
392    // ── 非 CTM 分支:对应 Java `KeywordExtractor#getKeywordPosition` ──
393    let mut x = entry.boundary.top_left_x + base_x;
394    let mut y = entry.boundary.top_left_y + base_y;
395
396    let mut min_x = f64::INFINITY;
397    let mut min_y = f64::INFINITY;
398    let mut max_x = f64::NEG_INFINITY;
399    let mut max_y = f64::NEG_INFINITY;
400
401    let end = text_index + keyword_len;
402    for i in 0..end {
403        if i >= text_index {
404            min_x = min_x.min(x);
405            min_y = min_y.min(y);
406            max_x = max_x.max(x);
407            max_y = max_y.max(y);
408        }
409        if i < delta_x.len() {
410            x += delta_x[i];
411        }
412        if i < delta_y.len() {
413            y += delta_y[i];
414        }
415    }
416
417    let w = max_x - min_x + font_size;
418    let h = max_y - min_y + font_size;
419
420    ST_Box::new(min_x, min_y - font_size, w, h)
421}
422
423/// 计算跨多个 TextCode 的关键字合并边界框。
424///
425/// 对应 Java: `KeywordExtractor#mergeKeywordPosition`
426///
427/// 对合并列表中的每个 TextCode 计算其贡献的字符区间和边界框,
428/// 然后合并所有框为一个外接矩形。
429///
430/// 当 [`TextCodeEntry::ctm`] 存在时,对每个 TextCode 贡献的左下/右上角
431/// 应用仿射变换再合并——对应 Java 版 CTM 分支。
432fn compute_merged_box(
433    entries: &[TextCodeEntry],
434    merge_indices: &[usize],
435    first_start_index: usize,
436    keyword_len: usize,
437) -> ST_Box {
438    let mut boxes = Vec::new();
439    let mut total_length = 0;
440
441    for (idx, &entry_idx) in merge_indices.iter().enumerate() {
442        let entry = &entries[entry_idx];
443        let content_chars: Vec<char> = entry.content.chars().collect();
444        let content_len = content_chars.len();
445        let delta_x = pad_delta(&entry.delta_x, content_len);
446        let delta_y = pad_delta(&entry.delta_y, content_len);
447
448        // 对应 Java: 计算当前 TextCode 贡献的字符数和起始偏移
449        let (text_length, start_char) = if idx == 0 && first_start_index > 0 {
450            let tl = content_len.saturating_sub(first_start_index);
451            total_length = tl;
452            (tl, first_start_index)
453        } else if total_length + content_len > keyword_len {
454            (keyword_len - total_length, 0)
455        } else {
456            total_length += content_len;
457            (content_len, 0)
458        };
459
460        // 对应 Java: getStringWidth
461        let start_for_width = if idx == 0 && first_start_index > 0 {
462            first_start_index
463        } else {
464            0
465        };
466        let mut width = get_string_width(start_for_width, text_length, &delta_x, entry.font_size);
467        if width <= 0.0 {
468            width = entry.font_size;
469        }
470
471        let height = entry.font_size;
472
473        // ── CTM 分支:对应 Java `mergeKeywordPosition` 中 CTM 处理 ──
474        if let Some(matrix) = entry.ctm {
475            // TextCode 局部坐标
476            let mut x = entry.x.unwrap_or(0.0);
477            let mut y = entry.y.unwrap_or(0.0);
478            if idx == 0 && first_start_index > 0 {
479                for j in 0..first_start_index {
480                    if j < delta_x.len() {
481                        x += delta_x[j];
482                    }
483                    if j < delta_y.len() {
484                        y += delta_y[j];
485                    }
486                }
487            }
488            let left_bottom = ctm_transform(&matrix, x, y);
489            let right_top = ctm_transform(&matrix, x + width, y - height);
490
491            let mut ctm_box = merge_positions(&[left_bottom, right_top]);
492            ctm_box.top_left_x += entry.boundary.top_left_x;
493            ctm_box.top_left_y += entry.boundary.top_left_y;
494            boxes.push(ctm_box);
495        } else {
496            // ── 非 CTM 分支 ──
497            let (base_x, base_y) = get_base_xy(entry, &delta_x, &delta_y, start_char);
498            let box_ = ST_Box::new(base_x, base_y - height, width, height);
499            boxes.push(box_);
500        }
501    }
502
503    merge_boxes(&boxes)
504}
505
506// ── KeywordExtractor ────────────────────────────────────────────────────────
507
508/// 关键字抽取器。
509///
510/// 对应 Java: `org.ofdrw.reader.keyword.KeywordExtractor`
511///
512/// 在 OFD 文档的文本内容中搜索关键字,返回匹配位置的页面和矩形区域。
513///
514/// 注意:Java 版使用 AWT `FontRenderContext` 计算精确的字符边界,
515/// Rust 版使用简化的文本宽度估算。对于精确排版,需要集成外部字体引擎。
516#[derive(Debug)]
517pub struct KeywordExtractor;
518
519impl KeywordExtractor {
520    /// 获取关键字在文档中的位置列表。
521    ///
522    /// 对应 Java: `KeywordExtractor.getKeyWordPositionList(OFDReader, String)`
523    ///
524    /// 搜索所有页面的文本内容,返回包含关键字的矩形区域。
525    ///
526    /// # 参数
527    ///
528    /// - `pages`: 已解析的页面列表。
529    /// - `keyword`: 要搜索的关键字。
530    #[must_use]
531    pub fn get_keyword_positions(pages: &[OfdPage], keyword: &str) -> Vec<KeywordPosition> {
532        if keyword.is_empty() {
533            return Vec::new();
534        }
535
536        let mut positions = Vec::new();
537        for (page_idx, page) in pages.iter().enumerate() {
538            let page_num = page_idx + 1;
539            let page_positions = Self::search_page(page, page_num, keyword);
540            positions.extend(page_positions);
541        }
542        positions
543    }
544
545    /// 在单个页面中搜索关键字。
546    #[allow(clippy::cast_precision_loss)]
547    fn search_page(page: &OfdPage, page_num: usize, keyword: &str) -> Vec<KeywordPosition> {
548        use easyofd_core::ContentObject;
549
550        let mut positions = Vec::new();
551        for obj in &page.content {
552            if let ContentObject::Text(text_obj) = obj {
553                let text = &text_obj.text;
554                // 查找所有匹配位置
555                let mut start = 0;
556                while let Some(idx) = text[start..].find(keyword) {
557                    let match_start = start + idx;
558                    // 估算关键字在文本中的位置
559                    // 使用平均字符宽度近似(每个字符约 3mm 宽度作为默认值)
560                    let char_width = 3.0;
561                    let x = text_obj.x + (match_start as f64) * char_width;
562                    let y = text_obj.y;
563                    let kw_width = (keyword.len() as f64) * char_width;
564                    let kw_height = text_obj.size / POINT_PER_MM;
565
566                    let rect = easyofd_core::ST_Box::new(x, y, kw_width, kw_height);
567                    positions.push(KeywordPosition::new(page_num, rect).with_keyword(keyword));
568                    start = match_start + keyword.len();
569                }
570            }
571        }
572        positions
573    }
574
575    /// 获取关键字坐标列表(带字体度量的精确版本)。
576    ///
577    /// 对应 Java: `KeywordExtractor.getKeyWordPositionList(OFDReader, String)`
578    ///
579    /// 此方法接受字体大小映射表,用于更精确地计算字符宽度。
580    ///
581    /// # 参数
582    ///
583    /// - `pages`: 已解析的页面列表。
584    /// - `keyword`: 要搜索的关键字。
585    /// - `font_sizes`: 字体大小映射(字体 ID -> 字号,单位毫米)。
586    #[must_use]
587    #[allow(clippy::cast_precision_loss)]
588    pub fn get_keyword_positions_with_fonts(
589        pages: &[OfdPage],
590        keyword: &str,
591        font_sizes: &std::collections::HashMap<String, f64>,
592    ) -> Vec<KeywordPosition> {
593        if keyword.is_empty() {
594            return Vec::new();
595        }
596
597        let mut positions = Vec::new();
598        for (page_idx, page) in pages.iter().enumerate() {
599            let page_num = page_idx + 1;
600            for obj in &page.content {
601                if let easyofd_core::ContentObject::Text(text_obj) = obj {
602                    let text = &text_obj.text;
603                    let char_width = font_sizes
604                        .get(&text_obj.font)
605                        .map_or(3.0, |size| size / POINT_PER_MM * 0.6);
606
607                    let mut start = 0;
608                    while let Some(idx) = text[start..].find(keyword) {
609                        let match_start = start + idx;
610                        let x = text_obj.x + (match_start as f64) * char_width;
611                        let y = text_obj.y;
612                        let kw_width = (keyword.len() as f64) * char_width;
613                        let kw_height = text_obj.size / POINT_PER_MM;
614
615                        let rect = easyofd_core::ST_Box::new(x, y, kw_width, kw_height);
616                        positions.push(KeywordPosition::new(page_num, rect).with_keyword(keyword));
617                        start = match_start + keyword.len();
618                    }
619                }
620            }
621        }
622        positions
623    }
624
625    // ── 跨 TextCode 边界匹配 ────────────────────────────────────────────────
626
627    /// 从 TextCode 条目列表中搜索关键字位置(支持跨 TextCode 边界匹配)。
628    ///
629    /// 对应 Java: `KeywordExtractor.getKeyWordPositionList(OFDReader, String[], int[])`
630    ///
631    /// 此方法接受按阅读顺序排列的 TextCode 条目列表。当关键字被 TextCode
632    /// 边界切断时(如"电子印章"分属两个 TextCode),算法会拼接相邻 TextCode
633    /// 的文本内容来定位完整关键字。
634    ///
635    /// # 参数
636    ///
637    /// - `entries`: 按阅读顺序排列的 TextCode 条目列表。
638    /// - `keyword`: 要搜索的关键字。
639    ///
640    /// # 匹配模式
641    ///
642    /// 1. **普通匹配**:关键字完整包含在单个 TextCode 中。
643    /// 2. **前缀匹配**:当前 TextCode 的内容是关键字的前缀,向后拼接。
644    /// 3. **后缀匹配**:当前 TextCode 的末尾匹配关键字的开头,向后拼接。
645    #[must_use]
646    pub fn get_keyword_positions_from_text_codes(
647        entries: &[TextCodeEntry],
648        keyword: &str,
649    ) -> Vec<KeywordPosition> {
650        if keyword.is_empty() || entries.is_empty() {
651            return Vec::new();
652        }
653
654        let keyword_chars: Vec<char> = keyword.chars().collect();
655        let mut positions = Vec::new();
656
657        for i in 0..entries.len() {
658            let entry = &entries[i];
659            // 对应 Java: content == null || "".equals(content.trim()) → skip
660            if entry.content.trim().is_empty() {
661                continue;
662            }
663
664            let content_chars: Vec<char> = entry.content.chars().collect();
665
666            // 1. 普通匹配:关键字完整包含在当前 TextCode 中
667            // 对应 Java: content.indexOf(keyword) != -1
668            if let Some(text_index) = char_find_from(&content_chars, &keyword_chars, 0) {
669                Self::add_normal_keyword_entry(
670                    entry,
671                    keyword,
672                    &keyword_chars,
673                    text_index,
674                    &mut positions,
675                );
676                continue;
677            }
678
679            // 2. 前缀匹配:当前内容是关键字的前缀
680            // 对应 Java: keyword.indexOf(content) == 0 && i != textCodeList.size() - 1
681            if keyword_chars.starts_with(&content_chars) && i != entries.len() - 1 {
682                Self::add_prefix_break(entries, i, keyword, &keyword_chars, &mut positions);
683                continue;
684            }
685
686            // 3. 后缀匹配:当前内容末尾匹配关键字开头
687            // 对应 Java: checkPostfixMatch(content, keyword) != -1
688            if let Some(start_index) = check_postfix_match(&content_chars, &keyword_chars) {
689                Self::add_postfix_break(
690                    entries,
691                    i,
692                    start_index,
693                    keyword,
694                    &keyword_chars,
695                    &mut positions,
696                );
697            }
698        }
699
700        positions
701    }
702
703    /// 处理普通匹配(关键字在单个 TextCode 内)。
704    ///
705    /// 对应 Java: `KeywordExtractor#addNormalKeyword`
706    ///
707    /// 查找当前 TextCode 中关键字的所有出现位置,为每个位置计算边界框。
708    fn add_normal_keyword_entry(
709        entry: &TextCodeEntry,
710        keyword: &str,
711        keyword_chars: &[char],
712        first_text_index: usize,
713        positions: &mut Vec<KeywordPosition>,
714    ) {
715        let content_chars: Vec<char> = entry.content.chars().collect();
716        let mut text_index = first_text_index;
717
718        loop {
719            let rect = compute_keyword_box(entry, &content_chars, text_index, keyword_chars.len());
720            positions.push(KeywordPosition::new(entry.page, rect).with_keyword(keyword));
721
722            // 对应 Java: textIndex = content.indexOf(keyword, textIndex + keywordLength)
723            let next_start = text_index + keyword_chars.len();
724            match char_find_from(&content_chars, keyword_chars, next_start) {
725                Some(next_idx) => text_index = next_idx,
726                None => break,
727            }
728        }
729    }
730
731    /// 处理前缀匹配(当前内容是关键字前缀,需要向后拼接)。
732    ///
733    /// 对应 Java: `KeywordExtractor#addPrefixBreakTextCodeList`
734    fn add_prefix_break(
735        entries: &[TextCodeEntry],
736        start_index: usize,
737        keyword: &str,
738        keyword_chars: &[char],
739        positions: &mut Vec<KeywordPosition>,
740    ) {
741        let first_content = entries[start_index].content.clone();
742        let merge_indices = search_next_text(entries, start_index, keyword, &first_content);
743
744        // 对应 Java: 拼接所有合并的 TextCode 内容,检查是否包含关键字
745        let merged: String = merge_indices
746            .iter()
747            .map(|&idx| entries[idx].content.as_str())
748            .collect();
749
750        if merged.contains(keyword) {
751            let page = entries[start_index].page;
752            let rect = compute_merged_box(entries, &merge_indices, 0, keyword_chars.len());
753            positions.push(KeywordPosition::new(page, rect).with_keyword(keyword));
754        }
755    }
756
757    /// 处理后缀匹配(当前内容末尾匹配关键字开头)。
758    ///
759    /// 对应 Java: `KeywordExtractor#addPostfixBreakTextCodeList`
760    fn add_postfix_break(
761        entries: &[TextCodeEntry],
762        start_index: usize,
763        postfix_start: usize,
764        keyword: &str,
765        keyword_chars: &[char],
766        positions: &mut Vec<KeywordPosition>,
767    ) {
768        let first_content = &entries[start_index].content;
769        // 对应 Java: textCode.getContent().substring(startIndex)
770        let byte_start = char_to_byte_offset(first_content, postfix_start);
771        let first_match = &first_content[byte_start..];
772
773        let merge_indices = search_next_text(entries, start_index, keyword, first_match);
774
775        // 对应 Java: 拼接所有合并的 TextCode 的完整内容
776        let merged: String = merge_indices
777            .iter()
778            .map(|&idx| entries[idx].content.as_str())
779            .collect();
780
781        if merged.contains(keyword) {
782            let page = entries[start_index].page;
783            let rect =
784                compute_merged_box(entries, &merge_indices, postfix_start, keyword_chars.len());
785            positions.push(KeywordPosition::new(page, rect).with_keyword(keyword));
786        }
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use easyofd_core::TextObject;
794
795    fn make_page_with_text(text: &str) -> OfdPage {
796        let mut page = OfdPage::new(210.0, 297.0);
797        page.add_text(TextObject::new(10.0, 20.0, text).size(10.0));
798        page
799    }
800
801    // ── 现有测试(回归验证) ─────────────────────────────────────────────────
802
803    #[test]
804    fn test_keyword_extractor_single_match() {
805        let pages = vec![make_page_with_text("Hello World OFD Test")];
806        let positions = KeywordExtractor::get_keyword_positions(&pages, "OFD");
807        assert_eq!(positions.len(), 1);
808        assert_eq!(positions[0].page, 1);
809        assert_eq!(positions[0].keyword.as_deref(), Some("OFD"));
810    }
811
812    #[test]
813    fn test_keyword_extractor_multiple_matches() {
814        let pages = vec![make_page_with_text("OFD is an OFD format")];
815        let positions = KeywordExtractor::get_keyword_positions(&pages, "OFD");
816        assert_eq!(positions.len(), 2);
817    }
818
819    #[test]
820    fn test_keyword_extractor_no_match() {
821        let pages = vec![make_page_with_text("Hello World")];
822        let positions = KeywordExtractor::get_keyword_positions(&pages, "OFD");
823        assert!(positions.is_empty());
824    }
825
826    #[test]
827    fn test_keyword_extractor_empty_keyword() {
828        let pages = vec![make_page_with_text("Hello")];
829        let positions = KeywordExtractor::get_keyword_positions(&pages, "");
830        assert!(positions.is_empty());
831    }
832
833    #[test]
834    fn test_keyword_extractor_multiple_pages() {
835        let pages = vec![
836            make_page_with_text("Page 1"),
837            make_page_with_text("Page 2 OFD"),
838        ];
839        let positions = KeywordExtractor::get_keyword_positions(&pages, "OFD");
840        assert_eq!(positions.len(), 1);
841        assert_eq!(positions[0].page, 2);
842    }
843
844    #[test]
845    fn test_keyword_extractor_with_fonts() {
846        let pages = vec![make_page_with_text("Test OFD keyword")];
847        let mut font_sizes = std::collections::HashMap::new();
848        font_sizes.insert("SimHei".to_string(), 12.0);
849        let positions =
850            KeywordExtractor::get_keyword_positions_with_fonts(&pages, "OFD", &font_sizes);
851        assert_eq!(positions.len(), 1);
852    }
853
854    #[test]
855    fn test_point_per_mm_constant() {
856        assert!((POINT_PER_MM - 2.8346).abs() < 0.01);
857    }
858
859    // ── 跨 TextCode 测试 ────────────────────────────────────────────────────
860
861    /// 辅助函数:创建简单的 TextCode 条目。
862    fn make_entry(content: &str, page: usize, x: f64, y: f64, font_size: f64) -> TextCodeEntry {
863        TextCodeEntry::new(
864            content,
865            page,
866            ST_Box::new(0.0, 0.0, 210.0, 297.0),
867            font_size,
868        )
869        .coordinate(x, y)
870    }
871
872    #[test]
873    fn test_single_text_code_match() {
874        // 单 TextCode 内普通匹配(对齐简化模式行为)
875        let entries = vec![make_entry("Hello World OFD Test", 1, 10.0, 20.0, 3.0)];
876        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "OFD");
877        assert_eq!(positions.len(), 1);
878        assert_eq!(positions[0].page, 1);
879        assert_eq!(positions[0].keyword.as_deref(), Some("OFD"));
880    }
881
882    #[test]
883    fn test_cross_2_text_codes() {
884        // 关键字 "电子印章" 跨两个 TextCode: "电子" + "印章"
885        let entries = vec![
886            make_entry("电子", 1, 10.0, 20.0, 3.0),
887            make_entry("印章", 1, 30.0, 20.0, 3.0),
888        ];
889        let positions =
890            KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子印章");
891        assert_eq!(positions.len(), 1);
892        assert_eq!(positions[0].page, 1);
893        assert_eq!(positions[0].keyword.as_deref(), Some("电子印章"));
894        // 合并框应跨越两个 TextCode
895        assert!(positions[0].rect.width > 3.0);
896    }
897
898    #[test]
899    fn test_cross_3_text_codes() {
900        // 关键字 "中华人民共和国" 跨三个 TextCode
901        let entries = vec![
902            make_entry("中华", 1, 10.0, 20.0, 3.0),
903            make_entry("人民", 1, 30.0, 20.0, 3.0),
904            make_entry("共和国", 1, 50.0, 20.0, 3.0),
905        ];
906        let positions =
907            KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "中华人民共和国");
908        assert_eq!(positions.len(), 1);
909        assert_eq!(positions[0].keyword.as_deref(), Some("中华人民共和国"));
910    }
911
912    #[test]
913    fn test_with_delta_x() {
914        // DeltaX 用 g 压缩语法展开后的 TextCode(每个字符偏移 10mm)
915        let entries = vec![
916            TextCodeEntry::new("电子印章", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
917                .coordinate(10.0, 20.0)
918                .delta_x(vec![10.0, 10.0, 10.0]),
919        ];
920        let positions =
921            KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子印章");
922        assert_eq!(positions.len(), 1);
923        // 验证宽度计算:font_size + sum(delta_x[0..3]) = 3 + 30 = 33
924        assert!((positions[0].rect.width - 33.0).abs() < 0.01);
925    }
926
927    #[test]
928    fn test_with_delta_x_cross_text_codes() {
929        // 跨 TextCode 带 DeltaX
930        let entries = vec![
931            TextCodeEntry::new("电子", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
932                .coordinate(10.0, 20.0)
933                .delta_x(vec![10.0, 10.0]),
934            TextCodeEntry::new("印章", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
935                .coordinate(30.0, 20.0)
936                .delta_x(vec![10.0, 10.0]),
937        ];
938        let positions =
939            KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子印章");
940        assert_eq!(positions.len(), 1);
941        assert_eq!(positions[0].keyword.as_deref(), Some("电子印章"));
942        // 合并框宽度 > 单个 TextCode 宽度
943        assert!(positions[0].rect.width > 10.0);
944    }
945
946    #[test]
947    fn test_no_match() {
948        let entries = vec![make_entry("Hello World", 1, 10.0, 20.0, 3.0)];
949        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子");
950        assert!(positions.is_empty());
951    }
952
953    #[test]
954    fn test_empty_keyword_from_entries() {
955        let entries = vec![make_entry("Hello", 1, 10.0, 20.0, 3.0)];
956        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "");
957        assert!(positions.is_empty());
958    }
959
960    #[test]
961    fn test_empty_entries() {
962        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&[], "OFD");
963        assert!(positions.is_empty());
964    }
965
966    #[test]
967    fn test_postfix_match() {
968        // "abc电" 末尾的 "电" 是 "电子" 的前缀
969        let entries = vec![
970            make_entry("abc电", 1, 10.0, 20.0, 3.0),
971            make_entry("子印章", 1, 40.0, 20.0, 3.0),
972        ];
973        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子");
974        assert_eq!(positions.len(), 1);
975        assert_eq!(positions[0].keyword.as_deref(), Some("电子"));
976    }
977
978    #[test]
979    fn test_multiple_matches_same_entry() {
980        let entries = vec![make_entry("OFD是OFD格式", 1, 10.0, 20.0, 3.0)];
981        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "OFD");
982        assert_eq!(positions.len(), 2);
983    }
984
985    #[test]
986    fn test_cross_page_boundary_no_match() {
987        // 不同页的 TextCode 不应跨页匹配
988        let entries = vec![
989            make_entry("电子", 1, 10.0, 20.0, 3.0),
990            make_entry("印章", 2, 10.0, 20.0, 3.0),
991        ];
992        let positions =
993            KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子印章");
994        assert!(positions.is_empty());
995    }
996
997    #[test]
998    fn test_skip_empty_content() {
999        // 空白 TextCode 应被跳过,不影响匹配
1000        let entries = vec![
1001            make_entry("电子", 1, 10.0, 20.0, 3.0),
1002            make_entry("  ", 1, 20.0, 20.0, 3.0),
1003            make_entry("印章", 1, 30.0, 20.0, 3.0),
1004        ];
1005        let positions =
1006            KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子印章");
1007        assert_eq!(positions.len(), 1);
1008    }
1009
1010    #[test]
1011    fn test_postfix_with_delta() {
1012        // 后缀匹配 + DeltaX 验证坐标计算
1013        let entries = vec![
1014            TextCodeEntry::new("x电", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
1015                .coordinate(5.0, 20.0)
1016                .delta_x(vec![8.0, 8.0]),
1017            TextCodeEntry::new("子", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
1018                .coordinate(21.0, 20.0),
1019        ];
1020        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子");
1021        assert_eq!(positions.len(), 1);
1022        assert_eq!(positions[0].keyword.as_deref(), Some("电子"));
1023        // 验证合并框起始 X 坐标应包含 delta 偏移
1024        // 第一段起始: boundary.x + x + delta_x[0] = 0 + 5 + 8 = 13
1025        assert!((positions[0].rect.top_left_x - 13.0).abs() < 0.01);
1026    }
1027
1028    #[test]
1029    fn test_keyword_not_in_any_entry() {
1030        let entries = vec![
1031            make_entry("abc", 1, 10.0, 20.0, 3.0),
1032            make_entry("def", 1, 30.0, 20.0, 3.0),
1033        ];
1034        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "xyz");
1035        assert!(positions.is_empty());
1036    }
1037
1038    #[test]
1039    fn test_partial_prefix_no_completion() {
1040        // 前缀匹配但后续 TextCode 无法凑齐关键字
1041        let entries = vec![
1042            make_entry("电", 1, 10.0, 20.0, 3.0),
1043            make_entry("xxx", 1, 30.0, 20.0, 3.0),
1044        ];
1045        let positions =
1046            KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子印章");
1047        assert!(positions.is_empty());
1048    }
1049
1050    #[test]
1051    fn test_position_coordinates_basic() {
1052        // 验证单 TextCode 匹配的坐标计算
1053        let entries = vec![
1054            TextCodeEntry::new("AB", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
1055                .coordinate(10.0, 20.0)
1056                .delta_x(vec![10.0, 10.0]),
1057        ];
1058        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "AB");
1059        assert_eq!(positions.len(), 1);
1060        // 起始位置: boundary(0,0) + textcode(10,20) = (10, 20)
1061        // A 位置: x=10
1062        // B 位置: x=10+10=20 (delta_x[0]=10)
1063        // minX=10, maxX=20, width = 20-10+3 = 13
1064        assert!((positions[0].rect.width - 13.0).abs() < 0.01);
1065        // height = maxY-minY+font_size = 0+3 = 3
1066        assert!((positions[0].rect.height - 3.0).abs() < 0.01);
1067        // top_left_x = minX = 10
1068        assert!((positions[0].rect.top_left_x - 10.0).abs() < 0.01);
1069        // top_left_y = minY - font_size = 20 - 3 = 17
1070        assert!((positions[0].rect.top_left_y - 17.0).abs() < 0.01);
1071    }
1072
1073    // ── CTM 仿射变换测试 ────────────────────────────────────────────────────
1074
1075    #[test]
1076    fn test_ctm_transform_identity() {
1077        // 单位矩阵:变换后坐标不变
1078        let matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
1079        let (x, y) = ctm_transform(&matrix, 10.0, 20.0);
1080        assert!((x - 10.0).abs() < f64::EPSILON);
1081        assert!((y - 20.0).abs() < f64::EPSILON);
1082    }
1083
1084    #[test]
1085    fn test_ctm_transform_translation() {
1086        // 平移矩阵 [1 0 0 1 5 10]:x'=x+5, y'=y+10
1087        let matrix = [1.0, 0.0, 0.0, 1.0, 5.0, 10.0];
1088        let (x, y) = ctm_transform(&matrix, 0.0, 0.0);
1089        assert!((x - 5.0).abs() < f64::EPSILON);
1090        assert!((y - 10.0).abs() < f64::EPSILON);
1091    }
1092
1093    #[test]
1094    fn test_ctm_transform_90_rotation() {
1095        // 90 度逆时针旋转矩阵 [0 1 -1 0 0 0]
1096        // x' = 0*x + (-1)*y + 0 = -y
1097        // y' = 1*x + 0*y + 0 = x
1098        let matrix = [0.0, 1.0, -1.0, 0.0, 0.0, 0.0];
1099        let (x, y) = ctm_transform(&matrix, 3.0, 4.0);
1100        assert!((x - (-4.0)).abs() < f64::EPSILON);
1101        assert!((y - 3.0).abs() < f64::EPSILON);
1102    }
1103
1104    #[test]
1105    fn test_ctm_keyword_single_match() {
1106        // 对应 Java: KeywordExtractor#getCtmKeywordPosition
1107        // 单 TextCode 带 CTM(单位矩阵),结果应与无 CTM 一致
1108        let entries = vec![
1109            TextCodeEntry::new("OFD", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
1110                .coordinate(10.0, 20.0)
1111                .ctm([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
1112        ];
1113        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "OFD");
1114        assert_eq!(positions.len(), 1);
1115        assert_eq!(positions[0].keyword.as_deref(), Some("OFD"));
1116    }
1117
1118    #[test]
1119    fn test_ctm_keyword_90_rotation() {
1120        // 90 度旋转文本,关键字 "AB" 的边界框应反映旋转变换
1121        // CTM = [0 1 -1 0 0 0](90 度逆时针)
1122        // 原始: x=10, y=20, width=font_size(3.0), height=font_size(3.0)
1123        // 变换后:
1124        //   leftTop(10, 20-3)    -> (-(17), 10) = (-17, 10)
1125        //   leftBottom(10, 20)   -> (-20, 10)
1126        //   rightTop(13, 20-3)   -> (-17, 13)
1127        //   rightBottom(13, 20)  -> (-20, 13)
1128        // 合并: minX=-20, minY=10, maxX=-17, maxY=13
1129        // 最终: (-20, 10, 3, 3)
1130        let entries = vec![
1131            TextCodeEntry::new("AB", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
1132                .coordinate(10.0, 20.0)
1133                .ctm([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]),
1134        ];
1135        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "AB");
1136        assert_eq!(positions.len(), 1);
1137        assert_eq!(positions[0].keyword.as_deref(), Some("AB"));
1138        // 旋转后框尺寸: width=3, height=3
1139        assert!((positions[0].rect.width - 3.0).abs() < 0.01);
1140        assert!((positions[0].rect.height - 3.0).abs() < 0.01);
1141    }
1142
1143    #[test]
1144    fn test_ctm_keyword_with_translation() {
1145        // CTM 平移 [1 0 0 1 50 100]:所有坐标偏移 (+50, +100)
1146        // 原始: x=10, y=20, boundary(0,0)
1147        // 非 CTM 结果: top_left = (10, 20-3) = (10, 17)
1148        // CTM 结果: transform(10, 20) = (60, 120), transform(10, 17) = (60, 117)
1149        //   加 boundary 偏移后: (60, 117)
1150        let entries = vec![
1151            TextCodeEntry::new("AB", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
1152                .coordinate(10.0, 20.0)
1153                .ctm([1.0, 0.0, 0.0, 1.0, 50.0, 100.0]),
1154        ];
1155        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "AB");
1156        assert_eq!(positions.len(), 1);
1157        // 验证平移效果:框整体偏移了 (50, 100)
1158        assert!((positions[0].rect.top_left_x - 60.0).abs() < 0.01);
1159        assert!((positions[0].rect.top_left_y - 117.0).abs() < 0.01);
1160    }
1161
1162    #[test]
1163    fn test_ctm_keyword_cross_text_codes() {
1164        // 跨 TextCode 带 CTM(单位矩阵),验证合并框正常工作
1165        let entries = vec![
1166            TextCodeEntry::new("电", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
1167                .coordinate(10.0, 20.0)
1168                .ctm([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
1169            TextCodeEntry::new("子", 1, ST_Box::new(0.0, 0.0, 210.0, 297.0), 3.0)
1170                .coordinate(20.0, 20.0)
1171                .ctm([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
1172        ];
1173        let positions = KeywordExtractor::get_keyword_positions_from_text_codes(&entries, "电子");
1174        assert_eq!(positions.len(), 1);
1175        assert_eq!(positions[0].keyword.as_deref(), Some("电子"));
1176        // 单位矩阵下,合并框应与无 CTM 行为一致
1177        assert!(positions[0].rect.width > 3.0);
1178    }
1179}