pdftract/crates/pdftract-core/src/profiles/extraction.rs
jedarden 62a36ea756 docs(pdftract-3eohy): add rustdoc examples to Glyph and Span types
- Add worked example to Glyph struct showing all 11 fields
- Add worked example to Span struct showing all 10 fields
- Examples use rust,no_run for internal dependencies
- cargo doc passes with docs.rs feature set
- Verification note added at notes/pdftract-3eohy.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:16:24 -04:00

464 lines
12 KiB
Rust

//! Extraction profile types (Phase 7.10).
//!
//! This module defines the rich extraction profile format that extends Phase 5.6
//! classification with extraction tuning and field extraction. Extraction profiles
//! use a boolean match DSL (all/any/none combinators) and can override extraction
//! options and extract structured fields.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Extraction profile with match DSL, extraction tuning, and field extraction.
///
/// This is the Phase 7.10 profile format, separate from the Phase 5.6 classification
/// `Profile` type. Extraction profiles drive both classification (via match DSL)
/// and extraction behavior (via tuning and field specs).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractionProfile {
/// Profile name (e.g., "invoice", "receipt")
pub name: String,
/// Human-readable description
pub description: String,
/// Priority for profile selection (higher = preferred when multiple match)
#[serde(default = "default_priority")]
pub priority: u32,
/// Match DSL expression (boolean tree with all/any/none combinators)
#[serde(default)]
pub match_expr: MatchExpr,
/// Extraction tuning overrides (optional)
#[serde(default)]
pub extraction: Option<ExtractionTuning>,
/// Field extraction specifications (optional)
#[serde(default)]
pub fields: HashMap<String, FieldSpec>,
}
fn default_priority() -> u32 {
10
}
/// Boolean match expression for document classification.
///
/// Supports all/any/none combinators for building complex matching rules.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MatchExpr {
/// Single predicate
Predicate(ExtractionMatchPredicate),
/// All of these must match
All {
/// All match expressions must evaluate to true
all: Vec<MatchExpr>
},
/// Any of these can match
Any {
/// At least one match expression must evaluate to true
any: Vec<MatchExpr>
},
/// None of these must match
None {
/// All match expressions must evaluate to false
none: Vec<MatchExpr>
},
}
impl Default for MatchExpr {
fn default() -> Self {
// Default to an Any that matches nothing (empty list)
MatchExpr::Any { any: Vec::new() }
}
}
/// Match predicate primitives for extraction profiles.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtractionMatchPredicate {
/// Text contains any of the given strings
TextContains {
/// Substring patterns to search for in document text
#[serde(default)]
patterns: Vec<String>,
},
/// Text matches the given regex
TextMatches {
/// Regular expression pattern to match against document text
pattern: String,
},
/// Heading text matches the given regex
HeadingMatches {
/// Regular expression pattern to match against heading text
pattern: String,
},
/// Document has currency pattern ($\d, €\d, etc.)
HasCurrencyPattern {
/// Must have currency pattern if true
#[serde(default)]
has_currency_pattern: bool,
},
/// Document has signature fields (AcroForm)
HasSignatureField {
/// Must have signature field if true
#[serde(default)]
has_signature_field: bool,
},
/// Structural predicates (has_table, page_count, etc.)
Structural {
/// Document contains a table if true
#[serde(default)]
has_table: bool,
/// Document contains a form field if true
#[serde(default)]
has_form_field: bool,
/// Document contains math notation if true
#[serde(default)]
has_math: bool,
/// Page count range constraint
#[serde(flatten)]
page_count: Option<PageCountRange>,
},
/// Text patterns alias for TextContains
#[serde(rename = "text_patterns")]
TextContainsAlias {
/// Substring patterns to search for in document text
#[serde(default)]
patterns: Vec<String>,
},
}
/// Page count range predicate.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageCountRange {
/// Minimum page count (inclusive)
#[serde(default)]
pub min: Option<u32>,
/// Maximum page count (inclusive)
#[serde(default)]
pub max: Option<u32>,
/// Human-readable hint for debugging
#[serde(default)]
pub hint: Option<String>,
}
/// Extraction tuning overrides.
///
/// These fields override the default ExtractionOptions when a profile matches.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractionTuning {
/// Reading order algorithm
pub reading_order: Option<String>,
/// Table detection mode
pub table_detection: Option<String>,
/// Readability threshold (0.0-1.0)
pub readability_threshold: Option<f32>,
/// Include invisible text
pub include_invisible: Option<bool>,
/// Include headers and footers
pub include_headers_footers: Option<bool>,
/// Zone filtering mode
pub zone_filtering: Option<String>,
/// Force OCR
pub force_ocr: Option<bool>,
/// Minimum block characters
pub min_block_chars: Option<usize>,
}
/// Field extraction specification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldSpec {
/// Field type (string, decimal, date, int, bool, array)
#[serde(rename = "type")]
pub field_type: String,
/// Extraction specification
pub extraction: FieldExtraction,
}
/// Field extraction definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FieldExtraction {
/// Simple pattern-based extraction
Patterns {
/// List of regex patterns to extract field value
patterns: Vec<String>,
/// Fallback value if no pattern matches
#[serde(default)]
fallback: Option<serde_yaml::Value>,
},
/// Rich extraction with localizers and extractors
Rich {
/// Regex pattern
#[serde(default)]
regex: Option<String>,
/// Near anchors (search near these strings)
#[serde(default)]
near: Option<Vec<String>>,
/// Maximum distance in points
#[serde(default)]
max_distance_pt: Option<usize>,
/// Region specification
#[serde(default)]
region: Option<String>,
/// Pick strategy (largest_font, smallest_font, nearest_below, nearest_right, first, last)
#[serde(default)]
pick: Option<String>,
/// Parse type (decimal, date, int, bool, string)
#[serde(default)]
parse: Option<String>,
/// After field (for ordering)
#[serde(default)]
after: Option<String>,
/// After heading
#[serde(default)]
after_heading: Option<String>,
/// Table region for array fields
#[serde(default)]
table_region: Option<String>,
/// Columnar regions for array fields
#[serde(default)]
columnar_regions: Option<String>,
/// Array schema for structured data
#[serde(default)]
schema: Option<Vec<FieldSchema>>,
/// Fallback value
#[serde(default)]
fallback: Option<serde_yaml::Value>,
},
}
/// Schema field for array extraction.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldSchema {
/// Field name in the output schema
pub name: String,
/// Field type (string, decimal, date, int, bool, array)
#[serde(rename = "type")]
pub field_type: String,
/// Whether this field is required in the output
#[serde(default)]
pub required: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extraction_profile_basic() {
let yaml = r#"
name: test
description: Test profile
priority: 50
"#;
let profile: ExtractionProfile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(profile.name, "test");
assert_eq!(profile.description, "Test profile");
assert_eq!(profile.priority, 50);
}
#[test]
fn test_match_expr_all() {
let yaml = r#"
match:
all:
- text_contains:
patterns: ["invoice", "bill"]
- structural:
has_table: true
"#;
let expr: MatchExpr = serde_yaml::from_str(yaml).unwrap();
match expr {
MatchExpr::All { all } => {
assert_eq!(all.len(), 2);
}
_ => panic!("Expected All"),
}
}
#[test]
fn test_match_expr_any() {
let yaml = r#"
match:
any:
- text_contains:
patterns: ["receipt"]
- text_matches:
pattern: "\\d+\\.\\d{2}"
"#;
let expr: MatchExpr = serde_yaml::from_str(yaml).unwrap();
match expr {
MatchExpr::Any { any } => {
assert_eq!(any.len(), 2);
}
_ => panic!("Expected Any"),
}
}
#[test]
fn test_match_expr_none() {
let yaml = r#"
match:
none:
- text_contains:
patterns: ["abstract", "bibliography"]
"#;
let expr: MatchExpr = serde_yaml::from_str(yaml).unwrap();
match expr {
MatchExpr::None { none } => {
assert_eq!(none.len(), 1);
}
_ => panic!("Expected None"),
}
}
#[test]
fn test_extraction_tuning() {
let yaml = r#"
extraction:
reading_order: xy_cut
table_detection: strict_borders
readability_threshold: 0.4
include_invisible: false
"#;
let tuning: ExtractionTuning = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tuning.reading_order, Some("xy_cut".to_string()));
assert_eq!(tuning.table_detection, Some("strict_borders".to_string()));
assert_eq!(tuning.readability_threshold, Some(0.4));
assert_eq!(tuning.include_invisible, Some(false));
}
#[test]
fn test_field_spec_simple() {
let yaml = r#"
total:
type: decimal
extraction:
patterns:
- "\\$\\s*(\\d+\\.\\d{2})"
fallback: null
"#;
let field: FieldSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(field.field_type, "decimal");
match field.extraction {
FieldExtraction::Patterns { patterns, .. } => {
assert_eq!(patterns.len(), 1);
}
_ => panic!("Expected Patterns"),
}
}
#[test]
fn test_field_spec_rich() {
let yaml = r#"
invoice_number:
type: string
extraction:
regex: "Invoice\\s*#\\s*([\\w-]+)"
near: ["Invoice", "Invoice Number"]
max_distance_pt: 200
"#;
let field: FieldSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(field.field_type, "string");
match field.extraction {
FieldExtraction::Rich { regex, near, max_distance_pt, .. } => {
assert!(regex.is_some());
assert!(near.is_some());
assert_eq!(max_distance_pt, Some(200));
}
_ => panic!("Expected Rich"),
}
}
#[test]
fn test_full_profile_roundtrip() {
let yaml = r#"
name: invoice
description: Commercial invoice with line items
priority: 50
match:
all:
- any:
- text_contains:
patterns: ["invoice", "bill to"]
- heading_matches:
pattern: "^Invoice\\b"
- structural:
has_table: true
extraction:
reading_order: line_dominant
table_detection: strict_borders
readability_threshold: 0.4
fields:
invoice_number:
type: string
extraction:
regex: "Invoice\\s*#\\s*([\\w-]+)"
near: ["Invoice"]
total:
type: decimal
extraction:
patterns:
- "total.*([\\d,]+\\.\\d{2})"
fallback: null
"#;
let profile: ExtractionProfile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(profile.name, "invoice");
assert_eq!(profile.priority, 50);
assert!(profile.extraction.is_some());
assert_eq!(profile.fields.len(), 2);
// Round-trip
let yaml_out = serde_yaml::to_string(&profile).unwrap();
let profile2: ExtractionProfile = serde_yaml::from_str(&yaml_out).unwrap();
assert_eq!(profile2.name, profile.name);
}
}