1use super::KeywordPosition;
22use easyofd_core::{OfdPage, ST_Box};
23
24const POINT_PER_MM: f64 = 72.0 / 25.4;
26
27#[derive(Debug, Clone)]
35pub struct TextCodeEntry {
36 pub content: String,
38 pub x: Option<f64>,
40 pub y: Option<f64>,
42 pub delta_x: Vec<f64>,
44 pub delta_y: Vec<f64>,
46 pub page: usize,
48 pub boundary: ST_Box,
50 pub font_size: f64,
52 pub ctm: Option<[f64; 6]>,
60}
61
62impl TextCodeEntry {
63 #[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 #[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 #[must_use]
89 pub fn delta_x(mut self, deltas: Vec<f64>) -> Self {
90 self.delta_x = deltas;
91 self
92 }
93
94 #[must_use]
96 pub fn delta_y(mut self, deltas: Vec<f64>) -> Self {
97 self.delta_y = deltas;
98 self
99 }
100
101 #[must_use]
109 pub fn ctm(mut self, ctm: [f64; 6]) -> Self {
110 self.ctm = Some(ctm);
111 self
112 }
113}
114
115fn 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
124fn 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
141fn 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
157fn 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 let start_index = content_chars.iter().rposition(|&c| c == first_char)?;
171
172 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
182fn 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 if next.content.trim().is_empty() {
203 continue;
204 }
205 if next.page != current_page {
207 break;
208 }
209
210 merge_text.push_str(&next.content);
211
212 if merge_text == keyword || merge_text.starts_with(keyword) {
214 merge_indices.push(j);
215 break;
216 }
217 if keyword.starts_with(&merge_text) {
219 merge_indices.push(j);
220 } else {
221 break;
222 }
223 }
224
225 merge_indices
226}
227
228fn 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
250fn 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
268fn 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
290fn 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
305fn 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
324fn 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 if let Some(matrix) = entry.ctm {
357 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 let string_width = get_string_width(text_index, keyword_len, &delta_x, font_size);
371 let height = font_size;
374
375 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 let mut ctm_box = merge_positions(&[left_top, left_bottom, right_top, right_bottom]);
384
385 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 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
423fn 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 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 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 if let Some(matrix) = entry.ctm {
475 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 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#[derive(Debug)]
517pub struct KeywordExtractor;
518
519impl KeywordExtractor {
520 #[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 #[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 let mut start = 0;
556 while let Some(idx) = text[start..].find(keyword) {
557 let match_start = start + idx;
558 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 #[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 #[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 if entry.content.trim().is_empty() {
661 continue;
662 }
663
664 let content_chars: Vec<char> = entry.content.chars().collect();
665
666 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 assert!(positions[0].rect.width > 3.0);
896 }
897
898 #[test]
899 fn test_cross_3_text_codes() {
900 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 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 assert!((positions[0].rect.width - 33.0).abs() < 0.01);
925 }
926
927 #[test]
928 fn test_with_delta_x_cross_text_codes() {
929 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 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 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 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 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 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 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 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 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 assert!((positions[0].rect.width - 13.0).abs() < 0.01);
1065 assert!((positions[0].rect.height - 3.0).abs() < 0.01);
1067 assert!((positions[0].rect.top_left_x - 10.0).abs() < 0.01);
1069 assert!((positions[0].rect.top_left_y - 17.0).abs() < 0.01);
1071 }
1072
1073 #[test]
1076 fn test_ctm_transform_identity() {
1077 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 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 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 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 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 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 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 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 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 assert!(positions[0].rect.width > 3.0);
1178 }
1179}