mod cache;
mod execution;
pub(crate) mod features;
mod format;
mod initialization;
mod page_markers;
#[cfg(test)]
mod tests;
pub use cache::clear_processor_cache;
pub use format::apply_output_format;
#[cfg(any(
feature = "classification",
feature = "summarization",
feature = "translation",
feature = "captioning",
feature = "qr-codes",
feature = "ner",
feature = "redaction",
feature = "quality",
feature = "keywords-yake",
feature = "keywords-rake"
))]
pub(crate) use initialization::automatic_registration_allowed;
pub(crate) use initialization::{
with_builtin_registration_recovery, with_post_processor_enabled, with_post_processor_suppressed,
};
use crate::Result;
use crate::core::config::ExtractionConfig;
use crate::types::ExtractedDocument;
use crate::types::internal::InternalDocument;
use execution::{execute_processor_stages, execute_validators};
use features::{execute_chunking, execute_language_detection, execute_token_reduction};
use initialization::{builtin_registration_error, initialize_processor_cache_for_async_pipeline};
const CAPTIONING_PROCESSOR_NAME: &str = "captioning";
const BUILTIN_REGISTRATION_SOURCE: &str = "builtin_registration";
#[cfg(all(feature = "ocr", feature = "tokio-runtime"))]
const FULL_PAGE_IMAGE_AREA_RATIO: f64 = 0.85;
type PostProcessorHandle = std::sync::Arc<dyn crate::plugins::PostProcessor>;
#[cfg(all(feature = "ocr", feature = "tokio-runtime"))]
fn image_ocr_positions(doc: &InternalDocument) -> Vec<usize> {
doc.images
.iter()
.enumerate()
.filter_map(|(position, image)| (!should_skip_pdf_image_ocr(doc, image)).then_some(position))
.collect()
}
#[cfg(all(feature = "ocr", feature = "tokio-runtime"))]
fn should_skip_pdf_image_ocr(doc: &InternalDocument, image: &crate::types::ExtractedImage) -> bool {
if doc.source_format != "pdf" || !page_has_extracted_text(doc, image.page_number) {
return false;
}
let Some(bounding_box) = image.bounding_box.as_ref() else {
return false;
};
let Some(page_number) = image.page_number else {
return false;
};
let Some(dimensions) = doc
.metadata
.pages
.as_ref()
.and_then(|structure| structure.pages.as_ref())
.and_then(|pages| pages.iter().find(|page| page.number == page_number))
.and_then(|page| page.dimensions)
else {
return false;
};
let page_width = dimensions.width;
let page_height = dimensions.height;
if page_width <= 0.0 || page_height <= 0.0 {
return false;
}
let image_area = (bounding_box.x1 - bounding_box.x0).abs() * (bounding_box.y1 - bounding_box.y0).abs();
image_area / (page_width * page_height) >= FULL_PAGE_IMAGE_AREA_RATIO
}
#[cfg(all(feature = "ocr", feature = "tokio-runtime"))]
fn page_has_extracted_text(doc: &InternalDocument, page_number: Option<u32>) -> bool {
let Some(page_number) = page_number else {
return false;
};
doc.elements
.iter()
.any(|element| element.page == Some(page_number) && !element.text.trim().is_empty())
|| doc.prebuilt_pages.as_ref().is_some_and(|pages| {
pages
.iter()
.any(|page| page.page_number == page_number && !page.content.trim().is_empty())
})
}
fn processors_without_captioning(
processors: &std::sync::Arc<Vec<PostProcessorHandle>>,
) -> std::sync::Arc<Vec<PostProcessorHandle>> {
std::sync::Arc::new(
processors
.iter()
.filter(|processor| processor.name() != CAPTIONING_PROCESSOR_NAME)
.cloned()
.collect(),
)
}
#[derive(Debug, Default)]
struct CaptioningCarryOver {
content: Option<String>,
entities: Option<Vec<crate::types::Entity>>,
}
impl CaptioningCarryOver {
fn apply(self, result: &mut ExtractedDocument) {
if let Some(content) = self.content {
result.content = content;
}
if self.entities.is_some() {
result.entities = self.entities;
}
}
}
fn image_descriptions_changed(
before: &[crate::types::ExtractedImage],
after: Option<&Vec<crate::types::ExtractedImage>>,
) -> bool {
let after = match after {
Some(images) => images.as_slice(),
None => &[],
};
before.len() != after.len()
|| before
.iter()
.zip(after)
.any(|(retained, captioned)| retained.description != captioned.description)
}
fn push_builtin_registration_warning(doc: &mut InternalDocument, registration_error: Option<String>) {
let Some(error) = registration_error else {
return;
};
doc.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed(BUILTIN_REGISTRATION_SOURCE),
message: std::borrow::Cow::Owned(format!(
"built-in post-processor registration was incomplete ({error}); a configured \
processor may silently produce no output for its stage"
)),
});
}
async fn run_captioning_prepass(
doc: &mut InternalDocument,
config: &ExtractionConfig,
include_structure: bool,
pp_config: &Option<&crate::core::config::PostProcessorConfig>,
middle_processors: &std::sync::Arc<Vec<PostProcessorHandle>>,
) -> Result<CaptioningCarryOver> {
if config.captioning.is_none() {
return Ok(CaptioningCarryOver::default());
}
let captioning_processors = std::sync::Arc::new(
middle_processors
.iter()
.filter(|processor| processor.name() == CAPTIONING_PROCESSOR_NAME)
.cloned()
.collect::<Vec<_>>(),
);
if captioning_processors.is_empty() {
doc.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("captioning"),
message: std::borrow::Cow::Borrowed("captioning feature not enabled — rebuild with --features captioning"),
});
return Ok(CaptioningCarryOver::default());
}
crate::extraction::derive::resolve_relationships(doc);
let mut caption_result = crate::extraction::derive::derive_extraction_result(
doc.clone(),
include_structure,
config.output_format.clone(),
);
let content_before = caption_result.content.clone();
execute_processor_stages(
&mut caption_result,
config,
pp_config,
&[(crate::plugins::ProcessingStage::Middle, captioning_processors)],
)
.await?;
let description_changed = image_descriptions_changed(&doc.images, caption_result.images.as_ref());
doc.images = caption_result.images.take().unwrap_or_default();
if description_changed {
doc.pre_rendered_content = None;
}
doc.metadata = std::mem::take(&mut caption_result.metadata);
#[cfg(feature = "tree-sitter")]
if let Some(code_intelligence) = caption_result.code_intelligence.take() {
doc.metadata.additional.insert(
std::borrow::Cow::Borrowed(crate::extractors::code::CODE_INTELLIGENCE_SCRATCH_KEY),
code_intelligence,
);
}
doc.uris = caption_result.uris.take().unwrap_or_default();
doc.processing_warnings = std::mem::take(&mut caption_result.processing_warnings);
doc.llm_usage = caption_result.llm_usage.take();
Ok(CaptioningCarryOver {
content: (caption_result.content != content_before).then(|| std::mem::take(&mut caption_result.content)),
entities: caption_result.entities.take(),
})
}
#[cfg_attr(feature = "otel", tracing::instrument(
skip(doc, config),
fields(
{ crate::telemetry::conventions::OPERATION } = crate::telemetry::conventions::operations::PIPELINE,
content.element_count = doc.elements.len(),
)
))]
#[cfg_attr(alef, alef(skip))]
pub async fn run_pipeline(mut doc: InternalDocument, config: &ExtractionConfig) -> Result<ExtractedDocument> {
doc.ocr_text_only = config.images.as_ref().map(|i| i.ocr_text_only).unwrap_or(false);
doc.append_ocr_text = config.images.as_ref().map(|i| i.append_ocr_text).unwrap_or(false);
doc.escape_markdown = config.escape_markdown;
doc.include_watermarks = config
.content_filter
.as_ref()
.is_some_and(|filter| filter.include_watermarks);
doc.table_anchors = config.table_anchors;
doc.page_marker_format = config
.pages
.as_ref()
.filter(|p| p.insert_page_markers)
.map(|p| p.marker_format.clone());
if let Some(format) = doc.page_marker_format.clone() {
page_markers::inject_page_marker_elements(&mut doc, &format);
}
#[cfg(all(feature = "ocr", feature = "tokio-runtime"))]
let image_ocr_enabled = config.images.as_ref().map(|i| i.run_ocr_on_images).unwrap_or(true);
#[cfg(all(feature = "ocr", feature = "tokio-runtime"))]
if image_ocr_enabled && config.ocr.is_some() && !doc.images.is_empty() {
let image_positions = image_ocr_positions(&doc);
if !image_positions.is_empty() {
let images_to_process = image_positions
.iter()
.map(|&position| doc.images[position].clone())
.collect();
match crate::extraction::image_ocr::process_images_with_ocr(
images_to_process,
config,
&mut doc.processing_warnings,
)
.await
{
Ok(processed) => {
for (position, image) in image_positions.into_iter().zip(processed) {
doc.images[position] = image;
}
}
Err(e) => {
doc.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("image_ocr"),
message: std::borrow::Cow::Owned(format!("Image OCR failed: {e}")),
});
}
}
}
}
replace_embedded_image_markdown_with_ocr(&mut doc);
append_embedded_image_ocr_text(&mut doc);
let pp_config = config.postprocessor.as_ref();
let postprocessing_enabled = pp_config.is_none_or(|processor_config| processor_config.enabled);
let processor_stages = if postprocessing_enabled {
let processor_stages = initialize_processor_cache_for_async_pipeline().await?;
push_builtin_registration_warning(&mut doc, builtin_registration_error());
Some(processor_stages)
} else {
None
};
let include_structure = config.include_document_structure;
let mut captioning_carry_over = CaptioningCarryOver::default();
if let Some(snapshot) = &processor_stages {
captioning_carry_over =
run_captioning_prepass(&mut doc, config, include_structure, &pp_config, &snapshot.middle).await?;
}
#[cfg(feature = "chunking")]
let chunker_heading_source: Option<String> = {
let needs_markdown = config.chunking.as_ref().is_some_and(|c| {
c.chunker_type == crate::core::config::ChunkerType::Markdown
|| c.resolve_preset().chunker_type == crate::core::config::ChunkerType::Markdown
}) && config.output_format == crate::core::config::OutputFormat::Plain;
if needs_markdown {
Some(crate::rendering::render_markdown(&doc))
} else {
None
}
};
#[cfg(not(feature = "chunking"))]
let chunker_heading_source: Option<String> = None;
#[cfg(feature = "html")]
let styled_html_prerender: Option<String> = {
use crate::plugins::InternalRenderer as _;
if config.output_format == crate::core::config::OutputFormat::Html {
config.html_output.as_ref().and_then(|html_cfg| {
match crate::rendering::StyledHtmlRenderer::new(html_cfg.clone()) {
Ok(renderer) => match renderer.render(&doc) {
Ok(html) => Some(html),
Err(e) => {
tracing::warn!("StyledHtmlRenderer render failed, falling back to default HTML: {e}");
None
}
},
Err(e) => {
tracing::warn!("StyledHtmlRenderer construction failed, falling back to default HTML: {e}");
None
}
}
})
} else {
None
}
};
let doc_for_elements = if config.result_format == crate::types::ResultFormat::ElementBased {
Some(doc.clone())
} else {
None
};
let mut result =
crate::extraction::derive::derive_extraction_result(doc, include_structure, config.output_format.clone());
result.internal_document = doc_for_elements;
captioning_carry_over.apply(&mut result);
let internal_document_source_content = result.internal_document.is_some().then(|| result.content.clone());
#[cfg(feature = "html")]
if let Some(html) = styled_html_prerender {
result.formatted_content = Some(html);
}
let formatted_content_source = result
.formatted_content
.as_ref()
.map(|formatted| (result.content.clone(), formatted.clone()));
#[cfg(feature = "image-encode")]
if let Some(ref image_cfg) = config.images {
apply_output_format_pass_with_security_limits(&mut result, image_cfg, config.security_limits.as_ref());
}
if let Some(ref image_cfg) = config.images {
apply_data_base64_pass(&mut result, image_cfg);
}
if let Some(snapshot) = &processor_stages {
execute_processor_stages(
&mut result,
config,
&pp_config,
&[(
crate::plugins::ProcessingStage::Early,
std::sync::Arc::clone(&snapshot.early),
)],
)
.await?;
}
execute_language_detection(&mut result, config)?;
execute_chunking(&mut result, config, chunker_heading_source.as_deref())?;
if let Some(snapshot) = &processor_stages {
let middle_processors = if config.captioning.is_some() {
processors_without_captioning(&snapshot.middle)
} else {
std::sync::Arc::clone(&snapshot.middle)
};
execute_processor_stages(
&mut result,
config,
&pp_config,
&[
(crate::plugins::ProcessingStage::Middle, middle_processors),
(
crate::plugins::ProcessingStage::Late,
std::sync::Arc::clone(&snapshot.late),
),
],
)
.await?;
}
drop(processor_stages);
execute_token_reduction(&mut result, config)?;
execute_validators(&result, config).await?;
normalize_nfc(&mut result);
discard_diverged_internal_document(&mut result, internal_document_source_content.as_deref());
discard_diverged_formatted_content(&mut result, formatted_content_source.as_ref());
apply_element_transform(&mut result, config);
#[cfg(all(feature = "liter-llm", not(target_arch = "wasm32")))]
if let Some(ref structured_config) = config.structured_extraction {
match crate::llm::structured::extract_structured(&result.content, structured_config).await {
Ok((output, usage)) => {
result.structured_output = Some(output);
crate::llm::usage::push_llm_usage(&mut result, usage);
}
Err(e) => {
tracing::warn!("Structured extraction failed: {e}");
result.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("structured_extraction"),
message: std::borrow::Cow::Owned(format!("Structured extraction failed: {e}")),
});
}
}
}
#[cfg(not(feature = "liter-llm"))]
if config.structured_extraction.is_some() {
result.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("structured_extraction"),
message: std::borrow::Cow::Borrowed("Structured extraction requires the 'liter-llm' feature"),
});
}
#[cfg(all(feature = "liter-llm", target_arch = "wasm32"))]
if config.structured_extraction.is_some() {
result.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("structured_extraction"),
message: std::borrow::Cow::Borrowed("Structured extraction is not available on wasm builds"),
});
}
result = apply_output_format(result, config.output_format.clone());
execute_chunking(&mut result, config, chunker_heading_source.as_deref())?;
populate_document_counts(&mut result);
#[cfg(feature = "heuristics")]
{
use crate::heuristics::confidence::{ConfidenceSignals, ConfidenceWeights, SchemaCompliance, score_confidence};
let text_coverage = measure_text_coverage(&result);
let signals = ConfidenceSignals::from_extraction_result(&result, SchemaCompliance::AllValid, text_coverage);
result.extraction_confidence = Some(score_confidence(signals, ConfidenceWeights::default()));
}
Ok(result)
}
#[cfg(not(feature = "tokio-runtime"))]
#[cfg_attr(feature = "otel", tracing::instrument(
skip(doc, config),
fields(
{ crate::telemetry::conventions::OPERATION } = crate::telemetry::conventions::operations::PIPELINE,
content.element_count = doc.elements.len(),
)
))]
#[cfg_attr(alef, alef(skip))]
pub fn run_pipeline_sync(mut doc: InternalDocument, config: &ExtractionConfig) -> Result<ExtractedDocument> {
doc.ocr_text_only = config.images.as_ref().map(|i| i.ocr_text_only).unwrap_or(false);
doc.append_ocr_text = config.images.as_ref().map(|i| i.append_ocr_text).unwrap_or(false);
doc.escape_markdown = config.escape_markdown;
doc.include_watermarks = config
.content_filter
.as_ref()
.is_some_and(|filter| filter.include_watermarks);
doc.table_anchors = config.table_anchors;
doc.page_marker_format = config
.pages
.as_ref()
.filter(|p| p.insert_page_markers)
.map(|p| p.marker_format.clone());
if let Some(format) = doc.page_marker_format.clone() {
page_markers::inject_page_marker_elements(&mut doc, &format);
}
replace_embedded_image_markdown_with_ocr(&mut doc);
append_embedded_image_ocr_text(&mut doc);
#[cfg(feature = "chunking")]
let chunker_heading_source: Option<String> = {
let needs_markdown = config.chunking.as_ref().is_some_and(|c| {
c.chunker_type == crate::core::config::ChunkerType::Markdown
|| c.resolve_preset().chunker_type == crate::core::config::ChunkerType::Markdown
}) && config.output_format == crate::core::config::OutputFormat::Plain;
if needs_markdown {
Some(crate::rendering::render_markdown(&doc))
} else {
None
}
};
#[cfg(not(feature = "chunking"))]
let chunker_heading_source: Option<String> = None;
#[cfg(feature = "html")]
let styled_html_prerender: Option<String> = {
use crate::plugins::InternalRenderer as _;
if config.output_format == crate::core::config::OutputFormat::Html {
config.html_output.as_ref().and_then(|html_cfg| {
match crate::rendering::StyledHtmlRenderer::new(html_cfg.clone()) {
Ok(renderer) => match renderer.render(&doc) {
Ok(html) => Some(html),
Err(e) => {
tracing::warn!("StyledHtmlRenderer render failed, falling back to default HTML: {e}");
None
}
},
Err(e) => {
tracing::warn!("StyledHtmlRenderer construction failed, falling back to default HTML: {e}");
None
}
}
})
} else {
None
}
};
let doc_for_elements = if config.result_format == crate::types::ResultFormat::ElementBased {
Some(doc.clone())
} else {
None
};
let include_structure = config.include_document_structure;
let mut result =
crate::extraction::derive::derive_extraction_result(doc, include_structure, config.output_format.clone());
result.internal_document = doc_for_elements;
let internal_document_source_content = result.internal_document.is_some().then(|| result.content.clone());
#[cfg(feature = "html")]
if let Some(html) = styled_html_prerender {
result.formatted_content = Some(html);
}
let formatted_content_source = result
.formatted_content
.as_ref()
.map(|formatted| (result.content.clone(), formatted.clone()));
#[cfg(feature = "image-encode")]
if let Some(ref image_cfg) = config.images {
apply_output_format_pass_with_security_limits(&mut result, image_cfg, config.security_limits.as_ref());
}
if let Some(ref image_cfg) = config.images {
apply_data_base64_pass(&mut result, image_cfg);
}
execute_language_detection(&mut result, config)?;
execute_token_reduction(&mut result, config)?;
normalize_nfc(&mut result);
discard_diverged_internal_document(&mut result, internal_document_source_content.as_deref());
discard_diverged_formatted_content(&mut result, formatted_content_source.as_ref());
apply_element_transform(&mut result, config);
result = apply_output_format(result, config.output_format.clone());
execute_chunking(&mut result, config, chunker_heading_source.as_deref())?;
populate_document_counts(&mut result);
#[cfg(feature = "heuristics")]
{
use crate::heuristics::confidence::{ConfidenceSignals, ConfidenceWeights, SchemaCompliance, score_confidence};
let text_coverage = measure_text_coverage(&result);
let signals = ConfidenceSignals::from_extraction_result(&result, SchemaCompliance::AllValid, text_coverage);
result.extraction_confidence = Some(score_confidence(signals, ConfidenceWeights::default()));
}
Ok(result)
}
fn populate_document_counts(result: &mut ExtractedDocument) {
let pages = result
.metadata
.pages
.as_ref()
.map(|p| p.total_count as usize)
.filter(|&n| n > 0)
.or_else(|| result.pages.as_ref().map(Vec::len))
.unwrap_or(0);
result.counts = crate::types::DocumentCounts {
pages,
tables: result.tables.len(),
images: result.images.as_ref().map_or(0, Vec::len),
};
}
#[cfg(feature = "heuristics")]
fn measure_text_coverage(result: &ExtractedDocument) -> f32 {
match result.pages.as_deref() {
Some(pages) if !pages.is_empty() => {
let usable = pages.iter().filter(|page| !page.content.trim().is_empty()).count();
usable as f32 / pages.len() as f32
}
_ => {
if result.content.trim().is_empty() {
0.0
} else {
1.0
}
}
}
}
#[cfg(all(feature = "image-encode", test))]
fn apply_output_format_pass(
result: &mut ExtractedDocument,
config: &crate::core::config::extraction::ImageExtractionConfig,
) {
apply_output_format_pass_with_security_limits(result, config, None);
}
#[cfg(feature = "image-encode")]
fn apply_output_format_pass_with_security_limits(
result: &mut ExtractedDocument,
config: &crate::core::config::extraction::ImageExtractionConfig,
security_limits: Option<&crate::extractors::security::SecurityLimits>,
) {
use crate::core::config::extraction::ImageOutputFormat;
use crate::core::image_encode::re_encode;
#[cfg(not(feature = "svg"))]
if matches!(config.output_format, ImageOutputFormat::Native) {
return;
}
#[cfg(feature = "svg")]
if matches!(config.output_format, ImageOutputFormat::Native) && !config.svg.sanitize {
return;
}
let target = config.output_format;
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = security_limits.unwrap_or(&default_security_limits);
for image in result.images.iter_mut().flatten() {
match re_encode(
image,
target,
security_limits,
#[cfg(feature = "svg")]
&config.svg,
) {
Ok(_) => {}
Err(warning) => {
result.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("image_encoder"),
message: std::borrow::Cow::Owned(warning.to_string()),
});
}
}
}
}
fn apply_data_base64_pass(
result: &mut ExtractedDocument,
config: &crate::core::config::extraction::ImageExtractionConfig,
) {
if !config.include_data_base64 {
return;
}
use base64::Engine as _;
for image in result.images.iter_mut().flatten() {
image.data_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&image.data));
}
}
fn discard_diverged_internal_document(result: &mut ExtractedDocument, source_content: Option<&str>) {
let Some(source_content) = source_content else {
return;
};
if result.content != source_content {
result.internal_document = None;
}
}
const OUTPUT_FORMAT_WARNING_SOURCE: &str = "output_format";
fn discard_diverged_formatted_content(result: &mut ExtractedDocument, source: Option<&(String, String)>) {
let Some((source_content, source_formatted)) = source else {
return;
};
if result.content == *source_content {
return;
}
if result.formatted_content.as_deref() != Some(source_formatted.as_str()) {
return;
}
result.formatted_content = None;
crate::core::diagnostics::push_warning(
&mut result.processing_warnings,
OUTPUT_FORMAT_WARNING_SOURCE,
"Post-processing rewrote the document text after the requested output format had \
already been rendered, so the rendering was discarded and the post-processed plain \
text is returned instead. Rendering it in the requested format would have undone \
the post-processors' changes",
);
}
fn apply_element_transform(result: &mut ExtractedDocument, config: &ExtractionConfig) {
if config.result_format == crate::types::ResultFormat::ElementBased {
result.elements = Some(crate::extraction::transform::transform_extraction_result_to_elements(
result,
));
}
}
fn replace_embedded_image_markdown_with_ocr(doc: &mut InternalDocument) {
if !doc.ocr_text_only || doc.images.is_empty() {
return;
}
let mut image_idx = 0usize;
for elem in &mut doc.elements {
if !matches!(elem.kind, crate::types::internal::ElementKind::Paragraph) {
continue;
}
if !is_markdown_image_reference(&elem.text) {
continue;
}
if let Some(img) = doc.images.get(image_idx)
&& let Some(ocr) = &img.ocr_result
&& !ocr.content.is_empty()
{
elem.text = ocr.content.clone();
image_idx += 1;
continue;
}
image_idx += 1;
}
for table in &mut doc.tables {
for row in &mut table.cells {
for cell in row {
if !is_markdown_image_reference(cell) {
continue;
}
if let Some(img) = doc.images.get(image_idx)
&& let Some(ocr) = &img.ocr_result
&& !ocr.content.is_empty()
{
*cell = ocr.content.clone();
image_idx += 1;
continue;
}
image_idx += 1;
}
}
}
}
fn append_embedded_image_ocr_text(doc: &mut InternalDocument) {
if doc.ocr_text_only || !doc.append_ocr_text || doc.images.is_empty() {
return;
}
let mut image_idx = 0usize;
let mut new_elements = Vec::with_capacity(doc.elements.len() * 2);
for elem in &doc.elements {
new_elements.push(elem.clone());
if matches!(elem.kind, crate::types::internal::ElementKind::Paragraph)
&& is_markdown_image_reference(&elem.text)
{
if let Some(img) = doc.images.get(image_idx)
&& let Some(ocr) = &img.ocr_result
&& !ocr.content.is_empty()
{
let ocr_elem = crate::types::internal::InternalElement::text(
crate::types::internal::ElementKind::Paragraph,
ocr.content.clone(),
0,
);
new_elements.push(ocr_elem);
}
image_idx += 1;
}
}
doc.elements = new_elements;
for table in &mut doc.tables {
for row in &mut table.cells {
for cell in row {
if !is_markdown_image_reference(cell) {
continue;
}
if let Some(img) = doc.images.get(image_idx)
&& let Some(ocr) = &img.ocr_result
&& !ocr.content.is_empty()
{
*cell = format!("{}\n\n{}", cell.trim(), ocr.content);
}
image_idx += 1;
}
}
}
}
fn is_markdown_image_reference(text: &str) -> bool {
let t = text.trim();
if !t.starts_with(" else {
return false;
};
if bracket_end < 2 {
return false;
}
let after = &t[bracket_end + 2..];
after.ends_with(')')
}
fn normalize_nfc(result: &mut ExtractedDocument) {
#[cfg(feature = "quality")]
{
use unicode_normalization::UnicodeNormalization;
result.content = result.content.nfc().collect();
if let Some(pages) = result.pages.as_mut() {
for page in pages.iter_mut() {
page.content = page.content.nfc().collect();
}
}
}
let _ = result;
}
#[cfg(all(test, feature = "heuristics"))]
mod issue_214_text_coverage_tests {
use super::*;
use crate::types::PageContent;
fn page(page_number: u32, content: &str) -> PageContent {
PageContent {
page_number,
content: content.to_string(),
tables: Vec::new(),
image_indices: Vec::new(),
image_preprocessing: None,
hierarchy: None,
is_blank: None,
layout_regions: None,
speaker_notes: None,
section_name: None,
sheet_name: None,
ocr_confidence: None,
}
}
#[test]
fn measures_fraction_of_non_blank_pages() {
let result = ExtractedDocument {
pages: Some(vec![
page(1, "Real text here"),
page(2, " "),
page(3, "More real text"),
]),
..Default::default()
};
assert!(
(measure_text_coverage(&result) - (2.0 / 3.0)).abs() < f32::EPSILON,
"expected 2/3 non-blank pages, got {}",
measure_text_coverage(&result)
);
}
#[test]
fn measures_full_coverage_when_all_pages_have_text() {
let result = ExtractedDocument {
pages: Some(vec![page(1, "Text one"), page(2, "Text two")]),
..Default::default()
};
assert_eq!(measure_text_coverage(&result), 1.0);
}
#[test]
fn measures_zero_coverage_when_all_pages_are_blank() {
let result = ExtractedDocument {
pages: Some(vec![page(1, ""), page(2, " \n\t")]),
..Default::default()
};
assert_eq!(measure_text_coverage(&result), 0.0);
}
#[test]
fn falls_back_to_binary_signal_when_pages_are_absent() {
let with_text = ExtractedDocument {
pages: None,
content: "Some extracted text".to_string(),
..Default::default()
};
assert_eq!(measure_text_coverage(&with_text), 1.0);
let empty = ExtractedDocument {
pages: None,
content: String::new(),
..Default::default()
};
assert_eq!(measure_text_coverage(&empty), 0.0);
}
#[test]
fn falls_back_to_binary_signal_when_pages_is_empty_vec() {
let result = ExtractedDocument {
pages: Some(vec![]),
content: "Some text".to_string(),
..Default::default()
};
assert_eq!(measure_text_coverage(&result), 1.0);
}
}
#[cfg(all(test, not(feature = "tokio-runtime")))]
mod issue_219_sync_pipeline_ocr_text_options_tests {
use super::*;
use crate::core::config::extraction::ImageExtractionConfig;
use crate::types::ExtractedImage;
use crate::types::internal::{ElementKind, InternalDocument, InternalElement};
use std::borrow::Cow;
fn image_with_ocr_text(ocr_text: &str) -> ExtractedImage {
ExtractedImage {
data: bytes::Bytes::new(),
format: Cow::Borrowed("png"),
ocr_result: Some(Box::new(ExtractedDocument {
content: ocr_text.to_string(),
..Default::default()
})),
..Default::default()
}
}
#[test]
fn ocr_text_only_replaces_embedded_image_markdown_on_sync_pipeline() {
let mut doc = InternalDocument::new("pptx");
doc.push_element(InternalElement::text(ElementKind::Paragraph, "", 0));
doc.images = vec![image_with_ocr_text("Recognized OCR text")];
let config = ExtractionConfig {
images: Some(ImageExtractionConfig {
ocr_text_only: true,
..Default::default()
}),
..Default::default()
};
let result = run_pipeline_sync(doc, &config).unwrap();
assert!(
result.content.contains("Recognized OCR text"),
"sync pipeline must replace embedded image markdown with OCR text when \
ocr_text_only=true, got: {:?}",
result.content
);
assert!(
!result.content.contains("![img]"),
"sync pipeline must not leave the markdown image placeholder when ocr_text_only=true, got: {:?}",
result.content
);
}
#[test]
fn append_ocr_text_appends_after_embedded_image_markdown_on_sync_pipeline() {
let mut doc = InternalDocument::new("pptx");
doc.push_element(InternalElement::text(ElementKind::Paragraph, "", 0));
doc.images = vec![image_with_ocr_text("Appended OCR text")];
let config = ExtractionConfig {
images: Some(ImageExtractionConfig {
append_ocr_text: true,
..Default::default()
}),
..Default::default()
};
let result = run_pipeline_sync(doc, &config).unwrap();
assert!(
result.content.contains("![img]"),
"append_ocr_text must keep the original markdown placeholder, got: {:?}",
result.content
);
assert!(
result.content.contains("Appended OCR text"),
"sync pipeline must append the OCR text when append_ocr_text=true, got: {:?}",
result.content
);
}
#[test]
fn default_config_does_not_touch_embedded_image_markdown_on_sync_pipeline() {
let mut doc = InternalDocument::new("pptx");
doc.push_element(InternalElement::text(ElementKind::Paragraph, "", 0));
doc.images = vec![image_with_ocr_text("Should not appear")];
let config = ExtractionConfig::default();
let result = run_pipeline_sync(doc, &config).unwrap();
assert!(
result.content.contains("![img]"),
"default config must leave the markdown placeholder untouched, got: {:?}",
result.content
);
assert!(
!result.content.contains("Should not appear"),
"default config must not inject OCR text, got: {:?}",
result.content
);
}
}
#[cfg(all(test, feature = "chunking", feature = "quality", feature = "tokio-runtime"))]
mod issue_213_chunk_offset_ordering_tests {
use super::*;
use crate::core::config::{ChunkerType, ChunkingConfig, OutputFormat};
use crate::types::internal::{ElementKind, InternalDocument, InternalElement};
const DECOMPOSED: &str = "Cafe\u{0301} is served here with extra words to keep the chunk large enough to matter.";
#[tokio::test]
async fn chunk_offsets_index_the_final_normalized_content() {
let mut doc = InternalDocument::new("plain");
doc.push_element(InternalElement::text(ElementKind::Paragraph, DECOMPOSED, 0));
let config = ExtractionConfig {
output_format: OutputFormat::Plain,
chunking: Some(ChunkingConfig {
max_characters: 2000,
overlap: 0,
trim: true,
chunker_type: ChunkerType::Text,
..Default::default()
}),
..Default::default()
};
let result = run_pipeline(doc, &config).await.unwrap();
assert!(
!result.content.contains('\u{0301}'),
"expected NFC normalization to compose the combining accent away, got: {:?}",
result.content
);
assert!(
result.content.contains('é'),
"expected composed 'é' in: {:?}",
result.content
);
assert!(
result.content.len() < DECOMPOSED.len(),
"normalization must have shortened the content by at least one byte"
);
let chunks = result.chunks.expect("chunks must be populated");
assert_eq!(chunks.len(), 1);
for chunk in &chunks {
let slice = &result.content[chunk.metadata.byte_start..chunk.metadata.byte_end];
assert_eq!(
slice, chunk.content,
"chunk byte_start/byte_end must index the FINAL (post-normalization) content, \
not a pre-mutation snapshot"
);
}
}
}
#[cfg(test)]
mod issue_271_builtin_registration_warning_tests {
use super::*;
#[test]
fn pushes_a_warning_naming_the_registration_error() {
let mut doc = InternalDocument::new("plain");
push_builtin_registration_warning(&mut doc, Some("summarization: boom".to_string()));
assert_eq!(doc.processing_warnings.len(), 1);
assert_eq!(doc.processing_warnings[0].source, "builtin_registration");
assert_eq!(
doc.processing_warnings[0].message,
"built-in post-processor registration was incomplete (summarization: boom); a configured \
processor may silently produce no output for its stage"
);
}
#[test]
fn pushes_nothing_when_registration_succeeded() {
let mut doc = InternalDocument::new("plain");
push_builtin_registration_warning(&mut doc, None);
assert!(doc.processing_warnings.is_empty());
}
#[tokio::test]
#[serial_test::serial]
async fn run_pipeline_surfaces_a_forced_builtin_registration_failure() {
use crate::core::pipeline::initialization::test_support::set_registration_error;
use crate::types::internal::InternalDocument;
set_registration_error(Some("summarization: boom".to_string()));
let mut doc = InternalDocument::new("plain");
doc.mime_type = "text/plain".to_string();
let config = ExtractionConfig::default();
let result = run_pipeline(doc, &config).await;
set_registration_error(None);
let processed = result.expect("run_pipeline must still succeed despite the registration failure");
assert_eq!(processed.processing_warnings.len(), 1);
assert_eq!(processed.processing_warnings[0].source, "builtin_registration");
assert_eq!(
processed.processing_warnings[0].message,
"built-in post-processor registration was incomplete (summarization: boom); a configured \
processor may silently produce no output for its stage"
);
}
}