#![cfg(all(feature = "liter-llm", feature = "layout-detection"))]
use std::io::Cursor;
use image::{ExtendedColorType, ImageEncoder};
use crate::RegionKind;
use crate::core::config::LlmConfig;
use crate::llm::region_extractor::extract_region_with_vlm;
use crate::pdf::structure::types::{LayoutHint, LayoutHintClass};
const MIN_REGION_CONFIDENCE: f32 = 0.6;
const MIN_REGION_PIXEL_AREA: u32 = 1_000;
pub(crate) struct RegionVlmResult {
pub page_index: usize,
pub markdown: String,
pub hint: LayoutHint,
}
const fn region_kind_for_hint(class_name: LayoutHintClass) -> Option<RegionKind> {
match class_name {
LayoutHintClass::Picture => Some(RegionKind::Figure),
LayoutHintClass::Table => Some(RegionKind::DenseTable),
LayoutHintClass::Other => Some(RegionKind::ComplexLayout),
_ => None,
}
}
pub(crate) async fn extract_vlm_regions(
layout_images: &[image::RgbImage],
layout_hints: &[Vec<LayoutHint>],
llm_config: &LlmConfig,
) -> Vec<RegionVlmResult> {
let mut results: Vec<RegionVlmResult> = Vec::new();
for (page_index, (page_image, hints)) in layout_images.iter().zip(layout_hints.iter()).enumerate() {
let img_width = page_image.width();
let img_height = page_image.height();
for hint in hints {
if hint.confidence < MIN_REGION_CONFIDENCE {
continue;
}
let Some(region_kind) = region_kind_for_hint(hint.class_name) else {
continue;
};
let pdf_top = hint.top;
let pdf_bottom = hint.bottom;
let pdf_left = hint.left;
let pdf_right = hint.right;
let pixel_y1 = (img_height as f32 - pdf_top).max(0.0).min(img_height as f32) as u32;
let pixel_y2 = (img_height as f32 - pdf_bottom).max(0.0).min(img_height as f32) as u32;
let pixel_x1 = pdf_left.max(0.0).min(img_width as f32) as u32;
let pixel_x2 = pdf_right.max(0.0).min(img_width as f32) as u32;
let (y_top, y_bot) = if pixel_y1 <= pixel_y2 {
(pixel_y1, pixel_y2)
} else {
(pixel_y2, pixel_y1)
};
let (x_left, x_right) = if pixel_x1 <= pixel_x2 {
(pixel_x1, pixel_x2)
} else {
(pixel_x2, pixel_x1)
};
let crop_w = x_right.saturating_sub(x_left);
let crop_h = y_bot.saturating_sub(y_top);
if crop_w * crop_h < MIN_REGION_PIXEL_AREA {
tracing::trace!(
page = page_index,
crop_w,
crop_h,
"region too small for VLM extraction; skipping"
);
continue;
}
let crop = image::imageops::crop_imm(page_image, x_left, y_top, crop_w, crop_h).to_image();
let mut png_buf = Cursor::new(Vec::<u8>::new());
let encode_result = image::codecs::png::PngEncoder::new(&mut png_buf).write_image(
crop.as_raw(),
crop.width(),
crop.height(),
ExtendedColorType::Rgb8,
);
if let Err(e) = encode_result {
tracing::warn!(
page = page_index,
error = %e,
"failed to PNG-encode region crop; skipping VLM call"
);
continue;
}
let crop_bytes = png_buf.into_inner();
tracing::debug!(
page = page_index,
region_kind = ?region_kind,
confidence = hint.confidence,
crop_w,
crop_h,
"sending region to VLM"
);
match extract_region_with_vlm(&crop_bytes, "image/png", region_kind, llm_config, None).await {
Ok(markdown) => {
let trimmed = markdown.trim().to_string();
if !trimmed.is_empty() {
results.push(RegionVlmResult {
page_index,
markdown: trimmed,
hint: hint.clone(),
});
}
}
Err(e) => {
tracing::warn!(
page = page_index,
region_kind = ?region_kind,
error = %e,
"VLM region extraction failed; region suppressed"
);
}
}
}
}
results
}
fn find_insertion_index(elements: &[crate::types::internal::InternalElement], page_num: u32, hint_top: f32) -> usize {
let mut insert_at = elements.len();
for (index, element) in elements.iter().enumerate() {
match element.page {
Some(page) if page == page_num => {
if let Some(bbox) = element.bbox
&& (bbox.y1 as f32) < hint_top
{
return index;
}
insert_at = index + 1;
}
Some(page) if page > page_num => return insert_at.min(index),
_ => {}
}
}
insert_at
}
fn shift_relationship_indices(document: &mut crate::types::internal::InternalDocument, from_index: usize) {
use crate::types::internal::RelationshipTarget;
let Ok(from_index) = u32::try_from(from_index) else {
return;
};
for relationship in &mut document.relationships {
if relationship.source >= from_index {
relationship.source += 1;
}
if let RelationshipTarget::Index(target_index) = &mut relationship.target
&& *target_index >= from_index
{
*target_index += 1;
}
}
}
pub(crate) fn inject_region_results(
document: &mut crate::types::internal::InternalDocument,
results: Vec<RegionVlmResult>,
) {
use crate::types::internal::{ElementKind, InternalElement};
for result in results {
let page_num = (result.page_index + 1) as u32;
let insert_at = find_insertion_index(&document.elements, page_num, result.hint.top);
shift_relationship_indices(document, insert_at);
document.elements.insert(
insert_at,
InternalElement::text(ElementKind::Paragraph, result.markdown, 0).with_page(page_num),
);
tracing::debug!(page = page_num, insert_at, "injected VLM region result into document");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pdf::structure::types::LayoutHintClass;
fn make_hint(class: LayoutHintClass, confidence: f32) -> LayoutHint {
LayoutHint {
class_name: class,
confidence,
left: 50.0,
bottom: 600.0,
right: 400.0,
top: 750.0,
}
}
#[test]
fn test_low_confidence_hints_are_skipped() {
let hint = make_hint(LayoutHintClass::Picture, 0.3);
assert!(hint.confidence < MIN_REGION_CONFIDENCE);
}
#[test]
fn should_skip_hints_with_dedicated_classical_handling() {
let classically_handled = [
LayoutHintClass::Text,
LayoutHintClass::SectionHeader,
LayoutHintClass::Title,
LayoutHintClass::PageHeader,
LayoutHintClass::PageFooter,
LayoutHintClass::Caption,
LayoutHintClass::Code,
LayoutHintClass::Formula,
LayoutHintClass::Footnote,
LayoutHintClass::ListItem,
LayoutHintClass::Form,
LayoutHintClass::KeyValueRegion,
LayoutHintClass::DocumentIndex,
];
for class in classically_handled {
assert_eq!(
region_kind_for_hint(class),
None,
"{class:?} must not be routed to the VLM"
);
}
}
#[test]
fn should_route_dense_table_hint_to_dense_table_region_kind() {
assert_eq!(
region_kind_for_hint(LayoutHintClass::Table),
Some(RegionKind::DenseTable)
);
}
#[test]
fn should_route_other_hint_to_complex_layout_region_kind() {
assert_eq!(
region_kind_for_hint(LayoutHintClass::Other),
Some(RegionKind::ComplexLayout)
);
}
#[test]
fn should_route_picture_hint_to_figure_region_kind() {
assert_eq!(region_kind_for_hint(LayoutHintClass::Picture), Some(RegionKind::Figure));
}
#[test]
fn test_min_pixel_area_constant() {
const { assert!(MIN_REGION_PIXEL_AREA > 0) };
}
fn element_with_bbox(page: u32, top: f64) -> crate::types::internal::InternalElement {
use crate::types::BoundingBox;
use crate::types::internal::{ElementKind, InternalElement};
let mut element = InternalElement::text(ElementKind::Paragraph, "existing", 0);
element.page = Some(page);
element.bbox = Some(BoundingBox {
x0: 0.0,
y0: top - 10.0,
x1: 100.0,
y1: top,
});
element
}
fn hint_with_top(top: f32) -> LayoutHint {
LayoutHint {
class_name: LayoutHintClass::Table,
confidence: 0.9,
left: 50.0,
bottom: top - 100.0,
right: 400.0,
top,
}
}
#[test]
fn should_splice_vlm_result_at_its_bbox_anchor_not_at_document_end() {
use crate::types::internal::InternalDocument;
let mut document = InternalDocument::default();
document.elements.push(element_with_bbox(1, 750.0));
document.elements.push(element_with_bbox(1, 400.0));
document.elements.push(element_with_bbox(2, 700.0));
let results = vec![RegionVlmResult {
page_index: 0,
markdown: "VLM TABLE".to_string(),
hint: hint_with_top(600.0),
}];
inject_region_results(&mut document, results);
assert_eq!(document.elements.len(), 4);
assert_eq!(document.elements[0].text, "existing");
assert_eq!(document.elements[0].page, Some(1));
assert_eq!(document.elements[1].text, "VLM TABLE");
assert_eq!(document.elements[1].page, Some(1));
assert_eq!(document.elements[2].text, "existing");
assert_eq!(document.elements[2].page, Some(1));
assert_eq!(document.elements[3].page, Some(2));
}
#[test]
fn should_append_vlm_result_after_page_when_no_element_bbox_is_below_it() {
use crate::types::internal::InternalDocument;
let mut document = InternalDocument::default();
document.elements.push(element_with_bbox(1, 750.0));
document.elements.push(element_with_bbox(1, 700.0));
let results = vec![RegionVlmResult {
page_index: 0,
markdown: "VLM CAPTION".to_string(),
hint: hint_with_top(600.0),
}];
inject_region_results(&mut document, results);
assert_eq!(document.elements.len(), 3);
assert_eq!(document.elements[2].text, "VLM CAPTION");
}
#[test]
fn should_shift_relationship_indices_when_splicing_before_referenced_elements() {
use crate::types::internal::{InternalDocument, Relationship, RelationshipKind, RelationshipTarget};
let mut document = InternalDocument::default();
document.elements.push(element_with_bbox(1, 750.0));
document.elements.push(element_with_bbox(1, 400.0));
document.elements.push(element_with_bbox(2, 700.0));
document.relationships.push(Relationship {
source: 1,
target: RelationshipTarget::Index(2),
kind: RelationshipKind::Caption,
});
document.relationships.push(Relationship {
source: 0,
target: RelationshipTarget::Key("unresolved".to_string()),
kind: RelationshipKind::InternalLink,
});
let results = vec![RegionVlmResult {
page_index: 0,
markdown: "VLM TABLE".to_string(),
hint: hint_with_top(600.0),
}];
inject_region_results(&mut document, results);
assert_eq!(document.elements[1].text, "VLM TABLE");
assert_eq!(document.elements[2].text, "existing");
assert_eq!(document.relationships[0].source, 2, "source index 1 must shift to 2");
assert_eq!(
document.relationships[0].target,
RelationshipTarget::Index(3),
"target index 2 must shift to 3"
);
assert_eq!(
document.relationships[1].source, 0,
"relationship before the splice point must not shift"
);
assert_eq!(
document.relationships[1].target,
RelationshipTarget::Key("unresolved".to_string()),
"unresolved key targets are untouched"
);
}
}