1use lopdf::Document;
8
9use crate::models::bbox::BoundingBox;
10use crate::models::chunks::{ImageChunk, 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
29pub fn recover_embedded_raster_tables(
31 doc: &Document,
32 page_id: lopdf::ObjectId,
33 image_chunks: &[ImageChunk],
34 text_chunks: &[TextChunk],
35) -> Vec<TableBorder> {
36 #[cfg(not(feature = "image"))]
37 {
38 let _ = (doc, page_id, image_chunks, text_chunks);
39 Vec::new()
40 }
41 #[cfg(feature = "image")]
42 {
43 recover_embedded_raster_tables_image(doc, page_id, image_chunks, text_chunks)
44 }
45}
46
47#[cfg(feature = "image")]
48fn recover_embedded_raster_tables_image(
49 doc: &Document,
50 page_id: lopdf::ObjectId,
51 image_chunks: &[ImageChunk],
52 text_chunks: &[TextChunk],
53) -> Vec<TableBorder> {
54 let mut out = Vec::new();
55 for image in image_chunks {
56 if !is_table_image_candidate(image, text_chunks) {
57 continue;
58 }
59 let Some(raw_index) = image.index else {
60 continue;
61 };
62 let index = raw_index.max(1);
64 let Ok(Some(extracted)) = extract_image_data(doc, page_id, index) else {
65 continue;
66 };
67 if let Some(table) = table_from_extracted(
68 &extracted.data,
69 &extracted.filter,
70 extracted.width,
71 extracted.height,
72 image,
73 text_chunks,
74 ) {
75 if bordered_table_is_plausible(&table) {
76 out.push(table);
77 }
78 }
79 }
80 out
81}
82
83#[cfg(feature = "image")]
84fn is_table_image_candidate(image: &ImageChunk, text_chunks: &[TextChunk]) -> bool {
85 let w = image.bbox.width();
86 let h = image.bbox.height();
87 if w < 80.0 || h < 40.0 {
88 return false;
89 }
90 let native_chars: usize = text_chunks
91 .iter()
92 .filter(|t| image.bbox.intersection_percent(&t.bbox) >= 0.7)
93 .map(|t| t.value.chars().filter(|c| !c.is_whitespace()).count())
94 .sum();
95 native_chars <= MAX_NATIVE_TEXT_CHARS_IN_IMAGE
96}
97
98#[cfg(feature = "image")]
104#[derive(Debug, Clone)]
105pub struct RasterCandidate {
106 pub id: u32,
108 pub page: u32,
110 pub gray: Vec<u8>,
112 pub width: u32,
114 pub height: u32,
116 pub hash: String,
118 pub image_bbox: BoundingBox,
120 pub text_chunks: Vec<TextChunk>,
122 bordered_grid: Option<Grid>,
124}
125
126#[cfg(feature = "image")]
127impl RasterCandidate {
128 pub fn meta(&self) -> RasterCandidateMeta {
130 RasterCandidateMeta {
131 id: self.id,
132 page: self.page,
133 width: self.width,
134 height: self.height,
135 hash: self.hash.clone(),
136 }
137 }
138}
139
140#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct RasterCandidateMeta {
143 pub id: u32,
145 pub page: u32,
147 pub width: u32,
149 pub height: u32,
151 pub hash: String,
153}
154
155#[cfg(feature = "image")]
157pub fn collect_raster_candidates(
158 doc: &Document,
159 page_id: lopdf::ObjectId,
160 image_chunks: &[ImageChunk],
161 text_chunks: &[TextChunk],
162 next_id: &mut u32,
163) -> Vec<RasterCandidate> {
164 let mut out = Vec::new();
165 for image in image_chunks {
166 if !is_table_image_candidate(image, text_chunks) {
167 continue;
168 }
169 let Some(raw_index) = image.index else {
170 continue;
171 };
172 let index = raw_index.max(1);
173 let Ok(Some(extracted)) = extract_image_data(doc, page_id, index) else {
174 continue;
175 };
176 if let Some(cand) = prepare_candidate(
177 *next_id,
178 &extracted.data,
179 &extracted.filter,
180 extracted.width,
181 extracted.height,
182 image,
183 text_chunks,
184 ) {
185 *next_id = next_id.saturating_add(1);
186 out.push(cand);
187 }
188 }
189 out
190}
191
192#[cfg(not(feature = "image"))]
193pub fn collect_raster_candidates(
195 _doc: &Document,
196 _page_id: lopdf::ObjectId,
197 _image_chunks: &[ImageChunk],
198 _text_chunks: &[TextChunk],
199 _next_id: &mut u32,
200) -> Vec<()> {
201 Vec::new()
202}
203
204#[cfg(feature = "image")]
206pub fn prepare_candidate(
207 id: u32,
208 data: &[u8],
209 filter: &str,
210 width: u32,
211 height: u32,
212 image: &ImageChunk,
213 text_chunks: &[TextChunk],
214) -> Option<RasterCandidate> {
215 let gray = decode_to_gray(data, filter, width, height)?;
216 if is_obvious_bar_chart(&gray) {
217 return None;
218 }
219 let bordered = detect_bordered_grid(&gray).filter(|g| grid_has_cell_ink(&gray, g));
220 let hash = fnv1a_hex(gray.as_raw());
223 let overlapping: Vec<TextChunk> = text_chunks
224 .iter()
225 .filter(|t| image.bbox.intersection_percent(&t.bbox) >= 0.7)
226 .cloned()
227 .collect();
228 let (gw, gh) = (gray.width(), gray.height());
229 Some(RasterCandidate {
230 id,
231 page: image.bbox.page_number?,
232 gray: gray.into_raw(),
233 width: gw,
234 height: gh,
235 hash,
236 image_bbox: image.bbox.clone(),
237 text_chunks: overlapping,
238 bordered_grid: bordered,
239 })
240}
241
242#[cfg(feature = "image")]
244pub fn build_table_from_candidate(
245 candidate: &RasterCandidate,
246 words: &[crate::pdf::ocr::OcrWord],
247) -> Option<TableBorder> {
248 let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
249 let mut words = words.to_vec();
250 let mut grid = candidate
251 .bordered_grid
252 .clone()
253 .or_else(|| detect_ocr_word_grid(&words, candidate.width, candidate.height));
254 if grid.is_none() {
255 if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
256 if let Some(g) = candidate
257 .bordered_grid
258 .clone()
259 .or_else(|| detect_ocr_word_grid(&ocrs_words, candidate.width, candidate.height))
260 {
261 words = ocrs_words;
262 grid = Some(g);
263 }
264 }
265 }
266 let grid = grid?;
267 build_table_cells(
268 &grid,
269 &gray,
270 &candidate.image_bbox,
271 &candidate.text_chunks,
272 &words,
273 )
274}
275
276#[cfg(feature = "image")]
277fn table_from_extracted(
278 data: &[u8],
279 filter: &str,
280 width: u32,
281 height: u32,
282 image: &ImageChunk,
283 text_chunks: &[TextChunk],
284) -> Option<TableBorder> {
285 let candidate = prepare_candidate(0, data, filter, width, height, image, text_chunks)?;
286 let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
287 let mut words = recognize_words(&gray);
288 let mut grid = candidate
289 .bordered_grid
290 .clone()
291 .or_else(|| detect_ocr_word_grid(&words, gray.width(), gray.height()));
292 if grid.is_none() {
295 if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
296 if let Some(g) = candidate
297 .bordered_grid
298 .clone()
299 .or_else(|| detect_ocr_word_grid(&ocrs_words, gray.width(), gray.height()))
300 {
301 words = ocrs_words;
302 grid = Some(g);
303 }
304 }
305 }
306 let grid = grid?;
307 build_table_cells(
308 &grid,
309 &gray,
310 &candidate.image_bbox,
311 &candidate.text_chunks,
312 &words,
313 )
314}
315
316#[cfg(feature = "image")]
317fn build_table_cells(
318 grid: &Grid,
319 gray: &GrayImage,
320 image_bbox: &BoundingBox,
321 text_chunks: &[TextChunk],
322 words: &[crate::pdf::ocr::OcrWord],
323) -> Option<TableBorder> {
324 let num_cols = grid.v.len().checked_sub(1)?;
325 let num_rows = grid.h.len().checked_sub(1)?;
326 if num_cols < 2 || num_rows < 2 {
327 return None;
328 }
329
330 let use_ocr = !words.is_empty();
331
332 let x_coords = map_boundaries(&grid.v, image_bbox.left_x, image_bbox.right_x, gray.width())?;
333 let y_coords = map_boundaries_desc(
334 &grid.h,
335 image_bbox.bottom_y,
336 image_bbox.top_y,
337 gray.height(),
338 )?;
339
340 let mut rows = Vec::with_capacity(num_rows);
341 let mut non_empty = 0usize;
342 for row_idx in 0..num_rows {
343 let mut cells = Vec::with_capacity(num_cols);
344 for col_idx in 0..num_cols {
345 let x1 = grid.v[col_idx];
346 let x2 = grid.v[col_idx + 1];
347 let y1 = grid.h[row_idx];
348 let y2 = grid.h[row_idx + 1];
349 let cell_bbox = BoundingBox::new(
350 image_bbox.page_number,
351 x_coords[col_idx],
352 y_coords[row_idx + 1],
353 x_coords[col_idx + 1],
354 y_coords[row_idx],
355 );
356 let (text, font_name) = if use_ocr {
357 (words_in_cell(words, x1, y1, x2, y2), "OCR")
358 } else {
359 (text_chunks_in_cell(text_chunks, &cell_bbox), "PDF")
360 };
361 let mut content = Vec::new();
362 if !text.is_empty() {
363 non_empty += 1;
364 content.push(TableToken {
365 base: TextChunk {
366 value: text,
367 bbox: cell_bbox.clone(),
368 font_name: font_name.into(),
369 font_size: (cell_bbox.height() * 0.55).max(6.0),
370 font_weight: if row_idx == 0 { 700.0 } else { 400.0 },
371 italic_angle: 0.0,
372 font_color: "#000000".into(),
373 contrast_ratio: 21.0,
374 symbol_ends: Vec::new(),
375 text_format: TextFormat::Normal,
376 text_type: TextType::Regular,
377 pdf_layer: PdfLayer::Content,
378 ocg_visible: true,
379 index: None,
380 page_number: image_bbox.page_number,
381 level: None,
382 mcid: None,
383 },
384 token_type: TableTokenType::Text,
385 });
386 }
387 cells.push(TableBorderCell {
388 bbox: cell_bbox,
389 index: None,
390 level: None,
391 row_number: row_idx,
392 col_number: col_idx,
393 row_span: 1,
394 col_span: 1,
395 content,
396 contents: Vec::new(),
397 semantic_type: None,
398 });
399 }
400 let row_bbox = BoundingBox::new(
401 image_bbox.page_number,
402 image_bbox.left_x,
403 y_coords[row_idx + 1],
404 image_bbox.right_x,
405 y_coords[row_idx],
406 );
407 rows.push(TableBorderRow {
408 bbox: row_bbox,
409 index: None,
410 level: None,
411 row_number: row_idx,
412 cells,
413 semantic_type: None,
414 });
415 }
416 if non_empty == 0 {
417 return None;
418 }
419
420 let table_bbox = BoundingBox::new(
421 image_bbox.page_number,
422 *x_coords.first()?,
423 *y_coords.last()?,
424 *x_coords.last()?,
425 *y_coords.first()?,
426 );
427 Some(TableBorder {
428 bbox: table_bbox,
429 index: None,
430 level: None,
431 x_coordinates: x_coords.clone(),
432 x_widths: vec![0.0; x_coords.len()],
433 y_coordinates: y_coords.clone(),
434 y_widths: vec![0.0; y_coords.len()],
435 rows,
436 num_rows,
437 num_columns: num_cols,
438 is_bad_table: false,
439 is_table_transformer: true,
440 previous_table: None,
441 next_table: None,
442 })
443}
444
445#[cfg(feature = "image")]
446fn fnv1a_hex(bytes: &[u8]) -> String {
447 let mut hash: u64 = 0xcbf29ce484222325;
448 for &b in bytes {
449 hash ^= u64::from(b);
450 hash = hash.wrapping_mul(0x100000001b3);
451 }
452 format!("{hash:016x}")
453}
454
455#[cfg(feature = "image")]
456fn recognize_words(gray: &GrayImage) -> Vec<crate::pdf::ocr::OcrWord> {
457 #[cfg(any(not(target_arch = "wasm32"), feature = "ocr-ocrs"))]
458 {
459 use crate::pdf::ocr::{default_engine, OcrEngine};
460 return OcrEngine::recognize(&*default_engine(), gray);
461 }
462 #[cfg(all(target_arch = "wasm32", not(feature = "ocr-ocrs")))]
463 {
464 let _ = gray;
465 Vec::new()
466 }
467}
468
469#[cfg(feature = "image")]
471fn recognize_words_ocrs_only(gray: &GrayImage) -> Option<Vec<crate::pdf::ocr::OcrWord>> {
472 #[cfg(feature = "ocr-ocrs")]
473 {
474 use crate::pdf::ocr::{OcrEngine, OcrsEngine};
475 return OcrsEngine::try_from_env().map(|e| e.recognize(gray));
476 }
477 #[cfg(not(feature = "ocr-ocrs"))]
478 {
479 let _ = gray;
480 None
481 }
482}
483
484#[cfg(feature = "image")]
485fn decode_to_gray(data: &[u8], filter: &str, width: u32, height: u32) -> Option<GrayImage> {
486 if filter == "DCTDecode" || data.starts_with(&[0xFF, 0xD8]) {
487 return image::load_from_memory(data).ok().map(|img| img.to_luma8());
488 }
489 if width == 0 || height == 0 {
490 return None;
491 }
492 let n = (width as usize).checked_mul(height as usize)?;
493 if data.len() == n {
494 return GrayImage::from_raw(width, height, data.to_vec());
495 }
496 if data.len() == n * 3 {
497 let mut luma = Vec::with_capacity(n);
498 for px in data.chunks_exact(3) {
499 let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
500 .round()
501 .clamp(0.0, 255.0) as u8;
502 luma.push(y);
503 }
504 return GrayImage::from_raw(width, height, luma);
505 }
506 if data.len() == n * 4 {
507 let mut luma = Vec::with_capacity(n);
508 for px in data.chunks_exact(4) {
509 let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
510 .round()
511 .clamp(0.0, 255.0) as u8;
512 luma.push(y);
513 }
514 return GrayImage::from_raw(width, height, luma);
515 }
516 image::load_from_memory(data).ok().map(|img| img.to_luma8())
517}
518
519#[cfg(feature = "image")]
520#[derive(Debug, Clone)]
521struct Grid {
522 v: Vec<u32>,
523 h: Vec<u32>,
524}
525
526#[cfg(feature = "image")]
527fn detect_bordered_grid(gray: &GrayImage) -> Option<Grid> {
528 let width = gray.width();
529 let height = gray.height();
530 if width < 100 || height < 80 {
531 return None;
532 }
533 let min_v = (f64::from(height) * MIN_LINE_DARK_RATIO).ceil() as u32;
534 let min_h = (f64::from(width) * MIN_LINE_DARK_RATIO).ceil() as u32;
535 let mut vertical: Vec<u32> =
536 merge_runs((0..width).filter(|&x| count_dark_col(gray, x) >= min_v))
537 .into_iter()
538 .map(|(a, b)| (a + b) / 2)
539 .collect();
540 let mut horizontal: Vec<u32> =
541 merge_runs((0..height).filter(|&y| count_dark_row(gray, y) >= min_h))
542 .into_iter()
543 .map(|(a, b)| (a + b) / 2)
544 .collect();
545 if vertical.len() < MIN_BORDERED_VERTICAL_LINES
546 || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
547 {
548 return None;
549 }
550 let (&min_x, &max_x) = vertical.first().zip(vertical.last())?;
551 let (&min_y, &max_y) = horizontal.first().zip(horizontal.last())?;
552 vertical.retain(|&x| dark_ratio_col(gray, x, min_y, max_y) >= MIN_TRUE_GRID_LINE_CONTINUITY);
553 horizontal.retain(|&y| dark_ratio_row(gray, y, min_x, max_x) >= MIN_TRUE_GRID_LINE_CONTINUITY);
554 if vertical.len() < MIN_BORDERED_VERTICAL_LINES
555 || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
556 {
557 return None;
558 }
559 Some(Grid {
560 v: vertical,
561 h: horizontal,
562 })
563}
564
565#[cfg(feature = "image")]
567fn detect_ocr_word_grid(
568 words: &[crate::pdf::ocr::OcrWord],
569 width: u32,
570 height: u32,
571) -> Option<Grid> {
572 if words.len() < 8 || width < 80 || height < 60 {
573 return None;
574 }
575 let mut heights: Vec<u32> = words.iter().map(|w| w.height.max(1)).collect();
576 heights.sort_unstable();
577 let med_h = heights[heights.len() / 2].max(6);
578 let y_gap = (med_h as f64 * 0.85).round() as u32;
579
580 let mut xs: Vec<u32> = words
581 .iter()
582 .filter(|w| w.width < width.saturating_mul(45) / 100 || w.width < 80)
584 .map(|w| w.left + w.width / 2)
585 .collect();
586 if xs.len() < 6 {
587 xs = words.iter().map(|w| w.left + w.width / 2).collect();
588 }
589 let mut ys: Vec<u32> = words.iter().map(|w| w.top + w.height / 2).collect();
590 xs.sort_unstable();
591 ys.sort_unstable();
592 let x_gap = adaptive_col_gap(&xs, med_h);
595 let col_centers = cluster_1d_centers(&xs, x_gap);
596 let row_centers = cluster_1d_centers(&ys, y_gap);
597 if col_centers.len() < 2 || row_centers.len() < 3 {
598 return None;
599 }
600 let occupied = count_occupied_cells(words, &col_centers, &row_centers, x_gap, y_gap);
602 let cells = col_centers.len() * row_centers.len();
603 if occupied < 6 || (occupied as f64) < 0.35 * cells as f64 {
604 return None;
605 }
606
607 let v = centers_to_boundaries(&col_centers, 0, width);
608 let h = centers_to_boundaries(&row_centers, 0, height);
609 if v.len() < 3 || h.len() < 4 {
610 return None;
611 }
612 Some(Grid { v, h })
613}
614
615#[cfg(feature = "image")]
617fn adaptive_col_gap(xs_sorted: &[u32], med_h: u32) -> u32 {
618 let mut u = xs_sorted.to_vec();
619 u.dedup();
620 if u.len() < 3 {
621 return (med_h as f64 * 2.5).round().max(12.0) as u32;
622 }
623 let mut diffs: Vec<u32> = u
624 .windows(2)
625 .map(|w| w[1] - w[0])
626 .filter(|&d| d > 1)
627 .collect();
628 if diffs.is_empty() {
629 return (med_h as f64 * 2.5).round().max(12.0) as u32;
630 }
631 diffs.sort_unstable();
632 let med = diffs[diffs.len() / 2].max(1);
633 ((med as f64) * 1.35)
634 .round()
635 .max((med_h as f64) * 2.0)
636 .max(16.0) as u32
637}
638
639#[cfg(feature = "image")]
640fn cluster_1d_centers(sorted: &[u32], gap: u32) -> Vec<u32> {
641 if sorted.is_empty() {
642 return Vec::new();
643 }
644 let mut clusters: Vec<Vec<u32>> = vec![vec![sorted[0]]];
645 for &x in &sorted[1..] {
646 let last = clusters.last_mut().unwrap();
647 if x.saturating_sub(*last.last().unwrap()) <= gap {
648 last.push(x);
649 } else {
650 clusters.push(vec![x]);
651 }
652 }
653 clusters
654 .into_iter()
655 .map(|c| c.iter().sum::<u32>() / c.len() as u32)
656 .collect()
657}
658
659#[cfg(feature = "image")]
660fn centers_to_boundaries(centers: &[u32], min_edge: u32, max_edge: u32) -> Vec<u32> {
661 if centers.is_empty() {
662 return vec![min_edge, max_edge];
663 }
664 let mut b = Vec::with_capacity(centers.len() + 1);
665 b.push(min_edge);
666 for i in 0..centers.len() - 1 {
667 b.push((centers[i] + centers[i + 1]) / 2);
668 }
669 b.push(max_edge.max(min_edge + 1));
670 for i in 1..b.len() {
672 if b[i] <= b[i - 1] {
673 b[i] = b[i - 1] + 1;
674 }
675 }
676 b
677}
678
679#[cfg(feature = "image")]
680fn count_occupied_cells(
681 words: &[crate::pdf::ocr::OcrWord],
682 cols: &[u32],
683 rows: &[u32],
684 x_gap: u32,
685 y_gap: u32,
686) -> usize {
687 let mut seen = std::collections::HashSet::new();
688 for w in words {
689 let cx = w.left + w.width / 2;
690 let cy = w.top + w.height / 2;
691 let Some(ci) = cols
692 .iter()
693 .position(|&c| cx.abs_diff(c) <= x_gap.saturating_mul(2))
694 else {
695 continue;
696 };
697 let Some(ri) = rows
698 .iter()
699 .position(|&r| cy.abs_diff(r) <= y_gap.saturating_mul(2))
700 else {
701 continue;
702 };
703 seen.insert((ri, ci));
704 }
705 seen.len()
706}
707
708#[cfg(feature = "image")]
709fn grid_has_cell_ink(gray: &GrayImage, grid: &Grid) -> bool {
710 let cols = grid.v.len().saturating_sub(1);
711 let rows = grid.h.len().saturating_sub(1);
712 if cols == 0 || rows == 0 {
713 return false;
714 }
715 let mut inked = 0usize;
716 let mut rows_with = 0usize;
717 let total = cols * rows;
718 for r in 0..rows {
719 let mut row_ink = false;
720 for c in 0..cols {
721 if cell_dark_ratio(gray, grid.v[c], grid.h[r], grid.v[c + 1], grid.h[r + 1]) >= 0.03 {
722 inked += 1;
723 row_ink = true;
724 }
725 }
726 if row_ink {
727 rows_with += 1;
728 }
729 }
730 (inked as f64 / total as f64) >= MIN_BORDERED_INKED_CELL_RATIO
731 && rows_with >= MIN_BORDERED_ROWS_WITH_INK
732}
733
734#[cfg(feature = "image")]
735fn is_obvious_bar_chart(gray: &GrayImage) -> bool {
736 if detect_bordered_grid(gray).is_some() {
737 return false;
738 }
739 let w = gray.width();
740 let h = gray.height();
741 if w < 120 || h < 80 {
742 return false;
743 }
744 let mut peaks = 0usize;
745 let mut run = 0u32;
746 let thresh = (h as f64 * 0.35) as u32;
747 for x in 0..w {
748 let dark = count_dark_col(gray, x);
749 if dark >= thresh {
750 run += 1;
751 } else if run > 0 {
752 if run >= 3 {
753 peaks += 1;
754 }
755 run = 0;
756 }
757 }
758 peaks >= 4
759}
760
761#[cfg(feature = "image")]
762fn count_dark_col(gray: &GrayImage, x: u32) -> u32 {
763 (0..gray.height())
764 .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
765 .count() as u32
766}
767
768#[cfg(feature = "image")]
769fn count_dark_row(gray: &GrayImage, y: u32) -> u32 {
770 (0..gray.width())
771 .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
772 .count() as u32
773}
774
775#[cfg(feature = "image")]
776fn dark_ratio_col(gray: &GrayImage, x: u32, y1: u32, y2: u32) -> f64 {
777 let h = y2.saturating_sub(y1).max(1);
778 let dark = (y1..y2)
779 .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
780 .count() as f64;
781 dark / f64::from(h)
782}
783
784#[cfg(feature = "image")]
785fn dark_ratio_row(gray: &GrayImage, y: u32, x1: u32, x2: u32) -> f64 {
786 let w = x2.saturating_sub(x1).max(1);
787 let dark = (x1..x2)
788 .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
789 .count() as f64;
790 dark / f64::from(w)
791}
792
793#[cfg(feature = "image")]
794fn cell_dark_ratio(gray: &GrayImage, x1: u32, y1: u32, x2: u32, y2: u32) -> f64 {
795 let mut dark = 0u32;
796 let mut total = 0u32;
797 for y in y1..y2 {
798 for x in x1..x2 {
799 total += 1;
800 if gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD {
801 dark += 1;
802 }
803 }
804 }
805 if total == 0 {
806 0.0
807 } else {
808 f64::from(dark) / f64::from(total)
809 }
810}
811
812#[cfg(feature = "image")]
813fn merge_runs(xs: impl Iterator<Item = u32>) -> Vec<(u32, u32)> {
814 let mut out = Vec::new();
815 let mut cur: Option<(u32, u32)> = None;
816 for x in xs {
817 cur = match cur {
818 None => Some((x, x)),
819 Some((a, b)) if x <= b + 2 => Some((a, x)),
820 Some(r) => {
821 out.push(r);
822 Some((x, x))
823 }
824 };
825 }
826 if let Some(r) = cur {
827 out.push(r);
828 }
829 out
830}
831
832#[cfg(feature = "image")]
833fn map_boundaries(lines: &[u32], page_a: f64, page_b: f64, raster_span: u32) -> Option<Vec<f64>> {
834 if raster_span == 0 || lines.is_empty() {
835 return None;
836 }
837 let span = page_b - page_a;
838 Some(
839 lines
840 .iter()
841 .map(|&p| page_a + span * (f64::from(p) / f64::from(raster_span)))
842 .collect(),
843 )
844}
845
846#[cfg(feature = "image")]
847fn map_boundaries_desc(
848 lines: &[u32],
849 page_bottom: f64,
850 page_top: f64,
851 raster_span: u32,
852) -> Option<Vec<f64>> {
853 if raster_span == 0 || lines.is_empty() {
854 return None;
855 }
856 let span = page_top - page_bottom;
857 Some(
858 lines
859 .iter()
860 .map(|&p| page_top - span * (f64::from(p) / f64::from(raster_span)))
861 .collect(),
862 )
863}
864
865#[cfg(feature = "image")]
866fn words_in_cell(words: &[crate::pdf::ocr::OcrWord], x1: u32, y1: u32, x2: u32, y2: u32) -> String {
867 let mut hit: Vec<&crate::pdf::ocr::OcrWord> = words
868 .iter()
869 .filter(|w| {
870 let cx = w.left + w.width / 2;
871 let cy = w.top + w.height / 2;
872 cx >= x1 && cx < x2 && cy >= y1 && cy < y2
873 })
874 .collect();
875 hit.sort_by(|a, b| (a.top, a.left).cmp(&(b.top, b.left)));
876 hit.iter()
877 .map(|w| w.text.as_str())
878 .collect::<Vec<_>>()
879 .join(" ")
880}
881
882#[cfg(feature = "image")]
884fn text_chunks_in_cell(text_chunks: &[TextChunk], cell: &BoundingBox) -> String {
885 let mut hit: Vec<&TextChunk> = text_chunks
886 .iter()
887 .filter(|t| {
888 if t.value.trim().is_empty() {
889 return false;
890 }
891 let cx = (t.bbox.left_x + t.bbox.right_x) * 0.5;
892 let cy = (t.bbox.bottom_y + t.bbox.top_y) * 0.5;
893 cx >= cell.left_x && cx <= cell.right_x && cy >= cell.bottom_y && cy <= cell.top_y
894 })
895 .collect();
896 hit.sort_by(|a, b| {
897 b.bbox
898 .top_y
899 .partial_cmp(&a.bbox.top_y)
900 .unwrap_or(std::cmp::Ordering::Equal)
901 .then_with(|| {
902 a.bbox
903 .left_x
904 .partial_cmp(&b.bbox.left_x)
905 .unwrap_or(std::cmp::Ordering::Equal)
906 })
907 });
908 hit.iter()
909 .map(|t| t.value.trim())
910 .filter(|s| !s.is_empty())
911 .collect::<Vec<_>>()
912 .join(" ")
913}
914
915#[cfg(feature = "image")]
916pub fn bordered_table_is_plausible(table: &TableBorder) -> bool {
918 if table.num_rows < 2 || table.num_columns < 2 {
919 return false;
920 }
921 let mut token_counts = Vec::new();
922 for row in &table.rows {
923 for cell in &row.cells {
924 let n = cell
925 .content
926 .iter()
927 .map(|t| t.base.value.split_whitespace().count())
928 .sum::<usize>();
929 if n > 0 {
930 token_counts.push(n);
931 }
932 }
933 }
934 if token_counts.is_empty() {
935 return false;
936 }
937 let mut sorted = token_counts.clone();
938 sorted.sort_unstable();
939 let median = sorted[sorted.len() / 2].max(1);
940 let max = *token_counts.iter().max().unwrap_or(&0);
941 !(max >= 8 && max >= median.saturating_mul(8))
942}