1use lopdf::Document;
8
9use crate::models::bbox::BoundingBox;
10use crate::models::chunks::{ImageChunk, ImagePaintSource, TextChunk};
11use crate::models::enums::{PdfLayer, TextFormat, TextType};
12use crate::models::table::{
13 TableBorder, TableBorderCell, TableBorderRow, TableToken, TableTokenType,
14};
15use crate::pdf::image_extractor::extract_image_data;
16
17#[cfg(feature = "image")]
18use image::GrayImage;
19
20const MAX_NATIVE_TEXT_CHARS_IN_IMAGE: usize = 250;
21const MIN_LINE_DARK_RATIO: f64 = 0.28;
22const MIN_TRUE_GRID_LINE_CONTINUITY: f64 = 0.60;
23const MIN_BORDERED_VERTICAL_LINES: usize = 3;
24const MIN_BORDERED_HORIZONTAL_LINES: usize = 3;
25const MIN_BORDERED_INKED_CELL_RATIO: f64 = 0.18;
26const MIN_BORDERED_ROWS_WITH_INK: usize = 2;
27const RASTER_DARK_THRESHOLD: u8 = 180;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub enum RasterCandidateKind {
33 Table,
35 TextBlocks,
37}
38
39pub fn recover_embedded_raster_tables(
41 doc: &Document,
42 page_id: lopdf::ObjectId,
43 image_chunks: &[ImageChunk],
44 text_chunks: &[TextChunk],
45) -> Vec<TableBorder> {
46 #[cfg(not(feature = "image"))]
47 {
48 let _ = (doc, page_id, image_chunks, text_chunks);
49 Vec::new()
50 }
51 #[cfg(feature = "image")]
52 {
53 recover_embedded_raster_tables_image(doc, page_id, image_chunks, text_chunks)
54 }
55}
56
57#[cfg(feature = "image")]
58fn recover_embedded_raster_tables_image(
59 doc: &Document,
60 page_id: lopdf::ObjectId,
61 image_chunks: &[ImageChunk],
62 text_chunks: &[TextChunk],
63) -> Vec<TableBorder> {
64 let mut out = Vec::new();
65 for image in image_chunks {
66 if !is_table_image_candidate(image, text_chunks) {
67 continue;
68 }
69 let Some(raw_index) = image.index else {
70 continue;
71 };
72 let index = raw_index.max(1);
74 let Ok(Some(extracted)) = extract_image_data(doc, page_id, index) else {
75 continue;
76 };
77 if let Some(table) = table_from_extracted(
78 &extracted.data,
79 &extracted.filter,
80 extracted.width,
81 extracted.height,
82 image,
83 text_chunks,
84 ) {
85 if bordered_table_is_plausible(&table) {
86 out.push(table);
87 }
88 }
89 }
90 out
91}
92
93#[cfg(feature = "image")]
94fn is_table_image_candidate(image: &ImageChunk, text_chunks: &[TextChunk]) -> bool {
95 let w = image.bbox.width();
96 let h = image.bbox.height();
97 if w < 80.0 || h < 40.0 {
98 return false;
99 }
100 native_text_chars_in_image(image, text_chunks) <= MAX_NATIVE_TEXT_CHARS_IN_IMAGE
101}
102
103fn native_text_chars_in_image(image: &ImageChunk, text_chunks: &[TextChunk]) -> usize {
104 text_chunks
105 .iter()
106 .filter(|t| native_text_overlaps_image(t, image))
107 .map(|t| t.value.chars().filter(|c| !c.is_whitespace()).count())
108 .sum()
109}
110
111fn native_text_overlaps_image(text: &TextChunk, image: &ImageChunk) -> bool {
114 let cx = text.bbox.center_x();
116 let cy = text.bbox.center_y();
117 cx >= image.bbox.left_x
118 && cx <= image.bbox.right_x
119 && cy >= image.bbox.bottom_y
120 && cy <= image.bbox.top_y
121}
122
123fn is_full_page_image(image: &ImageChunk, page_width: f64, page_height: f64) -> bool {
127 if page_width <= 1.0 || page_height <= 1.0 {
128 return false;
129 }
130 const EPS: f64 = 1.0;
131 image.bbox.width() + EPS >= page_width && image.bbox.height() + EPS >= page_height
132}
133
134fn classify_raster_candidate(
145 image: &ImageChunk,
146 text_chunks: &[TextChunk],
147 page_width: f64,
148 page_height: f64,
149) -> Option<RasterCandidateKind> {
150 let sparse_native =
151 native_text_chars_in_image(image, text_chunks) <= MAX_NATIVE_TEXT_CHARS_IN_IMAGE;
152
153 if image.source == ImagePaintSource::PatternFill {
155 return Some(RasterCandidateKind::TextBlocks);
156 }
157
158 if is_full_page_image(image, page_width, page_height) && sparse_native {
160 return Some(RasterCandidateKind::TextBlocks);
161 }
162
163 if text_chunks.is_empty() && image.bbox.width() >= 80.0 && image.bbox.height() >= 40.0 {
165 return Some(RasterCandidateKind::TextBlocks);
166 }
167
168 if is_table_image_candidate(image, text_chunks) {
170 return Some(RasterCandidateKind::Table);
171 }
172
173 None
174}
175
176#[cfg(feature = "image")]
182#[derive(Debug, Clone)]
183pub struct RasterCandidate {
184 pub id: u32,
186 pub page: u32,
188 pub gray: Vec<u8>,
190 pub width: u32,
192 pub height: u32,
194 pub hash: String,
196 pub image_bbox: BoundingBox,
198 pub image_index: Option<u32>,
200 pub text_chunks: Vec<TextChunk>,
202 pub kind: RasterCandidateKind,
204 bordered_grid: Option<Grid>,
206}
207
208#[cfg(feature = "image")]
209impl RasterCandidate {
210 pub fn meta(&self) -> RasterCandidateMeta {
212 RasterCandidateMeta {
213 id: self.id,
214 page: self.page,
215 width: self.width,
216 height: self.height,
217 hash: self.hash.clone(),
218 kind: self.kind,
219 }
220 }
221}
222
223#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
225pub struct RasterCandidateMeta {
226 pub id: u32,
228 pub page: u32,
230 pub width: u32,
232 pub height: u32,
234 pub hash: String,
236 #[serde(default = "default_candidate_kind")]
238 pub kind: RasterCandidateKind,
239}
240
241fn default_candidate_kind() -> RasterCandidateKind {
242 RasterCandidateKind::Table
243}
244
245#[cfg(feature = "image")]
247pub fn collect_raster_candidates(
248 doc: &Document,
249 page_id: lopdf::ObjectId,
250 image_chunks: &[ImageChunk],
251 text_chunks: &[TextChunk],
252 page_width: f64,
253 page_height: f64,
254 next_id: &mut u32,
255) -> Vec<RasterCandidate> {
256 let mut out = Vec::new();
257 for image in image_chunks {
258 let Some(kind) = classify_raster_candidate(image, text_chunks, page_width, page_height)
259 else {
260 continue;
261 };
262 let Some(raw_index) = image.index else {
263 continue;
264 };
265 let index = raw_index.max(1);
266 let Ok(Some(extracted)) = extract_image_data(doc, page_id, index) else {
267 continue;
268 };
269 if let Some(cand) = prepare_candidate(
270 *next_id,
271 &extracted.data,
272 &extracted.filter,
273 extracted.width,
274 extracted.height,
275 image,
276 text_chunks,
277 kind,
278 ) {
279 *next_id = next_id.saturating_add(1);
280 out.push(cand);
281 }
282 }
283 out
284}
285
286#[cfg(not(feature = "image"))]
287pub fn collect_raster_candidates(
289 _doc: &Document,
290 _page_id: lopdf::ObjectId,
291 _image_chunks: &[ImageChunk],
292 _text_chunks: &[TextChunk],
293 _page_width: f64,
294 _page_height: f64,
295 _next_id: &mut u32,
296) -> Vec<()> {
297 Vec::new()
298}
299
300#[cfg(feature = "image")]
302#[allow(clippy::too_many_arguments)]
303pub fn prepare_candidate(
304 id: u32,
305 data: &[u8],
306 filter: &str,
307 width: u32,
308 height: u32,
309 image: &ImageChunk,
310 text_chunks: &[TextChunk],
311 kind: RasterCandidateKind,
312) -> Option<RasterCandidate> {
313 let gray = decode_to_gray(data, filter, width, height)?;
314 if is_obvious_bar_chart(&gray) {
315 return None;
316 }
317 let bordered = detect_bordered_grid(&gray).filter(|g| grid_has_cell_ink(&gray, g));
318 let kind = if bordered.is_some() {
321 RasterCandidateKind::Table
322 } else {
323 kind
324 };
325 let bordered = if kind == RasterCandidateKind::Table {
326 bordered
327 } else {
328 None
329 };
330 let hash = fnv1a_hex(gray.as_raw());
331 let overlapping: Vec<TextChunk> = text_chunks
332 .iter()
333 .filter(|t| native_text_overlaps_image(t, image))
334 .cloned()
335 .collect();
336 let (gw, gh) = (gray.width(), gray.height());
337 Some(RasterCandidate {
338 id,
339 page: image.bbox.page_number?,
340 gray: gray.into_raw(),
341 width: gw,
342 height: gh,
343 hash,
344 image_bbox: image.bbox.clone(),
345 image_index: image.index,
346 text_chunks: overlapping,
347 kind,
348 bordered_grid: bordered,
349 })
350}
351
352#[cfg(feature = "image")]
354pub fn build_table_from_candidate(
355 candidate: &RasterCandidate,
356 words: &[crate::pdf::ocr::OcrWord],
357) -> Option<TableBorder> {
358 if candidate.kind != RasterCandidateKind::Table {
359 return None;
360 }
361 let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
362 let mut words = words.to_vec();
363 let mut grid = candidate
364 .bordered_grid
365 .clone()
366 .or_else(|| detect_ocr_word_grid(&words, candidate.width, candidate.height));
367 if grid.is_none() {
368 if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
369 if let Some(g) = candidate
370 .bordered_grid
371 .clone()
372 .or_else(|| detect_ocr_word_grid(&ocrs_words, candidate.width, candidate.height))
373 {
374 words = ocrs_words;
375 grid = Some(g);
376 }
377 }
378 }
379 let grid = grid?;
380 build_table_cells(
381 &grid,
382 &gray,
383 &candidate.image_bbox,
384 &candidate.text_chunks,
385 &words,
386 )
387}
388
389#[cfg(feature = "image")]
394pub fn build_text_chunks_from_candidate(
395 candidate: &RasterCandidate,
396 words: &[crate::pdf::ocr::OcrWord],
397) -> Vec<TextChunk> {
398 ocr_words_to_text_chunks(candidate, words)
399}
400
401#[cfg(feature = "image")]
404pub fn ocr_words_to_text_chunks(
405 candidate: &RasterCandidate,
406 words: &[crate::pdf::ocr::OcrWord],
407) -> Vec<TextChunk> {
408 if words.is_empty() {
409 return Vec::new();
410 }
411 let img = &candidate.image_bbox;
412 let pw = candidate.width.max(1) as f64;
413 let ph = candidate.height.max(1) as f64;
414 let mut out = Vec::with_capacity(words.len());
415 for (i, w) in words.iter().enumerate() {
416 let text = w.text.trim();
417 if text.is_empty() {
418 continue;
419 }
420 let left = img.left_x + (w.left as f64 / pw) * img.width();
422 let right = img.left_x + ((w.left + w.width) as f64 / pw) * img.width();
423 let top = img.top_y - (w.top as f64 / ph) * img.height();
424 let bottom = img.top_y - ((w.top + w.height) as f64 / ph) * img.height();
425 let bbox = BoundingBox::new(
426 Some(candidate.page),
427 left.min(right),
428 bottom.min(top),
429 left.max(right),
430 bottom.max(top),
431 );
432 let cx = bbox.center_x();
433 let cy = bbox.center_y();
434 let covered_by_native = candidate.text_chunks.iter().any(|t| {
436 cx >= t.bbox.left_x
437 && cx <= t.bbox.right_x
438 && cy >= t.bbox.bottom_y
439 && cy <= t.bbox.top_y
440 });
441 if covered_by_native {
442 continue;
443 }
444
445 let font_size = bbox.height().clamp(6.0, 72.0);
446 out.push(TextChunk {
447 value: text.to_string(),
448 bbox: bbox.clone(),
449 font_name: "OCR".to_string(),
450 font_size,
451 font_weight: 400.0,
452 italic_angle: 0.0,
453 font_color: "#000000".to_string(),
454 contrast_ratio: 21.0,
455 symbol_ends: vec![bbox.right_x],
456 text_format: TextFormat::Normal,
457 text_type: TextType::Regular,
458 pdf_layer: PdfLayer::Content,
459 ocg_visible: true,
460 index: Some(i),
461 page_number: Some(candidate.page),
462 level: None,
463 mcid: None,
464 });
465 }
466 out
467}
468
469#[cfg(feature = "image")]
470fn table_from_extracted(
471 data: &[u8],
472 filter: &str,
473 width: u32,
474 height: u32,
475 image: &ImageChunk,
476 text_chunks: &[TextChunk],
477) -> Option<TableBorder> {
478 let candidate = prepare_candidate(
479 0,
480 data,
481 filter,
482 width,
483 height,
484 image,
485 text_chunks,
486 RasterCandidateKind::Table,
487 )?;
488 let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
489 let mut words = recognize_words(&gray);
490 let mut grid = candidate
491 .bordered_grid
492 .clone()
493 .or_else(|| detect_ocr_word_grid(&words, gray.width(), gray.height()));
494 if grid.is_none() {
497 if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
498 if let Some(g) = candidate
499 .bordered_grid
500 .clone()
501 .or_else(|| detect_ocr_word_grid(&ocrs_words, gray.width(), gray.height()))
502 {
503 words = ocrs_words;
504 grid = Some(g);
505 }
506 }
507 }
508 let grid = grid?;
509 build_table_cells(
510 &grid,
511 &gray,
512 &candidate.image_bbox,
513 &candidate.text_chunks,
514 &words,
515 )
516}
517
518#[cfg(feature = "image")]
519fn build_table_cells(
520 grid: &Grid,
521 gray: &GrayImage,
522 image_bbox: &BoundingBox,
523 text_chunks: &[TextChunk],
524 words: &[crate::pdf::ocr::OcrWord],
525) -> Option<TableBorder> {
526 let num_cols = grid.v.len().checked_sub(1)?;
527 let num_rows = grid.h.len().checked_sub(1)?;
528 if num_cols < 2 || num_rows < 2 {
529 return None;
530 }
531
532 let use_ocr = !words.is_empty();
533
534 let x_coords = map_boundaries(&grid.v, image_bbox.left_x, image_bbox.right_x, gray.width())?;
535 let y_coords = map_boundaries_desc(
536 &grid.h,
537 image_bbox.bottom_y,
538 image_bbox.top_y,
539 gray.height(),
540 )?;
541
542 let mut rows = Vec::with_capacity(num_rows);
543 let mut non_empty = 0usize;
544 for row_idx in 0..num_rows {
545 let mut cells = Vec::with_capacity(num_cols);
546 for col_idx in 0..num_cols {
547 let x1 = grid.v[col_idx];
548 let x2 = grid.v[col_idx + 1];
549 let y1 = grid.h[row_idx];
550 let y2 = grid.h[row_idx + 1];
551 let cell_bbox = BoundingBox::new(
552 image_bbox.page_number,
553 x_coords[col_idx],
554 y_coords[row_idx + 1],
555 x_coords[col_idx + 1],
556 y_coords[row_idx],
557 );
558 let (text, font_name) = if use_ocr {
559 (words_in_cell(words, x1, y1, x2, y2), "OCR")
560 } else {
561 (text_chunks_in_cell(text_chunks, &cell_bbox), "PDF")
562 };
563 let mut content = Vec::new();
564 if !text.is_empty() {
565 non_empty += 1;
566 content.push(TableToken {
567 base: TextChunk {
568 value: text,
569 bbox: cell_bbox.clone(),
570 font_name: font_name.into(),
571 font_size: (cell_bbox.height() * 0.55).max(6.0),
572 font_weight: if row_idx == 0 { 700.0 } else { 400.0 },
573 italic_angle: 0.0,
574 font_color: "#000000".into(),
575 contrast_ratio: 21.0,
576 symbol_ends: Vec::new(),
577 text_format: TextFormat::Normal,
578 text_type: TextType::Regular,
579 pdf_layer: PdfLayer::Content,
580 ocg_visible: true,
581 index: None,
582 page_number: image_bbox.page_number,
583 level: None,
584 mcid: None,
585 },
586 token_type: TableTokenType::Text,
587 });
588 }
589 cells.push(TableBorderCell {
590 bbox: cell_bbox,
591 index: None,
592 level: None,
593 row_number: row_idx,
594 col_number: col_idx,
595 row_span: 1,
596 col_span: 1,
597 content,
598 contents: Vec::new(),
599 semantic_type: None,
600 });
601 }
602 let row_bbox = BoundingBox::new(
603 image_bbox.page_number,
604 image_bbox.left_x,
605 y_coords[row_idx + 1],
606 image_bbox.right_x,
607 y_coords[row_idx],
608 );
609 rows.push(TableBorderRow {
610 bbox: row_bbox,
611 index: None,
612 level: None,
613 row_number: row_idx,
614 cells,
615 semantic_type: None,
616 });
617 }
618 if non_empty == 0 {
619 return None;
620 }
621
622 let table_bbox = BoundingBox::new(
623 image_bbox.page_number,
624 *x_coords.first()?,
625 *y_coords.last()?,
626 *x_coords.last()?,
627 *y_coords.first()?,
628 );
629 Some(TableBorder {
630 bbox: table_bbox,
631 index: None,
632 level: None,
633 x_coordinates: x_coords.clone(),
634 x_widths: vec![0.0; x_coords.len()],
635 y_coordinates: y_coords.clone(),
636 y_widths: vec![0.0; y_coords.len()],
637 rows,
638 num_rows,
639 num_columns: num_cols,
640 is_bad_table: false,
641 is_table_transformer: true,
642 previous_table: None,
643 next_table: None,
644 })
645}
646
647#[cfg(feature = "image")]
648fn fnv1a_hex(bytes: &[u8]) -> String {
649 let mut hash: u64 = 0xcbf29ce484222325;
650 for &b in bytes {
651 hash ^= u64::from(b);
652 hash = hash.wrapping_mul(0x100000001b3);
653 }
654 format!("{hash:016x}")
655}
656
657#[cfg(feature = "image")]
658fn recognize_words(gray: &GrayImage) -> Vec<crate::pdf::ocr::OcrWord> {
659 #[cfg(any(not(target_arch = "wasm32"), feature = "ocr-ocrs"))]
660 {
661 use crate::pdf::ocr::{default_engine, OcrEngine};
662 return OcrEngine::recognize(&*default_engine(), gray);
663 }
664 #[cfg(all(target_arch = "wasm32", not(feature = "ocr-ocrs")))]
665 {
666 let _ = gray;
667 Vec::new()
668 }
669}
670
671#[cfg(feature = "image")]
673fn recognize_words_ocrs_only(gray: &GrayImage) -> Option<Vec<crate::pdf::ocr::OcrWord>> {
674 #[cfg(feature = "ocr-ocrs")]
675 {
676 use crate::pdf::ocr::{OcrEngine, OcrsEngine};
677 return OcrsEngine::try_from_env().map(|e| e.recognize(gray));
678 }
679 #[cfg(not(feature = "ocr-ocrs"))]
680 {
681 let _ = gray;
682 None
683 }
684}
685
686#[cfg(feature = "image")]
687fn decode_to_gray(data: &[u8], filter: &str, width: u32, height: u32) -> Option<GrayImage> {
688 crate::pdf::image_codecs::decode_image_to_gray(data, filter, width, height).map(|d| d.gray)
689}
690
691#[cfg(feature = "image")]
692#[derive(Debug, Clone)]
693struct Grid {
694 v: Vec<u32>,
695 h: Vec<u32>,
696}
697
698#[cfg(feature = "image")]
699fn detect_bordered_grid(gray: &GrayImage) -> Option<Grid> {
700 let width = gray.width();
701 let height = gray.height();
702 if width < 100 || height < 80 {
703 return None;
704 }
705 let min_v = (f64::from(height) * MIN_LINE_DARK_RATIO).ceil() as u32;
706 let min_h = (f64::from(width) * MIN_LINE_DARK_RATIO).ceil() as u32;
707 let mut vertical: Vec<u32> =
708 merge_runs((0..width).filter(|&x| count_dark_col(gray, x) >= min_v))
709 .into_iter()
710 .map(|(a, b)| (a + b) / 2)
711 .collect();
712 let mut horizontal: Vec<u32> =
713 merge_runs((0..height).filter(|&y| count_dark_row(gray, y) >= min_h))
714 .into_iter()
715 .map(|(a, b)| (a + b) / 2)
716 .collect();
717 if vertical.len() < MIN_BORDERED_VERTICAL_LINES
718 || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
719 {
720 return None;
721 }
722 let (&min_x, &max_x) = vertical.first().zip(vertical.last())?;
723 let (&min_y, &max_y) = horizontal.first().zip(horizontal.last())?;
724 vertical.retain(|&x| dark_ratio_col(gray, x, min_y, max_y) >= MIN_TRUE_GRID_LINE_CONTINUITY);
725 horizontal.retain(|&y| dark_ratio_row(gray, y, min_x, max_x) >= MIN_TRUE_GRID_LINE_CONTINUITY);
726 if vertical.len() < MIN_BORDERED_VERTICAL_LINES
727 || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
728 {
729 return None;
730 }
731 Some(Grid {
732 v: vertical,
733 h: horizontal,
734 })
735}
736
737#[cfg(feature = "image")]
739fn detect_ocr_word_grid(
740 words: &[crate::pdf::ocr::OcrWord],
741 width: u32,
742 height: u32,
743) -> Option<Grid> {
744 if words.len() < 8 || width < 80 || height < 60 {
745 return None;
746 }
747 let mut heights: Vec<u32> = words.iter().map(|w| w.height.max(1)).collect();
748 heights.sort_unstable();
749 let med_h = heights[heights.len() / 2].max(6);
750 let y_gap = (med_h as f64 * 0.85).round() as u32;
751
752 let mut xs: Vec<u32> = words
753 .iter()
754 .filter(|w| w.width < width.saturating_mul(45) / 100 || w.width < 80)
756 .map(|w| w.left + w.width / 2)
757 .collect();
758 if xs.len() < 6 {
759 xs = words.iter().map(|w| w.left + w.width / 2).collect();
760 }
761 let mut ys: Vec<u32> = words.iter().map(|w| w.top + w.height / 2).collect();
762 xs.sort_unstable();
763 ys.sort_unstable();
764 let x_gap = adaptive_col_gap(&xs, med_h);
767 let col_centers = cluster_1d_centers(&xs, x_gap);
768 let row_centers = cluster_1d_centers(&ys, y_gap);
769 if col_centers.len() < 2 || row_centers.len() < 3 {
770 return None;
771 }
772 let occupied = count_occupied_cells(words, &col_centers, &row_centers, x_gap, y_gap);
774 let cells = col_centers.len() * row_centers.len();
775 if occupied < 6 || (occupied as f64) < 0.35 * cells as f64 {
776 return None;
777 }
778
779 let v = centers_to_boundaries(&col_centers, 0, width);
780 let h = centers_to_boundaries(&row_centers, 0, height);
781 if v.len() < 3 || h.len() < 4 {
782 return None;
783 }
784 Some(Grid { v, h })
785}
786
787#[cfg(feature = "image")]
789fn adaptive_col_gap(xs_sorted: &[u32], med_h: u32) -> u32 {
790 let mut u = xs_sorted.to_vec();
791 u.dedup();
792 if u.len() < 3 {
793 return (med_h as f64 * 2.5).round().max(12.0) as u32;
794 }
795 let mut diffs: Vec<u32> = u
796 .windows(2)
797 .map(|w| w[1] - w[0])
798 .filter(|&d| d > 1)
799 .collect();
800 if diffs.is_empty() {
801 return (med_h as f64 * 2.5).round().max(12.0) as u32;
802 }
803 diffs.sort_unstable();
804 let med = diffs[diffs.len() / 2].max(1);
805 ((med as f64) * 1.35)
806 .round()
807 .max((med_h as f64) * 2.0)
808 .max(16.0) as u32
809}
810
811#[cfg(feature = "image")]
812fn cluster_1d_centers(sorted: &[u32], gap: u32) -> Vec<u32> {
813 if sorted.is_empty() {
814 return Vec::new();
815 }
816 let mut clusters: Vec<Vec<u32>> = vec![vec![sorted[0]]];
817 for &x in &sorted[1..] {
818 let last = clusters.last_mut().unwrap();
819 if x.saturating_sub(*last.last().unwrap()) <= gap {
820 last.push(x);
821 } else {
822 clusters.push(vec![x]);
823 }
824 }
825 clusters
826 .into_iter()
827 .map(|c| c.iter().sum::<u32>() / c.len() as u32)
828 .collect()
829}
830
831#[cfg(feature = "image")]
832fn centers_to_boundaries(centers: &[u32], min_edge: u32, max_edge: u32) -> Vec<u32> {
833 if centers.is_empty() {
834 return vec![min_edge, max_edge];
835 }
836 let mut b = Vec::with_capacity(centers.len() + 1);
837 b.push(min_edge);
838 for i in 0..centers.len() - 1 {
839 b.push((centers[i] + centers[i + 1]) / 2);
840 }
841 b.push(max_edge.max(min_edge + 1));
842 for i in 1..b.len() {
844 if b[i] <= b[i - 1] {
845 b[i] = b[i - 1] + 1;
846 }
847 }
848 b
849}
850
851#[cfg(feature = "image")]
852fn count_occupied_cells(
853 words: &[crate::pdf::ocr::OcrWord],
854 cols: &[u32],
855 rows: &[u32],
856 x_gap: u32,
857 y_gap: u32,
858) -> usize {
859 let mut seen = std::collections::HashSet::new();
860 for w in words {
861 let cx = w.left + w.width / 2;
862 let cy = w.top + w.height / 2;
863 let Some(ci) = cols
864 .iter()
865 .position(|&c| cx.abs_diff(c) <= x_gap.saturating_mul(2))
866 else {
867 continue;
868 };
869 let Some(ri) = rows
870 .iter()
871 .position(|&r| cy.abs_diff(r) <= y_gap.saturating_mul(2))
872 else {
873 continue;
874 };
875 seen.insert((ri, ci));
876 }
877 seen.len()
878}
879
880#[cfg(feature = "image")]
881fn grid_has_cell_ink(gray: &GrayImage, grid: &Grid) -> bool {
882 let cols = grid.v.len().saturating_sub(1);
883 let rows = grid.h.len().saturating_sub(1);
884 if cols == 0 || rows == 0 {
885 return false;
886 }
887 let mut inked = 0usize;
888 let mut rows_with = 0usize;
889 let total = cols * rows;
890 for r in 0..rows {
891 let mut row_ink = false;
892 for c in 0..cols {
893 if cell_dark_ratio(gray, grid.v[c], grid.h[r], grid.v[c + 1], grid.h[r + 1]) >= 0.03 {
894 inked += 1;
895 row_ink = true;
896 }
897 }
898 if row_ink {
899 rows_with += 1;
900 }
901 }
902 (inked as f64 / total as f64) >= MIN_BORDERED_INKED_CELL_RATIO
903 && rows_with >= MIN_BORDERED_ROWS_WITH_INK
904}
905
906#[cfg(feature = "image")]
907fn is_obvious_bar_chart(gray: &GrayImage) -> bool {
908 if detect_bordered_grid(gray).is_some() {
909 return false;
910 }
911 let w = gray.width();
912 let h = gray.height();
913 if w < 120 || h < 80 {
914 return false;
915 }
916 let mut peaks = 0usize;
917 let mut run = 0u32;
918 let thresh = (h as f64 * 0.35) as u32;
919 for x in 0..w {
920 let dark = count_dark_col(gray, x);
921 if dark >= thresh {
922 run += 1;
923 } else if run > 0 {
924 if run >= 3 {
925 peaks += 1;
926 }
927 run = 0;
928 }
929 }
930 peaks >= 4
931}
932
933#[cfg(feature = "image")]
934fn count_dark_col(gray: &GrayImage, x: u32) -> u32 {
935 (0..gray.height())
936 .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
937 .count() as u32
938}
939
940#[cfg(feature = "image")]
941fn count_dark_row(gray: &GrayImage, y: u32) -> u32 {
942 (0..gray.width())
943 .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
944 .count() as u32
945}
946
947#[cfg(feature = "image")]
948fn dark_ratio_col(gray: &GrayImage, x: u32, y1: u32, y2: u32) -> f64 {
949 let h = y2.saturating_sub(y1).max(1);
950 let dark = (y1..y2)
951 .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
952 .count() as f64;
953 dark / f64::from(h)
954}
955
956#[cfg(feature = "image")]
957fn dark_ratio_row(gray: &GrayImage, y: u32, x1: u32, x2: u32) -> f64 {
958 let w = x2.saturating_sub(x1).max(1);
959 let dark = (x1..x2)
960 .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
961 .count() as f64;
962 dark / f64::from(w)
963}
964
965#[cfg(feature = "image")]
966fn cell_dark_ratio(gray: &GrayImage, x1: u32, y1: u32, x2: u32, y2: u32) -> f64 {
967 let mut dark = 0u32;
968 let mut total = 0u32;
969 for y in y1..y2 {
970 for x in x1..x2 {
971 total += 1;
972 if gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD {
973 dark += 1;
974 }
975 }
976 }
977 if total == 0 {
978 0.0
979 } else {
980 f64::from(dark) / f64::from(total)
981 }
982}
983
984#[cfg(feature = "image")]
985fn merge_runs(xs: impl Iterator<Item = u32>) -> Vec<(u32, u32)> {
986 let mut out = Vec::new();
987 let mut cur: Option<(u32, u32)> = None;
988 for x in xs {
989 cur = match cur {
990 None => Some((x, x)),
991 Some((a, b)) if x <= b + 2 => Some((a, x)),
992 Some(r) => {
993 out.push(r);
994 Some((x, x))
995 }
996 };
997 }
998 if let Some(r) = cur {
999 out.push(r);
1000 }
1001 out
1002}
1003
1004#[cfg(feature = "image")]
1005fn map_boundaries(lines: &[u32], page_a: f64, page_b: f64, raster_span: u32) -> Option<Vec<f64>> {
1006 if raster_span == 0 || lines.is_empty() {
1007 return None;
1008 }
1009 let span = page_b - page_a;
1010 Some(
1011 lines
1012 .iter()
1013 .map(|&p| page_a + span * (f64::from(p) / f64::from(raster_span)))
1014 .collect(),
1015 )
1016}
1017
1018#[cfg(feature = "image")]
1019fn map_boundaries_desc(
1020 lines: &[u32],
1021 page_bottom: f64,
1022 page_top: f64,
1023 raster_span: u32,
1024) -> Option<Vec<f64>> {
1025 if raster_span == 0 || lines.is_empty() {
1026 return None;
1027 }
1028 let span = page_top - page_bottom;
1029 Some(
1030 lines
1031 .iter()
1032 .map(|&p| page_top - span * (f64::from(p) / f64::from(raster_span)))
1033 .collect(),
1034 )
1035}
1036
1037#[cfg(feature = "image")]
1038fn words_in_cell(words: &[crate::pdf::ocr::OcrWord], x1: u32, y1: u32, x2: u32, y2: u32) -> String {
1039 let mut hit: Vec<&crate::pdf::ocr::OcrWord> = words
1040 .iter()
1041 .filter(|w| {
1042 let cx = w.left + w.width / 2;
1043 let cy = w.top + w.height / 2;
1044 cx >= x1 && cx < x2 && cy >= y1 && cy < y2
1045 })
1046 .collect();
1047 hit.sort_by_key(|a| (a.top, a.left));
1048 hit.iter()
1049 .map(|w| w.text.as_str())
1050 .collect::<Vec<_>>()
1051 .join(" ")
1052}
1053
1054#[cfg(feature = "image")]
1056fn text_chunks_in_cell(text_chunks: &[TextChunk], cell: &BoundingBox) -> String {
1057 let mut hit: Vec<&TextChunk> = text_chunks
1058 .iter()
1059 .filter(|t| {
1060 if t.value.trim().is_empty() {
1061 return false;
1062 }
1063 let cx = (t.bbox.left_x + t.bbox.right_x) * 0.5;
1064 let cy = (t.bbox.bottom_y + t.bbox.top_y) * 0.5;
1065 cx >= cell.left_x && cx <= cell.right_x && cy >= cell.bottom_y && cy <= cell.top_y
1066 })
1067 .collect();
1068 hit.sort_by(|a, b| {
1069 b.bbox
1070 .top_y
1071 .partial_cmp(&a.bbox.top_y)
1072 .unwrap_or(std::cmp::Ordering::Equal)
1073 .then_with(|| {
1074 a.bbox
1075 .left_x
1076 .partial_cmp(&b.bbox.left_x)
1077 .unwrap_or(std::cmp::Ordering::Equal)
1078 })
1079 });
1080 hit.iter()
1081 .map(|t| t.value.trim())
1082 .filter(|s| !s.is_empty())
1083 .collect::<Vec<_>>()
1084 .join(" ")
1085}
1086
1087#[cfg(feature = "image")]
1088pub fn bordered_table_is_plausible(table: &TableBorder) -> bool {
1090 if table.num_rows < 2 || table.num_columns < 2 {
1091 return false;
1092 }
1093 let mut token_counts = Vec::new();
1094 for row in &table.rows {
1095 for cell in &row.cells {
1096 let n = cell
1097 .content
1098 .iter()
1099 .map(|t| t.base.value.split_whitespace().count())
1100 .sum::<usize>();
1101 if n > 0 {
1102 token_counts.push(n);
1103 }
1104 }
1105 }
1106 if token_counts.is_empty() {
1107 return false;
1108 }
1109 let mut sorted = token_counts.clone();
1110 sorted.sort_unstable();
1111 let median = sorted[sorted.len() / 2].max(1);
1112 let max = *token_counts.iter().max().unwrap_or(&0);
1113 !(max >= 8 && max >= median.saturating_mul(8))
1114}
1115
1116#[cfg(all(test, feature = "image"))]
1117mod classify_tests {
1118 use super::*;
1119 use crate::models::chunks::ImagePaintSource;
1120 use crate::models::enums::{PdfLayer, TextFormat, TextType};
1121
1122 fn img(source: ImagePaintSource, w: f64, h: f64) -> ImageChunk {
1123 ImageChunk {
1124 bbox: BoundingBox::new(Some(1), 0.0, 0.0, w, h),
1125 index: Some(1),
1126 level: None,
1127 source,
1128 }
1129 }
1130
1131 #[test]
1132 fn full_bleed_do_image_is_text_blocks() {
1133 let image = img(ImagePaintSource::Do, 959.76, 539.41);
1134 let kind = classify_raster_candidate(&image, &[], 959.76, 540.0);
1135 assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1136 assert!(is_full_page_image(&image, 959.76, 540.0));
1137 }
1138
1139 #[test]
1140 fn pattern_fill_is_text_blocks() {
1141 let image = img(ImagePaintSource::PatternFill, 400.0, 300.0);
1142 let kind = classify_raster_candidate(&image, &[], 400.0, 300.0);
1143 assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1144 }
1145
1146 #[test]
1147 fn small_embedded_do_with_sparse_text_is_table() {
1148 let image = img(ImagePaintSource::Do, 200.0, 100.0);
1149 let kind = classify_raster_candidate(&image, &[], 612.0, 792.0);
1150 assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1152
1153 let outside = TextChunk {
1155 value: "Caption outside".into(),
1156 bbox: BoundingBox::new(Some(1), 0.0, 700.0, 200.0, 720.0),
1157 font_name: "Helvetica".into(),
1158 font_size: 12.0,
1159 font_weight: 400.0,
1160 italic_angle: 0.0,
1161 font_color: "#000".into(),
1162 contrast_ratio: 21.0,
1163 symbol_ends: vec![],
1164 text_format: TextFormat::Normal,
1165 text_type: TextType::Regular,
1166 pdf_layer: PdfLayer::Main,
1167 ocg_visible: true,
1168 index: None,
1169 page_number: Some(1),
1170 level: None,
1171 mcid: None,
1172 };
1173 let kind = classify_raster_candidate(&image, &[outside], 612.0, 792.0);
1174 assert_eq!(kind, Some(RasterCandidateKind::Table));
1175 }
1176}