pub mod detect;
pub mod error;
pub mod ingest;
pub mod redact;
pub mod types;
pub use error::EngineError;
pub use types::{
DetectionSource, DocumentFormat, Entity, ExtractedDocument, Input, OutputFormat,
RedactionResult,
};
use detect::TierA;
use ingest::{DefaultIngestor, Ingestor};
pub const DEFAULT_OCR_TILING: u32 = 2;
fn merge_by_box(found: &mut Vec<Entity>, extra: Vec<Entity>) {
for e in extra {
let Some(nb) = e.bbox else { continue };
let dup = found.iter().any(|f| match f.bbox {
Some(b) => {
f.entity_type == e.entity_type
&& b.page == nb.page
&& (nb.x + nb.width / 2.0) >= b.x
&& (nb.x + nb.width / 2.0) <= b.x + b.width
&& (nb.y + nb.height / 2.0) >= b.y
&& (nb.y + nb.height / 2.0) <= b.y + b.height
}
None => false,
});
if !dup {
found.push(e);
}
}
}
pub struct Engine {
ingestor: Box<dyn Ingestor>,
tier_a: TierA,
liteparse_config: liteparse::config::LiteParseConfig,
ocr_tiling: u32,
}
impl Default for Engine {
fn default() -> Self {
Self::with_liteparse_config(liteparse::config::LiteParseConfig::default())
}
}
impl Engine {
pub fn new(ingestor: Box<dyn Ingestor>) -> Self {
Self {
ingestor,
tier_a: TierA::default(),
liteparse_config: liteparse::config::LiteParseConfig::default(),
ocr_tiling: DEFAULT_OCR_TILING,
}
}
pub fn with_liteparse_config(config: liteparse::config::LiteParseConfig) -> Self {
Self {
ingestor: Box::new(DefaultIngestor::with_liteparse_config(config.clone())),
tier_a: TierA::default(),
liteparse_config: config,
ocr_tiling: DEFAULT_OCR_TILING,
}
}
pub async fn process(
&self,
input: Input,
format: OutputFormat,
entities: Option<&[String]>,
score_threshold: Option<f32>,
) -> Result<RedactionResult, EngineError> {
let (input, doc, found, _needs_boxes) = self
.detect_phase(input, format, entities, score_threshold)
.await?;
self.redact_phase(input, doc, found, format).await
}
#[cfg(feature = "tier-b")]
pub async fn process_with_tier_b(
&self,
input: Input,
format: OutputFormat,
entities: Option<&[String]>,
score_threshold: Option<f32>,
tier_b: &detect::TierB,
language: &str,
) -> Result<RedactionResult, EngineError> {
let (input, doc, mut found, needs_boxes) = self
.detect_phase(input, format, entities, score_threshold)
.await?;
let mut extra: Vec<Entity> = tier_b
.analyze(&doc.text, language)
.await?
.into_iter()
.filter(|e| {
let entity_ok =
entities.is_none_or(|wanted| wanted.iter().any(|w| w == &e.entity_type));
let score_ok = score_threshold.is_none_or(|t| e.score >= t);
let overlaps_tier_a = found
.iter()
.any(|a| a.span.start < e.span.end && e.span.start < a.span.end);
entity_ok && score_ok && !overlaps_tier_a
})
.collect();
detect::attach_bboxes(&mut extra, &doc.word_boxes);
if needs_boxes {
let unplaceable = detect::unplaceable_types(&extra);
if !unplaceable.is_empty() {
return Err(EngineError::Redact(format!(
"Tier B found {} entit{} ({}) whose text span could not be matched to any OCR word box, so a pixel redaction would silently leave {} visible. Refusing to produce a document that looks redacted but isn't; request OutputFormat::Markdown for a text-level redaction of this input instead.",
unplaceable.len(),
if unplaceable.len() == 1 { "y" } else { "ies" },
unplaceable.join(", "),
if unplaceable.len() == 1 { "it" } else { "them" },
)));
}
}
found.extend(extra);
self.redact_phase(input, doc, found, format).await
}
async fn detect_phase(
&self,
input: Input,
format: OutputFormat,
entities: Option<&[String]>,
score_threshold: Option<f32>,
) -> Result<(Input, ExtractedDocument, Vec<Entity>, bool), EngineError> {
if format == OutputFormat::Native && matches!(input, Input::Document { .. }) {
return Err(EngineError::Unsupported(
"OutputFormat::Native has no meaning for a Document input — anydoc only converts \
to markdown, never back to DOCX/XLSX/etc.; request OutputFormat::Markdown instead"
.into(),
));
}
let needs_boxes =
format == OutputFormat::Native && matches!(input, Input::Pdf(_) | Input::Image(_));
let mut input = match input {
Input::Image(bytes) => {
Input::Image(ingest::normalize_orientation(&bytes).unwrap_or(bytes))
}
other => other,
};
let mut doc = self.ingestor.ingest(&input, needs_boxes).await?;
let mut found = self.tier_a.analyze(&doc, entities, score_threshold);
if found.is_empty() && self.ocr_tiling > 0 {
if let Input::Image(ref bytes) = input {
let ladder = [90.0f32, 180.0, 270.0, 10.0, -10.0, 15.0, -15.0, 20.0, -20.0];
for degrees in ladder {
let rotated = if degrees == 90.0 || degrees == 180.0 || degrees == 270.0 {
ingest::rotate_bytes(bytes, degrees as u32)
} else {
ingest::rotate_bytes_fine(bytes, degrees)
};
let Some(rotated) = rotated else {
continue;
};
let candidate = Input::Image(rotated.clone());
let Ok(rdoc) = self.ingestor.ingest(&candidate, needs_boxes).await else {
continue;
};
let rfound = self.tier_a.analyze(&rdoc, entities, score_threshold);
if !rfound.is_empty() {
input = candidate;
doc = rdoc;
found = rfound;
break;
}
}
}
}
if needs_boxes && self.ocr_tiling > 1 {
if let Input::Image(ref bytes) = input {
let tiler = ingest::LiteparseIngestor::with_config(self.liteparse_config.clone());
if let Ok(tiled) = tiler.ingest_image_tiled(bytes, self.ocr_tiling, 0.15).await {
let extra = self.tier_a.analyze(&tiled, entities, score_threshold);
merge_by_box(&mut found, extra);
}
}
}
Ok((input, doc, found, needs_boxes))
}
async fn redact_phase(
&self,
input: Input,
doc: ExtractedDocument,
entities: Vec<Entity>,
format: OutputFormat,
) -> Result<RedactionResult, EngineError> {
if format == OutputFormat::Markdown {
return Ok(redact::redact_text(&doc, &entities, format));
}
match input {
Input::Text(_) | Input::Document { .. } => {
Ok(redact::redact_text(&doc, &entities, format))
}
Input::Image(bytes) => {
let redacted_bytes =
redact::redact_image_bytes(&bytes, &entities, crate::ingest::IMAGE_OCR_DPI)?;
Ok(RedactionResult {
format,
bytes: Some(redacted_bytes),
entities,
..Default::default()
})
}
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
Input::Pdf(bytes) => {
#[cfg(target_arch = "wasm32")]
{
let _ = &self.liteparse_config; Err(EngineError::Unsupported(
"pixel-level PDF redaction is unavailable in a wasm32 build — request \
OutputFormat::Markdown instead, or use a native/Node/Python build for \
OutputFormat::Native"
.into(),
))
}
#[cfg(not(target_arch = "wasm32"))]
{
let redacted_bytes =
redact::redact_pdf_bytes(&bytes, &entities, &self.liteparse_config).await?;
Ok(RedactionResult {
format,
bytes: Some(redacted_bytes),
entities,
..Default::default()
})
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{BoundingBox, DetectionSource, Span};
fn boxed(entity_type: &str, x: f32, y: f32) -> Entity {
Entity {
entity_type: entity_type.into(),
span: Span { start: 0, end: 4 },
score: 1.0,
bbox: Some(BoundingBox {
page: 1,
x,
y,
width: 20.0,
height: 10.0,
}),
source: DetectionSource::TierA,
}
}
#[test]
fn merge_keeps_a_genuinely_separate_second_occurrence() {
let mut found = vec![boxed("FR_NIR", 100.0, 100.0)];
merge_by_box(&mut found, vec![boxed("FR_NIR", 100.0, 4000.0)]);
assert_eq!(found.len(), 2, "a second, distant occurrence must be kept");
}
#[test]
fn merge_drops_the_same_box_seen_twice_across_overlapping_tiles() {
let mut found = vec![boxed("FR_NIR", 100.0, 100.0)];
merge_by_box(&mut found, vec![boxed("FR_NIR", 101.0, 101.0)]);
assert_eq!(found.len(), 1, "an overlapping duplicate must be dropped");
}
#[test]
fn merge_keeps_a_different_entity_type_at_the_same_spot() {
let mut found = vec![boxed("FR_NIR", 100.0, 100.0)];
merge_by_box(&mut found, vec![boxed("IBAN_CODE", 100.0, 100.0)]);
assert_eq!(found.len(), 2);
}
#[test]
fn merge_ignores_boxless_entities() {
let mut found = vec![boxed("FR_NIR", 100.0, 100.0)];
let mut boxless = boxed("PERSON", 0.0, 0.0);
boxless.bbox = None;
merge_by_box(&mut found, vec![boxless]);
assert_eq!(found.len(), 1);
}
#[tokio::test]
async fn redacts_fr_nir_in_plain_text() {
let engine = Engine::default();
let result = engine
.process(
Input::Text("mon NIR est 185017512345609, merci.".to_string()),
OutputFormat::Native,
None,
None,
)
.await
.expect("text pipeline never hits the unimplemented pixel path");
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].entity_type, "FR_NIR");
assert!(!result.text.unwrap().contains("185017512345609"));
}
#[tokio::test]
async fn entities_filter_redacts_only_the_requested_type() {
let engine = Engine::default();
let text = "email: john@example.com, nir: 185017512345609".to_string();
let filtered = engine
.process(
Input::Text(text.clone()),
OutputFormat::Native,
Some(&["FR_NIR".to_string()]),
None,
)
.await
.unwrap();
assert_eq!(filtered.entities.len(), 1);
assert_eq!(filtered.entities[0].entity_type, "FR_NIR");
let redacted = filtered.text.unwrap();
assert!(redacted.contains("john@example.com")); assert!(!redacted.contains("185017512345609"));
let unfiltered = engine
.process(Input::Text(text), OutputFormat::Native, None, None)
.await
.unwrap();
assert_eq!(unfiltered.entities.len(), 2); }
#[tokio::test]
async fn score_threshold_drops_low_confidence_matches() {
let engine = Engine::default();
let text = "email: john@example.com, nir: 185017512345609".to_string();
let result = engine
.process(Input::Text(text), OutputFormat::Native, None, Some(0.95))
.await
.unwrap();
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].entity_type, "FR_NIR");
assert!(result.text.unwrap().contains("john@example.com")); }
}