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")]
302pub fn prepare_candidate(
303 id: u32,
304 data: &[u8],
305 filter: &str,
306 width: u32,
307 height: u32,
308 image: &ImageChunk,
309 text_chunks: &[TextChunk],
310 kind: RasterCandidateKind,
311) -> Option<RasterCandidate> {
312 let gray = decode_to_gray(data, filter, width, height)?;
313 if is_obvious_bar_chart(&gray) {
314 return None;
315 }
316 let bordered = detect_bordered_grid(&gray).filter(|g| grid_has_cell_ink(&gray, g));
317 let kind = if bordered.is_some() {
320 RasterCandidateKind::Table
321 } else {
322 kind
323 };
324 let bordered = if kind == RasterCandidateKind::Table {
325 bordered
326 } else {
327 None
328 };
329 let hash = fnv1a_hex(gray.as_raw());
330 let overlapping: Vec<TextChunk> = text_chunks
331 .iter()
332 .filter(|t| native_text_overlaps_image(t, image))
333 .cloned()
334 .collect();
335 let (gw, gh) = (gray.width(), gray.height());
336 Some(RasterCandidate {
337 id,
338 page: image.bbox.page_number?,
339 gray: gray.into_raw(),
340 width: gw,
341 height: gh,
342 hash,
343 image_bbox: image.bbox.clone(),
344 image_index: image.index,
345 text_chunks: overlapping,
346 kind,
347 bordered_grid: bordered,
348 })
349}
350
351#[cfg(feature = "image")]
353pub fn build_table_from_candidate(
354 candidate: &RasterCandidate,
355 words: &[crate::pdf::ocr::OcrWord],
356) -> Option<TableBorder> {
357 if candidate.kind != RasterCandidateKind::Table {
358 return None;
359 }
360 let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
361 let mut words = words.to_vec();
362 let mut grid = candidate
363 .bordered_grid
364 .clone()
365 .or_else(|| detect_ocr_word_grid(&words, candidate.width, candidate.height));
366 if grid.is_none() {
367 if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
368 if let Some(g) = candidate
369 .bordered_grid
370 .clone()
371 .or_else(|| detect_ocr_word_grid(&ocrs_words, candidate.width, candidate.height))
372 {
373 words = ocrs_words;
374 grid = Some(g);
375 }
376 }
377 }
378 let grid = grid?;
379 build_table_cells(
380 &grid,
381 &gray,
382 &candidate.image_bbox,
383 &candidate.text_chunks,
384 &words,
385 )
386}
387
388#[cfg(feature = "image")]
393pub fn build_text_chunks_from_candidate(
394 candidate: &RasterCandidate,
395 words: &[crate::pdf::ocr::OcrWord],
396) -> Vec<TextChunk> {
397 ocr_words_to_text_chunks(candidate, words)
398}
399
400#[cfg(feature = "image")]
403pub fn ocr_words_to_text_chunks(
404 candidate: &RasterCandidate,
405 words: &[crate::pdf::ocr::OcrWord],
406) -> Vec<TextChunk> {
407 if words.is_empty() {
408 return Vec::new();
409 }
410 let img = &candidate.image_bbox;
411 let pw = candidate.width.max(1) as f64;
412 let ph = candidate.height.max(1) as f64;
413 let mut out = Vec::with_capacity(words.len());
414 for (i, w) in words.iter().enumerate() {
415 let text = w.text.trim();
416 if text.is_empty() {
417 continue;
418 }
419 let left = img.left_x + (w.left as f64 / pw) * img.width();
421 let right = img.left_x + ((w.left + w.width) as f64 / pw) * img.width();
422 let top = img.top_y - (w.top as f64 / ph) * img.height();
423 let bottom = img.top_y - ((w.top + w.height) as f64 / ph) * img.height();
424 let bbox = BoundingBox::new(
425 Some(candidate.page),
426 left.min(right),
427 bottom.min(top),
428 left.max(right),
429 bottom.max(top),
430 );
431 let cx = bbox.center_x();
432 let cy = bbox.center_y();
433 let covered_by_native = candidate.text_chunks.iter().any(|t| {
435 cx >= t.bbox.left_x
436 && cx <= t.bbox.right_x
437 && cy >= t.bbox.bottom_y
438 && cy <= t.bbox.top_y
439 });
440 if covered_by_native {
441 continue;
442 }
443
444 let font_size = bbox.height().clamp(6.0, 72.0);
445 out.push(TextChunk {
446 value: text.to_string(),
447 bbox: bbox.clone(),
448 font_name: "OCR".to_string(),
449 font_size,
450 font_weight: 400.0,
451 italic_angle: 0.0,
452 font_color: "#000000".to_string(),
453 contrast_ratio: 21.0,
454 symbol_ends: vec![bbox.right_x],
455 text_format: TextFormat::Normal,
456 text_type: TextType::Regular,
457 pdf_layer: PdfLayer::Content,
458 ocg_visible: true,
459 index: Some(i),
460 page_number: Some(candidate.page),
461 level: None,
462 mcid: None,
463 });
464 }
465 out
466}
467
468#[cfg(feature = "image")]
469fn table_from_extracted(
470 data: &[u8],
471 filter: &str,
472 width: u32,
473 height: u32,
474 image: &ImageChunk,
475 text_chunks: &[TextChunk],
476) -> Option<TableBorder> {
477 let candidate = prepare_candidate(
478 0,
479 data,
480 filter,
481 width,
482 height,
483 image,
484 text_chunks,
485 RasterCandidateKind::Table,
486 )?;
487 let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
488 let mut words = recognize_words(&gray);
489 let mut grid = candidate
490 .bordered_grid
491 .clone()
492 .or_else(|| detect_ocr_word_grid(&words, gray.width(), gray.height()));
493 if grid.is_none() {
496 if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
497 if let Some(g) = candidate
498 .bordered_grid
499 .clone()
500 .or_else(|| detect_ocr_word_grid(&ocrs_words, gray.width(), gray.height()))
501 {
502 words = ocrs_words;
503 grid = Some(g);
504 }
505 }
506 }
507 let grid = grid?;
508 build_table_cells(
509 &grid,
510 &gray,
511 &candidate.image_bbox,
512 &candidate.text_chunks,
513 &words,
514 )
515}
516
517#[cfg(feature = "image")]
518fn build_table_cells(
519 grid: &Grid,
520 gray: &GrayImage,
521 image_bbox: &BoundingBox,
522 text_chunks: &[TextChunk],
523 words: &[crate::pdf::ocr::OcrWord],
524) -> Option<TableBorder> {
525 let num_cols = grid.v.len().checked_sub(1)?;
526 let num_rows = grid.h.len().checked_sub(1)?;
527 if num_cols < 2 || num_rows < 2 {
528 return None;
529 }
530
531 let use_ocr = !words.is_empty();
532
533 let x_coords = map_boundaries(&grid.v, image_bbox.left_x, image_bbox.right_x, gray.width())?;
534 let y_coords = map_boundaries_desc(
535 &grid.h,
536 image_bbox.bottom_y,
537 image_bbox.top_y,
538 gray.height(),
539 )?;
540
541 let mut rows = Vec::with_capacity(num_rows);
542 let mut non_empty = 0usize;
543 for row_idx in 0..num_rows {
544 let mut cells = Vec::with_capacity(num_cols);
545 for col_idx in 0..num_cols {
546 let x1 = grid.v[col_idx];
547 let x2 = grid.v[col_idx + 1];
548 let y1 = grid.h[row_idx];
549 let y2 = grid.h[row_idx + 1];
550 let cell_bbox = BoundingBox::new(
551 image_bbox.page_number,
552 x_coords[col_idx],
553 y_coords[row_idx + 1],
554 x_coords[col_idx + 1],
555 y_coords[row_idx],
556 );
557 let (text, font_name) = if use_ocr {
558 (words_in_cell(words, x1, y1, x2, y2), "OCR")
559 } else {
560 (text_chunks_in_cell(text_chunks, &cell_bbox), "PDF")
561 };
562 let mut content = Vec::new();
563 if !text.is_empty() {
564 non_empty += 1;
565 content.push(TableToken {
566 base: TextChunk {
567 value: text,
568 bbox: cell_bbox.clone(),
569 font_name: font_name.into(),
570 font_size: (cell_bbox.height() * 0.55).max(6.0),
571 font_weight: if row_idx == 0 { 700.0 } else { 400.0 },
572 italic_angle: 0.0,
573 font_color: "#000000".into(),
574 contrast_ratio: 21.0,
575 symbol_ends: Vec::new(),
576 text_format: TextFormat::Normal,
577 text_type: TextType::Regular,
578 pdf_layer: PdfLayer::Content,
579 ocg_visible: true,
580 index: None,
581 page_number: image_bbox.page_number,
582 level: None,
583 mcid: None,
584 },
585 token_type: TableTokenType::Text,
586 });
587 }
588 cells.push(TableBorderCell {
589 bbox: cell_bbox,
590 index: None,
591 level: None,
592 row_number: row_idx,
593 col_number: col_idx,
594 row_span: 1,
595 col_span: 1,
596 content,
597 contents: Vec::new(),
598 semantic_type: None,
599 });
600 }
601 let row_bbox = BoundingBox::new(
602 image_bbox.page_number,
603 image_bbox.left_x,
604 y_coords[row_idx + 1],
605 image_bbox.right_x,
606 y_coords[row_idx],
607 );
608 rows.push(TableBorderRow {
609 bbox: row_bbox,
610 index: None,
611 level: None,
612 row_number: row_idx,
613 cells,
614 semantic_type: None,
615 });
616 }
617 if non_empty == 0 {
618 return None;
619 }
620
621 let table_bbox = BoundingBox::new(
622 image_bbox.page_number,
623 *x_coords.first()?,
624 *y_coords.last()?,
625 *x_coords.last()?,
626 *y_coords.first()?,
627 );
628 Some(TableBorder {
629 bbox: table_bbox,
630 index: None,
631 level: None,
632 x_coordinates: x_coords.clone(),
633 x_widths: vec![0.0; x_coords.len()],
634 y_coordinates: y_coords.clone(),
635 y_widths: vec![0.0; y_coords.len()],
636 rows,
637 num_rows,
638 num_columns: num_cols,
639 is_bad_table: false,
640 is_table_transformer: true,
641 previous_table: None,
642 next_table: None,
643 })
644}
645
646#[cfg(feature = "image")]
647fn fnv1a_hex(bytes: &[u8]) -> String {
648 let mut hash: u64 = 0xcbf29ce484222325;
649 for &b in bytes {
650 hash ^= u64::from(b);
651 hash = hash.wrapping_mul(0x100000001b3);
652 }
653 format!("{hash:016x}")
654}
655
656#[cfg(feature = "image")]
657fn recognize_words(gray: &GrayImage) -> Vec<crate::pdf::ocr::OcrWord> {
658 #[cfg(any(not(target_arch = "wasm32"), feature = "ocr-ocrs"))]
659 {
660 use crate::pdf::ocr::{default_engine, OcrEngine};
661 return OcrEngine::recognize(&*default_engine(), gray);
662 }
663 #[cfg(all(target_arch = "wasm32", not(feature = "ocr-ocrs")))]
664 {
665 let _ = gray;
666 Vec::new()
667 }
668}
669
670#[cfg(feature = "image")]
672fn recognize_words_ocrs_only(gray: &GrayImage) -> Option<Vec<crate::pdf::ocr::OcrWord>> {
673 #[cfg(feature = "ocr-ocrs")]
674 {
675 use crate::pdf::ocr::{OcrEngine, OcrsEngine};
676 return OcrsEngine::try_from_env().map(|e| e.recognize(gray));
677 }
678 #[cfg(not(feature = "ocr-ocrs"))]
679 {
680 let _ = gray;
681 None
682 }
683}
684
685#[cfg(feature = "image")]
686fn decode_to_gray(data: &[u8], filter: &str, width: u32, height: u32) -> Option<GrayImage> {
687 if filter == "DCTDecode" || data.starts_with(&[0xFF, 0xD8]) {
688 return image::load_from_memory(data).ok().map(|img| img.to_luma8());
689 }
690 if width == 0 || height == 0 {
691 return None;
692 }
693 let n = (width as usize).checked_mul(height as usize)?;
694 if data.len() == n {
695 return GrayImage::from_raw(width, height, data.to_vec());
696 }
697 if data.len() == n * 3 {
698 let mut luma = Vec::with_capacity(n);
699 for px in data.chunks_exact(3) {
700 let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
701 .round()
702 .clamp(0.0, 255.0) as u8;
703 luma.push(y);
704 }
705 return GrayImage::from_raw(width, height, luma);
706 }
707 if data.len() == n * 4 {
708 let mut luma = Vec::with_capacity(n);
709 for px in data.chunks_exact(4) {
710 let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
711 .round()
712 .clamp(0.0, 255.0) as u8;
713 luma.push(y);
714 }
715 return GrayImage::from_raw(width, height, luma);
716 }
717 image::load_from_memory(data).ok().map(|img| img.to_luma8())
718}
719
720#[cfg(feature = "image")]
721#[derive(Debug, Clone)]
722struct Grid {
723 v: Vec<u32>,
724 h: Vec<u32>,
725}
726
727#[cfg(feature = "image")]
728fn detect_bordered_grid(gray: &GrayImage) -> Option<Grid> {
729 let width = gray.width();
730 let height = gray.height();
731 if width < 100 || height < 80 {
732 return None;
733 }
734 let min_v = (f64::from(height) * MIN_LINE_DARK_RATIO).ceil() as u32;
735 let min_h = (f64::from(width) * MIN_LINE_DARK_RATIO).ceil() as u32;
736 let mut vertical: Vec<u32> =
737 merge_runs((0..width).filter(|&x| count_dark_col(gray, x) >= min_v))
738 .into_iter()
739 .map(|(a, b)| (a + b) / 2)
740 .collect();
741 let mut horizontal: Vec<u32> =
742 merge_runs((0..height).filter(|&y| count_dark_row(gray, y) >= min_h))
743 .into_iter()
744 .map(|(a, b)| (a + b) / 2)
745 .collect();
746 if vertical.len() < MIN_BORDERED_VERTICAL_LINES
747 || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
748 {
749 return None;
750 }
751 let (&min_x, &max_x) = vertical.first().zip(vertical.last())?;
752 let (&min_y, &max_y) = horizontal.first().zip(horizontal.last())?;
753 vertical.retain(|&x| dark_ratio_col(gray, x, min_y, max_y) >= MIN_TRUE_GRID_LINE_CONTINUITY);
754 horizontal.retain(|&y| dark_ratio_row(gray, y, min_x, max_x) >= MIN_TRUE_GRID_LINE_CONTINUITY);
755 if vertical.len() < MIN_BORDERED_VERTICAL_LINES
756 || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
757 {
758 return None;
759 }
760 Some(Grid {
761 v: vertical,
762 h: horizontal,
763 })
764}
765
766#[cfg(feature = "image")]
768fn detect_ocr_word_grid(
769 words: &[crate::pdf::ocr::OcrWord],
770 width: u32,
771 height: u32,
772) -> Option<Grid> {
773 if words.len() < 8 || width < 80 || height < 60 {
774 return None;
775 }
776 let mut heights: Vec<u32> = words.iter().map(|w| w.height.max(1)).collect();
777 heights.sort_unstable();
778 let med_h = heights[heights.len() / 2].max(6);
779 let y_gap = (med_h as f64 * 0.85).round() as u32;
780
781 let mut xs: Vec<u32> = words
782 .iter()
783 .filter(|w| w.width < width.saturating_mul(45) / 100 || w.width < 80)
785 .map(|w| w.left + w.width / 2)
786 .collect();
787 if xs.len() < 6 {
788 xs = words.iter().map(|w| w.left + w.width / 2).collect();
789 }
790 let mut ys: Vec<u32> = words.iter().map(|w| w.top + w.height / 2).collect();
791 xs.sort_unstable();
792 ys.sort_unstable();
793 let x_gap = adaptive_col_gap(&xs, med_h);
796 let col_centers = cluster_1d_centers(&xs, x_gap);
797 let row_centers = cluster_1d_centers(&ys, y_gap);
798 if col_centers.len() < 2 || row_centers.len() < 3 {
799 return None;
800 }
801 let occupied = count_occupied_cells(words, &col_centers, &row_centers, x_gap, y_gap);
803 let cells = col_centers.len() * row_centers.len();
804 if occupied < 6 || (occupied as f64) < 0.35 * cells as f64 {
805 return None;
806 }
807
808 let v = centers_to_boundaries(&col_centers, 0, width);
809 let h = centers_to_boundaries(&row_centers, 0, height);
810 if v.len() < 3 || h.len() < 4 {
811 return None;
812 }
813 Some(Grid { v, h })
814}
815
816#[cfg(feature = "image")]
818fn adaptive_col_gap(xs_sorted: &[u32], med_h: u32) -> u32 {
819 let mut u = xs_sorted.to_vec();
820 u.dedup();
821 if u.len() < 3 {
822 return (med_h as f64 * 2.5).round().max(12.0) as u32;
823 }
824 let mut diffs: Vec<u32> = u
825 .windows(2)
826 .map(|w| w[1] - w[0])
827 .filter(|&d| d > 1)
828 .collect();
829 if diffs.is_empty() {
830 return (med_h as f64 * 2.5).round().max(12.0) as u32;
831 }
832 diffs.sort_unstable();
833 let med = diffs[diffs.len() / 2].max(1);
834 ((med as f64) * 1.35)
835 .round()
836 .max((med_h as f64) * 2.0)
837 .max(16.0) as u32
838}
839
840#[cfg(feature = "image")]
841fn cluster_1d_centers(sorted: &[u32], gap: u32) -> Vec<u32> {
842 if sorted.is_empty() {
843 return Vec::new();
844 }
845 let mut clusters: Vec<Vec<u32>> = vec![vec![sorted[0]]];
846 for &x in &sorted[1..] {
847 let last = clusters.last_mut().unwrap();
848 if x.saturating_sub(*last.last().unwrap()) <= gap {
849 last.push(x);
850 } else {
851 clusters.push(vec![x]);
852 }
853 }
854 clusters
855 .into_iter()
856 .map(|c| c.iter().sum::<u32>() / c.len() as u32)
857 .collect()
858}
859
860#[cfg(feature = "image")]
861fn centers_to_boundaries(centers: &[u32], min_edge: u32, max_edge: u32) -> Vec<u32> {
862 if centers.is_empty() {
863 return vec![min_edge, max_edge];
864 }
865 let mut b = Vec::with_capacity(centers.len() + 1);
866 b.push(min_edge);
867 for i in 0..centers.len() - 1 {
868 b.push((centers[i] + centers[i + 1]) / 2);
869 }
870 b.push(max_edge.max(min_edge + 1));
871 for i in 1..b.len() {
873 if b[i] <= b[i - 1] {
874 b[i] = b[i - 1] + 1;
875 }
876 }
877 b
878}
879
880#[cfg(feature = "image")]
881fn count_occupied_cells(
882 words: &[crate::pdf::ocr::OcrWord],
883 cols: &[u32],
884 rows: &[u32],
885 x_gap: u32,
886 y_gap: u32,
887) -> usize {
888 let mut seen = std::collections::HashSet::new();
889 for w in words {
890 let cx = w.left + w.width / 2;
891 let cy = w.top + w.height / 2;
892 let Some(ci) = cols
893 .iter()
894 .position(|&c| cx.abs_diff(c) <= x_gap.saturating_mul(2))
895 else {
896 continue;
897 };
898 let Some(ri) = rows
899 .iter()
900 .position(|&r| cy.abs_diff(r) <= y_gap.saturating_mul(2))
901 else {
902 continue;
903 };
904 seen.insert((ri, ci));
905 }
906 seen.len()
907}
908
909#[cfg(feature = "image")]
910fn grid_has_cell_ink(gray: &GrayImage, grid: &Grid) -> bool {
911 let cols = grid.v.len().saturating_sub(1);
912 let rows = grid.h.len().saturating_sub(1);
913 if cols == 0 || rows == 0 {
914 return false;
915 }
916 let mut inked = 0usize;
917 let mut rows_with = 0usize;
918 let total = cols * rows;
919 for r in 0..rows {
920 let mut row_ink = false;
921 for c in 0..cols {
922 if cell_dark_ratio(gray, grid.v[c], grid.h[r], grid.v[c + 1], grid.h[r + 1]) >= 0.03 {
923 inked += 1;
924 row_ink = true;
925 }
926 }
927 if row_ink {
928 rows_with += 1;
929 }
930 }
931 (inked as f64 / total as f64) >= MIN_BORDERED_INKED_CELL_RATIO
932 && rows_with >= MIN_BORDERED_ROWS_WITH_INK
933}
934
935#[cfg(feature = "image")]
936fn is_obvious_bar_chart(gray: &GrayImage) -> bool {
937 if detect_bordered_grid(gray).is_some() {
938 return false;
939 }
940 let w = gray.width();
941 let h = gray.height();
942 if w < 120 || h < 80 {
943 return false;
944 }
945 let mut peaks = 0usize;
946 let mut run = 0u32;
947 let thresh = (h as f64 * 0.35) as u32;
948 for x in 0..w {
949 let dark = count_dark_col(gray, x);
950 if dark >= thresh {
951 run += 1;
952 } else if run > 0 {
953 if run >= 3 {
954 peaks += 1;
955 }
956 run = 0;
957 }
958 }
959 peaks >= 4
960}
961
962#[cfg(feature = "image")]
963fn count_dark_col(gray: &GrayImage, x: u32) -> u32 {
964 (0..gray.height())
965 .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
966 .count() as u32
967}
968
969#[cfg(feature = "image")]
970fn count_dark_row(gray: &GrayImage, y: u32) -> u32 {
971 (0..gray.width())
972 .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
973 .count() as u32
974}
975
976#[cfg(feature = "image")]
977fn dark_ratio_col(gray: &GrayImage, x: u32, y1: u32, y2: u32) -> f64 {
978 let h = y2.saturating_sub(y1).max(1);
979 let dark = (y1..y2)
980 .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
981 .count() as f64;
982 dark / f64::from(h)
983}
984
985#[cfg(feature = "image")]
986fn dark_ratio_row(gray: &GrayImage, y: u32, x1: u32, x2: u32) -> f64 {
987 let w = x2.saturating_sub(x1).max(1);
988 let dark = (x1..x2)
989 .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
990 .count() as f64;
991 dark / f64::from(w)
992}
993
994#[cfg(feature = "image")]
995fn cell_dark_ratio(gray: &GrayImage, x1: u32, y1: u32, x2: u32, y2: u32) -> f64 {
996 let mut dark = 0u32;
997 let mut total = 0u32;
998 for y in y1..y2 {
999 for x in x1..x2 {
1000 total += 1;
1001 if gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD {
1002 dark += 1;
1003 }
1004 }
1005 }
1006 if total == 0 {
1007 0.0
1008 } else {
1009 f64::from(dark) / f64::from(total)
1010 }
1011}
1012
1013#[cfg(feature = "image")]
1014fn merge_runs(xs: impl Iterator<Item = u32>) -> Vec<(u32, u32)> {
1015 let mut out = Vec::new();
1016 let mut cur: Option<(u32, u32)> = None;
1017 for x in xs {
1018 cur = match cur {
1019 None => Some((x, x)),
1020 Some((a, b)) if x <= b + 2 => Some((a, x)),
1021 Some(r) => {
1022 out.push(r);
1023 Some((x, x))
1024 }
1025 };
1026 }
1027 if let Some(r) = cur {
1028 out.push(r);
1029 }
1030 out
1031}
1032
1033#[cfg(feature = "image")]
1034fn map_boundaries(lines: &[u32], page_a: f64, page_b: f64, raster_span: u32) -> Option<Vec<f64>> {
1035 if raster_span == 0 || lines.is_empty() {
1036 return None;
1037 }
1038 let span = page_b - page_a;
1039 Some(
1040 lines
1041 .iter()
1042 .map(|&p| page_a + span * (f64::from(p) / f64::from(raster_span)))
1043 .collect(),
1044 )
1045}
1046
1047#[cfg(feature = "image")]
1048fn map_boundaries_desc(
1049 lines: &[u32],
1050 page_bottom: f64,
1051 page_top: f64,
1052 raster_span: u32,
1053) -> Option<Vec<f64>> {
1054 if raster_span == 0 || lines.is_empty() {
1055 return None;
1056 }
1057 let span = page_top - page_bottom;
1058 Some(
1059 lines
1060 .iter()
1061 .map(|&p| page_top - span * (f64::from(p) / f64::from(raster_span)))
1062 .collect(),
1063 )
1064}
1065
1066#[cfg(feature = "image")]
1067fn words_in_cell(words: &[crate::pdf::ocr::OcrWord], x1: u32, y1: u32, x2: u32, y2: u32) -> String {
1068 let mut hit: Vec<&crate::pdf::ocr::OcrWord> = words
1069 .iter()
1070 .filter(|w| {
1071 let cx = w.left + w.width / 2;
1072 let cy = w.top + w.height / 2;
1073 cx >= x1 && cx < x2 && cy >= y1 && cy < y2
1074 })
1075 .collect();
1076 hit.sort_by(|a, b| (a.top, a.left).cmp(&(b.top, b.left)));
1077 hit.iter()
1078 .map(|w| w.text.as_str())
1079 .collect::<Vec<_>>()
1080 .join(" ")
1081}
1082
1083#[cfg(feature = "image")]
1085fn text_chunks_in_cell(text_chunks: &[TextChunk], cell: &BoundingBox) -> String {
1086 let mut hit: Vec<&TextChunk> = text_chunks
1087 .iter()
1088 .filter(|t| {
1089 if t.value.trim().is_empty() {
1090 return false;
1091 }
1092 let cx = (t.bbox.left_x + t.bbox.right_x) * 0.5;
1093 let cy = (t.bbox.bottom_y + t.bbox.top_y) * 0.5;
1094 cx >= cell.left_x && cx <= cell.right_x && cy >= cell.bottom_y && cy <= cell.top_y
1095 })
1096 .collect();
1097 hit.sort_by(|a, b| {
1098 b.bbox
1099 .top_y
1100 .partial_cmp(&a.bbox.top_y)
1101 .unwrap_or(std::cmp::Ordering::Equal)
1102 .then_with(|| {
1103 a.bbox
1104 .left_x
1105 .partial_cmp(&b.bbox.left_x)
1106 .unwrap_or(std::cmp::Ordering::Equal)
1107 })
1108 });
1109 hit.iter()
1110 .map(|t| t.value.trim())
1111 .filter(|s| !s.is_empty())
1112 .collect::<Vec<_>>()
1113 .join(" ")
1114}
1115
1116#[cfg(feature = "image")]
1117pub fn bordered_table_is_plausible(table: &TableBorder) -> bool {
1119 if table.num_rows < 2 || table.num_columns < 2 {
1120 return false;
1121 }
1122 let mut token_counts = Vec::new();
1123 for row in &table.rows {
1124 for cell in &row.cells {
1125 let n = cell
1126 .content
1127 .iter()
1128 .map(|t| t.base.value.split_whitespace().count())
1129 .sum::<usize>();
1130 if n > 0 {
1131 token_counts.push(n);
1132 }
1133 }
1134 }
1135 if token_counts.is_empty() {
1136 return false;
1137 }
1138 let mut sorted = token_counts.clone();
1139 sorted.sort_unstable();
1140 let median = sorted[sorted.len() / 2].max(1);
1141 let max = *token_counts.iter().max().unwrap_or(&0);
1142 !(max >= 8 && max >= median.saturating_mul(8))
1143}
1144
1145#[cfg(all(test, feature = "image"))]
1146mod classify_tests {
1147 use super::*;
1148 use crate::models::chunks::ImagePaintSource;
1149 use crate::models::enums::{PdfLayer, TextFormat, TextType};
1150
1151 fn img(source: ImagePaintSource, w: f64, h: f64) -> ImageChunk {
1152 ImageChunk {
1153 bbox: BoundingBox::new(Some(1), 0.0, 0.0, w, h),
1154 index: Some(1),
1155 level: None,
1156 source,
1157 }
1158 }
1159
1160 #[test]
1161 fn full_bleed_do_image_is_text_blocks() {
1162 let image = img(ImagePaintSource::Do, 959.76, 539.41);
1163 let kind = classify_raster_candidate(&image, &[], 959.76, 540.0);
1164 assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1165 assert!(is_full_page_image(&image, 959.76, 540.0));
1166 }
1167
1168 #[test]
1169 fn pattern_fill_is_text_blocks() {
1170 let image = img(ImagePaintSource::PatternFill, 400.0, 300.0);
1171 let kind = classify_raster_candidate(&image, &[], 400.0, 300.0);
1172 assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1173 }
1174
1175 #[test]
1176 fn small_embedded_do_with_sparse_text_is_table() {
1177 let image = img(ImagePaintSource::Do, 200.0, 100.0);
1178 let kind = classify_raster_candidate(&image, &[], 612.0, 792.0);
1179 assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1181
1182 let outside = TextChunk {
1184 value: "Caption outside".into(),
1185 bbox: BoundingBox::new(Some(1), 0.0, 700.0, 200.0, 720.0),
1186 font_name: "Helvetica".into(),
1187 font_size: 12.0,
1188 font_weight: 400.0,
1189 italic_angle: 0.0,
1190 font_color: "#000".into(),
1191 contrast_ratio: 21.0,
1192 symbol_ends: vec![],
1193 text_format: TextFormat::Normal,
1194 text_type: TextType::Regular,
1195 pdf_layer: PdfLayer::Main,
1196 ocg_visible: true,
1197 index: None,
1198 page_number: Some(1),
1199 level: None,
1200 mcid: None,
1201 };
1202 let kind = classify_raster_candidate(&image, &[outside], 612.0, 792.0);
1203 assert_eq!(kind, Some(RasterCandidateKind::Table));
1204 }
1205}