pub const MAX_SOURCE_BYTES: u64 = 128 * 1024 * 1024;
pub const MAX_PART_BYTES: u64 = 64 * 1024 * 1024;
pub const MAX_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_SHEET_ROWS: u32 = 1_048_576;
pub const MAX_SHEET_COLUMNS: u32 = 16_384;
pub const MAX_SHEET_CELLS: u64 = 10_000_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecordsLimits {
pub max_records: u64,
pub max_tables: u64,
pub max_output_bytes: u64,
}
impl Default for RecordsLimits {
fn default() -> RecordsLimits {
RecordsLimits {
max_records: 100_000,
max_tables: 256,
max_output_bytes: 64 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageOcrLimits {
pub wall_timeout_secs: u64,
pub max_response_bytes: u32,
pub max_stderr_bytes: u64,
pub address_space_bytes: u64,
pub cpu_seconds: u64,
pub file_size_bytes: u64,
pub max_processes: u64,
#[serde(default)]
pub inventory: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub svg_provider: std::collections::BTreeMap<String, provider::ProviderPin>,
}
impl Default for ImageOcrLimits {
fn default() -> ImageOcrLimits {
ImageOcrLimits {
wall_timeout_secs: 300,
max_response_bytes: 8 * 1024 * 1024,
max_stderr_bytes: 4 * 1024 * 1024,
address_space_bytes: 64 * 1024 * 1024 * 1024,
cpu_seconds: 600,
file_size_bytes: 64 * 1024 * 1024,
max_processes: 64,
inventory: std::collections::BTreeMap::new(),
svg_provider: std::collections::BTreeMap::new(),
}
}
}
pub const IMAGE_OCR_MAX_AREA_PX: u64 = 2_359_296;
pub const IMAGE_OCR_VALIDATED_LONG_EDGE: u32 = 1600;
pub const IMAGE_OCR_DECODE_ALLOC: u64 = 64 * 1024 * 1024;
pub(crate) const IMAGE_OCR_ROLE_LABELS: [&str; 5] = [
"engine-cli",
"vision-weights",
"vision-projector",
"ocr-prompt",
"ocr-limit-policy",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AsrProfile {
pub max_duration_seconds: u64,
pub wall_timeout_secs: u64,
pub cpu_seconds: u64,
pub max_resident_bytes: u64,
pub max_response_bytes: u32,
pub max_stderr_bytes: u64,
pub file_size_bytes: u64,
pub max_processes: u64,
pub language: String,
pub inventory: std::collections::BTreeMap<String, String>,
}
fn is_lower_hex_256(value: &str) -> bool {
value.len() == 64
&& value
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
}
fn validate_asr_profile(profile: &AsrProfile, name: &str) -> Result<()> {
let rules_error = |message: String| Error::Rules {
name: name.to_string(),
message,
};
if profile.language != "en" {
return Err(rules_error(format!(
"[asr] language {:?} is not supported: the built-in decode policy pins \"en\"",
profile.language
)));
}
let required_roles = [
subprocess::ASR_ROLE_CLI,
subprocess::ASR_ROLE_WEIGHTS,
subprocess::ASR_ROLE_DECODE_POLICY,
];
let allowed_roles = [
subprocess::ASR_ROLE_CLI,
subprocess::ASR_ROLE_WEIGHTS,
subprocess::ASR_ROLE_DECODE_POLICY,
subprocess::ASR_ROLE_PROBE,
];
for role in required_roles {
if !profile.inventory.contains_key(role) {
return Err(rules_error(format!(
"[asr.inventory] is missing role {role:?}"
)));
}
}
for (role, hash) in &profile.inventory {
if !allowed_roles.contains(&role.as_str()) {
return Err(rules_error(format!(
"[asr.inventory] names unknown role {role:?}"
)));
}
if !is_lower_hex_256(hash) {
return Err(rules_error(format!(
"[asr.inventory] role {role:?} is not a lowercase hex BLAKE3"
)));
}
}
let built_in = crate::hash::hash_bytes(subprocess::ASR_DECODE_POLICY.as_bytes());
if profile.inventory[subprocess::ASR_ROLE_DECODE_POLICY] != built_in {
return Err(rules_error(
"[asr.inventory] asr-decode-policy does not match the built-in decode \
serialization"
.to_string(),
));
}
Ok(())
}
fn validate_image_ocr_limits(limits: &ImageOcrLimits, name: &str) -> Result<()> {
provider::validate_pins(&limits.svg_provider).map_err(|message| Error::Rules {
name: name.to_string(),
message,
})?;
for (role, hash) in &limits.inventory {
if !IMAGE_OCR_ROLE_LABELS.contains(&role.as_str()) {
return Err(Error::Rules {
name: name.to_string(),
message: format!("[image_ocr.inventory] names unknown role {role:?}"),
});
}
if !is_lower_hex_256(hash) {
return Err(Error::Rules {
name: name.to_string(),
message: format!(
"[image_ocr.inventory] role {role:?} is not a lowercase hex BLAKE3"
),
});
}
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct RuntimeInventory {
paths: std::collections::BTreeMap<String, std::path::PathBuf>,
}
impl RuntimeInventory {
pub fn empty() -> RuntimeInventory {
RuntimeInventory::default()
}
pub fn known_roles() -> impl Iterator<Item = &'static str> {
IMAGE_OCR_ROLE_LABELS
.into_iter()
.chain(subprocess::ASR_FILE_ROLE_LABELS)
}
pub fn set(&mut self, role: &str, path: std::path::PathBuf) -> Result<()> {
let rules_error = |message: String| Error::Rules {
name: "runtime-inventory".to_string(),
message,
};
if !Self::known_roles().any(|known| known == role) {
return Err(rules_error(format!("unknown runtime role {role:?}")));
}
if !path.is_absolute() {
return Err(rules_error(format!(
"runtime role {role:?} needs an absolute path"
)));
}
let metadata = std::fs::symlink_metadata(&path)
.map_err(|e| rules_error(format!("runtime role {role:?} cannot be inspected: {e}")))?;
if !metadata.file_type().is_file() {
return Err(rules_error(format!(
"runtime role {role:?} is not a literal regular file"
)));
}
self.paths.insert(role.to_string(), path);
Ok(())
}
pub fn path(&self, role: &str) -> Option<&std::path::Path> {
self.paths.get(role).map(std::path::PathBuf::as_path)
}
pub fn parse(text: &str, name: &str) -> Result<RuntimeInventory> {
let raw: std::collections::BTreeMap<String, String> =
toml::from_str(text).map_err(|e| Error::Rules {
name: name.to_string(),
message: e.to_string(),
})?;
let mut inventory = RuntimeInventory::empty();
for (role, path) in raw {
inventory.set(&role, std::path::PathBuf::from(path))?;
}
Ok(inventory)
}
pub fn load(path: &std::path::Path) -> Result<RuntimeInventory> {
let text = std::fs::read_to_string(path).map_err(|e| Error::io("read", path, e))?;
RuntimeInventory::parse(&text, &path.display().to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageMetadataLimits {
pub max_decompressed_bytes: u64,
pub max_xml_depth: u32,
pub max_xml_events: u64,
pub max_boxes: u64,
pub max_rows: u64,
pub max_output_bytes: u64,
}
impl Default for ImageMetadataLimits {
fn default() -> ImageMetadataLimits {
ImageMetadataLimits {
max_decompressed_bytes: 16 * 1024 * 1024,
max_xml_depth: 100,
max_xml_events: 1_000_000,
max_boxes: 10_000,
max_rows: 4096,
max_output_bytes: 16 * 1024 * 1024,
}
}
}
use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use crate::detect::RESERVED_FORMAT_IDS;
use crate::segments::Segment;
use crate::{Error, Result};
mod anydoc_log;
pub mod container;
mod differential;
mod document;
pub mod eml;
pub mod html;
pub mod pdf;
#[cfg(all(unix, feature = "records-worker"))]
pub(crate) mod records;
#[cfg(all(unix, feature = "image-ocr"))]
pub(crate) mod image_ocr;
#[cfg(all(unix, feature = "audio-asr"))]
pub(crate) mod audio_asr;
pub mod image_metadata;
pub mod provider;
pub mod subprocess;
#[cfg(all(unix, feature = "svg-provider"))]
pub(crate) mod svg_raster;
mod visibility;
mod workbook;
pub use container::{
CONTAINER_ZIP_ID, CONTAINER_ZIP_VERSION, ContainerLimits, Inflated, ZipContainer,
preflight_entry_count,
};
pub use differential::{
DiffMetrics, DiffVerdict, DifferentialOutcome, compare_texts, normalize_for_diff,
};
pub use document::{AnydocDocument, markdown_to_plain};
pub use eml::{EmlExpansion, EmlMime, expand_eml};
pub use html::HtmlStrip;
pub use subprocess::{PdfSubprocess, RecordsSubprocess};
pub use visibility::{IndexRange, SheetVisibility, WorkbookVisibility, read_visibility};
pub use workbook::WorkbookIr;
#[derive(Debug, Clone)]
pub struct Outcome {
pub converter_id: String,
pub converter_version: String,
pub detected_format: String,
pub text: String,
pub warnings: Vec<String>,
pub segments: Vec<Segment>,
pub artifact_kind: crate::manifest::ArtifactKind,
pub media: Option<crate::manifest::Media>,
}
#[derive(Debug, Clone)]
pub struct ConvertError {
pub code: &'static str,
pub message: String,
}
impl fmt::Display for ConvertError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ConvertError {}
pub trait Converter {
fn id(&self) -> &'static str;
fn version(&self) -> &'static str;
fn convert(
&self,
source: &[u8],
detected_format: &str,
) -> std::result::Result<Outcome, ConvertError>;
}
pub fn normalize_text(input: &str) -> String {
let mut unified = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '\r' {
if chars.peek() == Some(&'\n') {
chars.next();
}
unified.push('\n');
} else {
unified.push(c);
}
}
unified.nfc().collect()
}
pub(crate) fn is_invisible_format(c: char) -> bool {
matches!(
c as u32,
0x00AD | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0xFEFF
)
}
pub(crate) fn is_meaningful(c: char) -> bool {
!c.is_whitespace() && !c.is_control() && !is_invisible_format(c)
}
pub struct PlainTextPassthrough;
pub const NO_CONVERTER_REASON: &str = "no-converter";
pub const RECORDS_NOT_BUILT_REASON: &str = "records-worker-not-built";
pub const IMAGE_OCR_NOT_BUILT_REASON: &str = "image-ocr-not-built";
pub const SVG_PROVIDER_NOT_BUILT: &str = "svg-provider-not-built";
pub const AUDIO_ASR_NOT_BUILT_REASON: &str = "audio-asr-not-built";
pub const ENGINE_UNPINNED_REASON: &str = "engine-unpinned";
pub const PASSTHROUGH_ID: &str = "text-passthrough";
pub const PASSTHROUGH_VERSION: &str = "1.2.0";
fn decode_utf16_pairs(
bytes: &[u8],
big_endian: bool,
mark_len: usize,
) -> std::result::Result<String, ConvertError> {
if !bytes.len().is_multiple_of(2) {
return Err(ConvertError {
code: "invalid_utf16",
message: format!("odd trailing byte at byte {}", mark_len + bytes.len() - 1),
});
}
let units = bytes.chunks_exact(2).map(|pair| {
if big_endian {
u16::from_be_bytes([pair[0], pair[1]])
} else {
u16::from_le_bytes([pair[0], pair[1]])
}
});
let mut decoded = String::with_capacity(bytes.len() / 2);
let mut consumed_units = 0usize;
for result in char::decode_utf16(units) {
match result {
Ok(c) => {
decoded.push(c);
consumed_units += c.len_utf16();
}
Err(_) => {
return Err(ConvertError {
code: "invalid_utf16",
message: format!(
"unpaired surrogate at byte {}",
mark_len + consumed_units * 2
),
});
}
}
}
Ok(decoded)
}
impl Converter for PlainTextPassthrough {
fn id(&self) -> &'static str {
PASSTHROUGH_ID
}
fn version(&self) -> &'static str {
PASSTHROUGH_VERSION
}
fn convert(
&self,
source: &[u8],
detected_format: &str,
) -> std::result::Result<Outcome, ConvertError> {
let source_len = source.len();
let mut warnings = Vec::new();
let raw = if source.starts_with(&[0xFF, 0xFE, 0x00, 0x00])
|| source.starts_with(&[0x00, 0x00, 0xFE, 0xFF])
{
return Err(ConvertError {
code: "unsupported-encoding",
message: "utf-32 byte order mark".to_string(),
});
} else if let Some(rest) = source.strip_prefix(&[0xFF, 0xFE][..]) {
warnings.push("transcoded from utf-16le".to_string());
decode_utf16_pairs(rest, false, 2)?
} else if let Some(rest) = source.strip_prefix(&[0xFE, 0xFF][..]) {
warnings.push("transcoded from utf-16be".to_string());
decode_utf16_pairs(rest, true, 2)?
} else {
let raw = String::from_utf8(source.to_vec()).map_err(|e| ConvertError {
code: "invalid_utf8",
message: format!("invalid UTF-8 at byte {}", e.utf8_error().valid_up_to()),
})?;
match raw.strip_prefix('\u{feff}') {
Some(stripped) => {
warnings.push("stripped leading byte order mark".to_string());
stripped.to_string()
}
None => raw,
}
};
if let Some(offset) = raw.find('\0') {
return Err(ConvertError {
code: "nul_bytes",
message: format!(
"NUL at byte {offset} of the decoded text, the bytes may be \
BOM-less UTF-16, re-export as UTF-8"
),
});
}
let text = normalize_text(&raw);
if source_len > 0 && text.is_empty() {
return Err(ConvertError {
code: "empty_output",
message: format!("source is {source_len} bytes but conversion produced no text"),
});
}
let segments = vec![Segment::span(0, text.len(), "document")];
Ok(Outcome {
converter_id: PASSTHROUGH_ID.to_string(),
converter_version: PASSTHROUGH_VERSION.to_string(),
detected_format: detected_format.to_string(),
text,
warnings,
segments,
artifact_kind: crate::manifest::ArtifactKind::Text,
media: None,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawRegistry {
version: String,
converters: Vec<RawEntry>,
#[serde(default)]
unsupported: Vec<RawUnsupported>,
#[serde(default)]
containers: Option<ContainerLimits>,
#[serde(default)]
records: Option<RecordsLimits>,
#[serde(default)]
image_ocr: Option<ImageOcrLimits>,
#[serde(default)]
image_metadata: Option<ImageMetadataLimits>,
#[serde(default)]
asr: Option<AsrProfile>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawEntry {
id: String,
version: String,
formats: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawUnsupported {
reason: String,
formats: Vec<String>,
}
pub struct Registry {
version: String,
converters: Vec<Box<dyn Converter>>,
by_format: HashMap<String, usize>,
unsupported_reasons: HashMap<String, String>,
container_limits: ContainerLimits,
records_limits: RecordsLimits,
image_ocr_limits: ImageOcrLimits,
image_metadata_limits: ImageMetadataLimits,
asr_profile: Option<AsrProfile>,
svg_ocr_index: Option<usize>,
#[cfg(all(
unix,
feature = "image-ocr",
feature = "svg-provider",
feature = "test-adapters"
))]
svg_provider: Option<provider::SvgProvider>,
image_metadata_index: Option<usize>,
}
impl Registry {
pub fn builtin() -> Result<Self> {
Self::parse(
include_str!("../../rules/converters.toml"),
"converters.toml",
)
}
pub fn builtin_with_inventory(inventory: &RuntimeInventory) -> Result<Self> {
Self::parse_with_inventory(
include_str!("../../rules/converters.toml"),
"converters.toml",
inventory,
)
}
pub fn builtin_with_runtime(
inventory: &RuntimeInventory,
provider: Option<&provider::ProviderConfig>,
) -> Result<Self> {
Self::parse_with_runtime(
include_str!("../../rules/converters.toml"),
"converters.toml",
inventory,
provider,
)
}
pub fn parse(text: &str, name: &str) -> Result<Self> {
Self::parse_with_inventory(text, name, &RuntimeInventory::empty())
}
#[cfg_attr(
not(all(unix, any(feature = "image-ocr", feature = "audio-asr"))),
allow(unused_variables)
)]
pub fn parse_with_inventory(
text: &str,
name: &str,
inventory: &RuntimeInventory,
) -> Result<Self> {
Self::parse_with_runtime(text, name, inventory, None)
}
#[cfg_attr(
not(all(unix, any(feature = "image-ocr", feature = "audio-asr"))),
allow(unused_variables)
)]
pub fn parse_with_runtime(
text: &str,
name: &str,
inventory: &RuntimeInventory,
provider: Option<&provider::ProviderConfig>,
) -> Result<Self> {
let raw: RawRegistry = toml::from_str(text).map_err(|e| Error::Rules {
name: name.to_string(),
message: e.to_string(),
})?;
let records_limits = raw.records.clone().unwrap_or_default();
let image_ocr_limits = raw.image_ocr.clone().unwrap_or_default();
let image_metadata_limits = raw.image_metadata.clone().unwrap_or_default();
validate_image_ocr_limits(&image_ocr_limits, name)?;
let asr_profile = raw.asr.clone();
if let Some(profile) = &asr_profile {
validate_asr_profile(profile, name)?;
}
let mut converters: Vec<Box<dyn Converter>> = Vec::new();
let mut by_format = HashMap::new();
let mut unsupported_reasons = HashMap::new();
#[allow(unused_mut)]
let mut image_metadata_index: Option<usize> = None;
#[allow(unused_mut)]
let mut svg_ocr_index: Option<usize> = None;
for entry in &raw.converters {
#[cfg(not(feature = "records-worker"))]
if entry.id == subprocess::RECORDS_SUBPROCESS_ID {
for format in &entry.formats {
if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
return Err(Error::Rules {
name: name.to_string(),
message: format!("format id {format:?} is reserved"),
});
}
if by_format.contains_key(format)
|| unsupported_reasons
.insert(format.clone(), RECORDS_NOT_BUILT_REASON.to_string())
.is_some()
{
return Err(Error::Rules {
name: name.to_string(),
message: format!("format {format:?} claimed twice"),
});
}
}
continue;
}
#[cfg(not(all(unix, feature = "image-ocr")))]
if entry.id == subprocess::IMAGE_PIXEL_OCR_ID {
for format in &entry.formats {
if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
return Err(Error::Rules {
name: name.to_string(),
message: format!("format id {format:?} is reserved"),
});
}
if by_format.contains_key(format)
|| unsupported_reasons
.insert(format.clone(), IMAGE_OCR_NOT_BUILT_REASON.to_string())
.is_some()
{
return Err(Error::Rules {
name: name.to_string(),
message: format!("format {format:?} claimed twice"),
});
}
}
continue;
}
#[cfg(not(all(unix, feature = "audio-asr")))]
if entry.id == subprocess::ASR_ADAPTER_ID {
for format in &entry.formats {
if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
return Err(Error::Rules {
name: name.to_string(),
message: format!("format id {format:?} is reserved"),
});
}
if by_format.contains_key(format)
|| unsupported_reasons
.insert(format.clone(), AUDIO_ASR_NOT_BUILT_REASON.to_string())
.is_some()
{
return Err(Error::Rules {
name: name.to_string(),
message: format!("format {format:?} claimed twice"),
});
}
}
continue;
}
#[cfg(all(
unix,
feature = "audio-asr",
not(all(target_os = "macos", target_arch = "aarch64"))
))]
if entry.id == subprocess::ASR_ADAPTER_ID {
for format in &entry.formats {
if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
return Err(Error::Rules {
name: name.to_string(),
message: format!("format id {format:?} is reserved"),
});
}
if by_format.contains_key(format)
|| unsupported_reasons
.insert(format.clone(), ENGINE_UNPINNED_REASON.to_string())
.is_some()
{
return Err(Error::Rules {
name: name.to_string(),
message: format!("format {format:?} claimed twice"),
});
}
}
continue;
}
#[cfg(not(all(unix, feature = "image-metadata")))]
if entry.id == image_metadata::IMAGE_METADATA_ID {
continue;
}
let converter: Box<dyn Converter> = match entry.id.as_str() {
PASSTHROUGH_ID => Box::new(PlainTextPassthrough),
html::HTML_STRIP_ID => Box::new(HtmlStrip),
eml::EML_ID => Box::new(EmlMime),
document::ANYDOC_ID => Box::new(AnydocDocument),
workbook::WORKBOOK_ID => Box::new(WorkbookIr),
subprocess::PDF_SUBPROCESS_ID => Box::new(PdfSubprocess),
subprocess::RECORDS_SUBPROCESS_ID => {
Box::new(RecordsSubprocess::new(records_limits.clone()))
}
#[cfg(all(unix, feature = "image-ocr"))]
subprocess::IMAGE_PIXEL_OCR_ID => {
#[cfg(feature = "svg-provider")]
let converter = match provider.and_then(provider::ProviderConfig::svg) {
Some(svg) => {
if image_ocr_limits.svg_provider.is_empty() {
return Err(Error::Rules {
name: name.to_string(),
message: "a provider is configured but [image_ocr.svg_provider] pins no expectations for it".to_string(),
});
}
for (label, role) in [
(provider::PROVIDER_ROLE_RASTER, &svg.raster),
(provider::PROVIDER_ROLE_ENCODER, &svg.encoder),
] {
provider::check_closure_is_pinned(
label,
role,
image_ocr_limits.svg_provider.get(label),
)
.map_err(|message| {
Error::Rules {
name: name.to_string(),
message,
}
})?;
}
Box::new(subprocess::ImagePixelOcr::with_svg_provider(
image_ocr_limits.clone(),
inventory,
svg,
))
}
None => Box::new(subprocess::ImagePixelOcr::new(
image_ocr_limits.clone(),
inventory,
)),
};
#[cfg(not(feature = "svg-provider"))]
let converter = Box::new(subprocess::ImagePixelOcr::new(
image_ocr_limits.clone(),
inventory,
));
converter
}
#[cfg(all(
unix,
feature = "audio-asr",
target_os = "macos",
target_arch = "aarch64"
))]
subprocess::ASR_ADAPTER_ID => {
let profile = asr_profile.clone().ok_or_else(|| Error::Rules {
name: name.to_string(),
message: "the asr-adapter entry requires an [asr] section".to_string(),
})?;
Box::new(subprocess::AsrAdapter::new(profile, inventory))
}
#[cfg(all(unix, feature = "image-metadata"))]
image_metadata::IMAGE_METADATA_ID => Box::new(subprocess::ImageMetadata::new(
image_metadata_limits.clone(),
)),
other => {
return Err(Error::Rules {
name: name.to_string(),
message: format!("unknown converter id {other:?}"),
});
}
};
if entry.version != converter.version() {
return Err(Error::Rules {
name: name.to_string(),
message: format!(
"converter {:?} pins version {:?} but the implementation is {:?}",
entry.id,
entry.version,
converter.version()
),
});
}
let index = converters.len();
#[cfg(all(unix, feature = "image-ocr", feature = "svg-provider"))]
if entry.id == subprocess::IMAGE_PIXEL_OCR_ID
&& provider.and_then(provider::ProviderConfig::svg).is_some()
{
svg_ocr_index = Some(index);
}
#[cfg(all(unix, feature = "image-metadata"))]
if entry.id == image_metadata::IMAGE_METADATA_ID {
image_metadata_index = Some(index);
}
converters.push(converter);
for format in &entry.formats {
if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
return Err(Error::Rules {
name: name.to_string(),
message: format!("format id {format:?} is reserved"),
});
}
if by_format.insert(format.clone(), index).is_some() {
return Err(Error::Rules {
name: name.to_string(),
message: format!("format {format:?} claimed twice"),
});
}
}
}
for entry in &raw.unsupported {
for format in &entry.formats {
if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
return Err(Error::Rules {
name: name.to_string(),
message: format!("format id {format:?} is reserved"),
});
}
if by_format.contains_key(format)
|| unsupported_reasons
.insert(format.clone(), entry.reason.clone())
.is_some()
{
return Err(Error::Rules {
name: name.to_string(),
message: format!("format {format:?} claimed twice"),
});
}
}
}
Ok(Registry {
version: raw.version,
converters,
by_format,
unsupported_reasons,
container_limits: raw.containers.unwrap_or_default(),
records_limits,
image_ocr_limits,
image_metadata_limits,
asr_profile,
image_metadata_index,
svg_ocr_index,
#[cfg(all(
unix,
feature = "image-ocr",
feature = "svg-provider",
feature = "test-adapters"
))]
svg_provider: provider.and_then(provider::ProviderConfig::svg).cloned(),
})
}
pub fn version(&self) -> &str {
&self.version
}
pub fn converter_for(&self, format_id: &str) -> Option<&dyn Converter> {
self.by_format
.get(format_id)
.map(|index| self.converters[*index].as_ref())
}
pub fn container_limits(&self) -> &ContainerLimits {
&self.container_limits
}
pub fn records_limits(&self) -> &RecordsLimits {
&self.records_limits
}
pub fn image_ocr_limits(&self) -> &ImageOcrLimits {
&self.image_ocr_limits
}
pub fn image_metadata_limits(&self) -> &ImageMetadataLimits {
&self.image_metadata_limits
}
pub fn asr_profile(&self) -> Option<&AsrProfile> {
self.asr_profile.as_ref()
}
#[cfg(all(
unix,
feature = "audio-asr",
feature = "test-adapters",
target_os = "macos",
target_arch = "aarch64"
))]
pub fn use_fake_asr(&mut self) {
if let Some(index) = self
.converters
.iter()
.position(|c| c.id() == subprocess::ASR_ADAPTER_ID)
&& let Some(profile) = self.asr_profile.clone()
{
self.converters[index] = Box::new(subprocess::AsrAdapter::new_fake(profile));
}
}
#[cfg(all(unix, feature = "image-ocr", feature = "test-adapters"))]
pub fn use_fake_image_ocr(&mut self) {
if let Some(index) = self
.converters
.iter()
.position(|c| c.id() == subprocess::IMAGE_PIXEL_OCR_ID)
{
#[cfg(feature = "svg-provider")]
{
self.converters[index] = match &self.svg_provider {
Some(svg) => Box::new(subprocess::ImagePixelOcr::new_fake_with_svg_provider(
self.image_ocr_limits.clone(),
svg,
)),
None => Box::new(subprocess::ImagePixelOcr::new_fake(
self.image_ocr_limits.clone(),
)),
};
}
#[cfg(not(feature = "svg-provider"))]
{
self.converters[index] = Box::new(subprocess::ImagePixelOcr::new_fake(
self.image_ocr_limits.clone(),
));
}
}
}
#[cfg(all(
unix,
feature = "image-ocr",
feature = "svg-provider",
feature = "test-adapters"
))]
pub fn use_provider_spawn_recheck_test_seam(&mut self, path: std::path::PathBuf) {
if let (Some(index), Some(svg)) = (self.svg_ocr_index, &self.svg_provider) {
self.converters[index] = Box::new(
subprocess::ImagePixelOcr::new_fake_with_svg_provider_spawn_recheck(
self.image_ocr_limits.clone(),
svg,
path,
),
);
}
}
pub fn svg_ocr_converter(&self) -> Option<&dyn Converter> {
self.svg_ocr_index
.map(|index| self.converters[index].as_ref())
}
pub fn image_metadata_converter(&self) -> Option<&dyn Converter> {
self.image_metadata_index
.map(|index| self.converters[index].as_ref())
}
pub fn unsupported_reason(&self, format_id: &str) -> Option<&str> {
if self.by_format.contains_key(format_id) {
return None;
}
Some(
self.unsupported_reasons
.get(format_id)
.map(String::as_str)
.unwrap_or(NO_CONVERTER_REASON),
)
}
#[cfg(test)]
pub(crate) fn for_tests(
version: &str,
entries: Vec<(Box<dyn Converter>, Vec<&str>)>,
) -> Registry {
let mut converters = Vec::new();
let mut by_format = HashMap::new();
for (converter, formats) in entries {
let index = converters.len();
converters.push(converter);
for format in formats {
by_format.insert(format.to_string(), index);
}
}
Registry {
version: version.to_string(),
converters,
by_format,
unsupported_reasons: HashMap::new(),
container_limits: ContainerLimits::default(),
records_limits: RecordsLimits::default(),
image_ocr_limits: ImageOcrLimits::default(),
image_metadata_limits: ImageMetadataLimits::default(),
asr_profile: None,
image_metadata_index: None,
svg_ocr_index: None,
#[cfg(all(
unix,
feature = "image-ocr",
feature = "svg-provider",
feature = "test-adapters"
))]
svg_provider: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_converts_crlf_to_lf() {
assert_eq!(normalize_text("a\r\nb\r\n"), "a\nb\n");
}
#[test]
fn normalize_converts_lone_cr_to_lf() {
assert_eq!(normalize_text("a\rb\r"), "a\nb\n");
assert_eq!(normalize_text("a\r\rb"), "a\n\nb");
}
#[test]
fn normalize_composes_to_nfc() {
assert_eq!(normalize_text("cafe\u{301}"), "caf\u{e9}");
}
#[test]
fn normalize_never_applies_nfkc() {
assert_eq!(normalize_text("\u{fb01}le \u{2460}"), "\u{fb01}le \u{2460}");
}
#[test]
fn passthrough_rejects_invalid_utf8() {
let err = PlainTextPassthrough
.convert(b"hello \xff world", "text")
.unwrap_err();
assert_eq!(err.code, "invalid_utf8");
}
#[test]
fn passthrough_fails_on_suspiciously_empty_output() {
let err = PlainTextPassthrough
.convert("\u{feff}".as_bytes(), "text")
.unwrap_err();
assert_eq!(err.code, "empty_output");
}
#[test]
fn passthrough_accepts_an_empty_source() {
let outcome = PlainTextPassthrough.convert(b"", "text").unwrap();
assert_eq!(outcome.text, "");
}
fn utf16_bytes(text: &str, big_endian: bool) -> Vec<u8> {
let mut bytes = if big_endian {
vec![0xFE, 0xFF]
} else {
vec![0xFF, 0xFE]
};
for unit in text.encode_utf16() {
bytes.extend_from_slice(&if big_endian {
unit.to_be_bytes()
} else {
unit.to_le_bytes()
});
}
bytes
}
#[test]
fn passthrough_transcodes_utf16le_behind_a_byte_order_mark() {
let source = utf16_bytes("a,b\r\n1,caf\u{e9}\r\n", false);
let outcome = PlainTextPassthrough.convert(&source, "csv").unwrap();
assert_eq!(outcome.text, "a,b\n1,caf\u{e9}\n");
assert_eq!(
outcome.warnings,
vec!["transcoded from utf-16le".to_string()]
);
}
#[test]
fn passthrough_transcodes_utf16be_behind_a_byte_order_mark() {
let source = utf16_bytes("total \u{5317}\u{4eac}\n", true);
let outcome = PlainTextPassthrough.convert(&source, "text").unwrap();
assert_eq!(outcome.text, "total \u{5317}\u{4eac}\n");
assert_eq!(
outcome.warnings,
vec!["transcoded from utf-16be".to_string()]
);
}
#[test]
fn passthrough_refuses_utf32_byte_order_marks() {
for source in [
b"\xFF\xFE\x00\x00A\x00\x00\x00".as_slice(),
b"\x00\x00\xFE\xFF\x00\x00\x00A".as_slice(),
] {
let err = PlainTextPassthrough.convert(source, "text").unwrap_err();
assert_eq!(err.code, "unsupported-encoding");
assert!(err.to_string().starts_with("unsupported-encoding: utf-32"));
}
}
#[test]
fn passthrough_fails_an_odd_utf16_tail_at_its_offset() {
let err = PlainTextPassthrough
.convert(b"\xFF\xFEA\x00B", "text")
.unwrap_err();
assert_eq!(err.code, "invalid_utf16");
assert_eq!(err.message, "odd trailing byte at byte 4");
}
#[test]
fn passthrough_fails_an_unpaired_surrogate_at_its_offset() {
let err = PlainTextPassthrough
.convert(b"\xFF\xFEA\x00\x00\xD8", "text")
.unwrap_err();
assert_eq!(err.code, "invalid_utf16");
assert_eq!(err.message, "unpaired surrogate at byte 4");
}
#[test]
fn passthrough_refuses_nul_bytes_with_the_first_offset() {
let err = PlainTextPassthrough
.convert(b"ab\0cd\0", "text")
.unwrap_err();
assert_eq!(err.code, "nul_bytes");
assert!(err.message.contains("byte 2"), "{}", err.message);
assert!(err.message.contains("BOM-less UTF-16"), "{}", err.message);
let err = PlainTextPassthrough
.convert(b"\xFF\xFEA\x00\x00\x00B\x00", "text")
.unwrap_err();
assert_eq!(err.code, "nul_bytes");
assert!(err.message.contains("byte 1"), "{}", err.message);
}
#[test]
fn passthrough_still_fails_bomless_utf16_as_invalid_utf8() {
let mut source = utf16_bytes("R\u{e9}sum\u{e9}\n", false);
source.drain(..2);
let err = PlainTextPassthrough.convert(&source, "text").unwrap_err();
assert_eq!(err.code, "invalid_utf8");
}
#[test]
fn builtin_registry_claims_the_text_family() {
let registry = Registry::builtin().unwrap();
assert_eq!(registry.version(), "10");
assert!(registry.converter_for("json").is_some());
assert!(registry.converter_for("yaml").is_some());
assert!(registry.converter_for("svg").is_some());
assert!(registry.converter_for("ipynb").is_some());
assert!(registry.converter_for("python").is_some());
assert!(registry.converter_for("pickle").is_none());
assert!(registry.converter_for("text").is_some());
assert!(registry.converter_for("markdown").is_some());
assert!(registry.converter_for("csv").is_some());
assert!(registry.converter_for("pdf").is_some());
assert_eq!(
registry.converter_for("pdf").map(|c| c.id()),
Some("pdf-subprocess")
);
assert!(registry.converter_for("docx").is_some());
assert!(registry.converter_for("xlsx").is_some());
assert_eq!(
registry.converter_for("html").map(|c| c.id()),
Some("html-strip")
);
assert_eq!(
registry.converter_for("eml").map(|c| c.id()),
Some("eml-mime")
);
assert!(registry.converter_for("msg").is_none());
assert!(registry.converter_for("xlsb").is_none());
assert_eq!(
registry.unsupported_reason("xlsb"),
Some("hidden-visibility-unresolved")
);
assert_eq!(
registry.unsupported_reason("pickle"),
Some("pickle-deserialization-unsafe")
);
for format in ["png", "jpeg", "webp"] {
assert_eq!(
registry.converter_for(format).map(|c| c.id()),
Some("image-pixel-ocr"),
"{format}"
);
assert_eq!(registry.unsupported_reason(format), None, "{format}");
}
assert_eq!(registry.unsupported_reason("tiff"), Some("engine-unpinned"));
assert_eq!(
registry.unsupported_reason("heic"),
Some("no-jailed-rasterizer")
);
assert_eq!(
registry.converter_for("ai").map(|c| c.id()),
Some("pdf-subprocess")
);
for format in ["parquet", "avro", "sqlite"] {
assert_eq!(
registry.converter_for(format).map(|c| c.id()),
Some("records-worker"),
"{format}"
);
assert_eq!(registry.unsupported_reason(format), None, "{format}");
}
assert_eq!(
registry.unsupported_reason("arrow"),
Some("converter-deferred")
);
assert_eq!(
registry.unsupported_reason("tar"),
Some("container-deferred")
);
assert_eq!(
registry.unsupported_reason("psd"),
Some("proprietary-binary")
);
assert_eq!(
registry.unsupported_reason("unknown"),
Some(NO_CONVERTER_REASON)
);
assert_eq!(registry.unsupported_reason("text"), None);
assert_eq!(registry.unsupported_reason("mp4"), Some("engine-unpinned"));
assert_eq!(registry.unsupported_reason("html"), None);
assert_eq!(
registry.unsupported_reason("msg"),
Some(NO_CONVERTER_REASON)
);
}
#[test]
fn the_raster_family_routes_by_the_image_ocr_feature() {
let registry = Registry::builtin().unwrap();
let feature_present = cfg!(all(unix, feature = "image-ocr"));
for format in ["png", "jpeg", "webp"] {
if feature_present {
assert!(registry.converter_for(format).is_some(), "{format}");
assert_eq!(registry.unsupported_reason(format), None, "{format}");
} else {
assert!(registry.converter_for(format).is_none(), "{format}");
assert_eq!(
registry.unsupported_reason(format),
Some(IMAGE_OCR_NOT_BUILT_REASON),
"{format}"
);
}
}
}
#[test]
fn the_audio_family_routes_by_the_feature_and_platform() {
let registry = Registry::builtin().unwrap();
let pinned_class = cfg!(all(
unix,
feature = "audio-asr",
target_os = "macos",
target_arch = "aarch64"
));
let feature_present = cfg!(all(unix, feature = "audio-asr"));
for format in ["wav", "mp3", "flac", "m4a"] {
if pinned_class {
assert_eq!(
registry.converter_for(format).map(|c| c.id()),
Some("asr-adapter"),
"{format}"
);
assert_eq!(registry.unsupported_reason(format), None, "{format}");
} else if feature_present {
assert!(registry.converter_for(format).is_none(), "{format}");
assert_eq!(
registry.unsupported_reason(format),
Some(ENGINE_UNPINNED_REASON),
"{format}"
);
} else {
assert!(registry.converter_for(format).is_none(), "{format}");
assert_eq!(
registry.unsupported_reason(format),
Some(AUDIO_ASR_NOT_BUILT_REASON),
"{format}"
);
}
}
assert_eq!(registry.unsupported_reason("mp4"), Some("engine-unpinned"));
assert_eq!(registry.unsupported_reason("mov"), Some("engine-unpinned"));
let profile = registry
.asr_profile()
.expect("the built-in rules carry an [asr] section");
assert_eq!(profile.max_duration_seconds, 3600);
assert_eq!(profile.wall_timeout_secs, 900);
assert_eq!(profile.cpu_seconds, 600);
assert_eq!(profile.max_resident_bytes, 6_442_450_944);
assert_eq!(profile.max_response_bytes, 4_194_304);
assert_eq!(profile.max_stderr_bytes, 4_194_304);
assert_eq!(profile.file_size_bytes, 1_207_959_552);
assert_eq!(profile.max_processes, 64);
assert_eq!(profile.language, "en");
assert_eq!(profile.inventory.len(), 4);
assert!(profile.inventory.contains_key("asr-probe"));
}
#[test]
fn the_asr_profile_is_validated_at_parse() {
let policy_hash = crate::hash::hash_bytes(subprocess::ASR_DECODE_POLICY.as_bytes());
let zeros = "0".repeat(64);
let base = |language: &str, extra_row: &str, policy: &str| {
format!(
r#"version = "1"
converters = []
[asr]
max_duration_seconds = 3600
wall_timeout_secs = 900
cpu_seconds = 600
max_resident_bytes = 6442450944
max_response_bytes = 4194304
max_stderr_bytes = 4194304
file_size_bytes = 1207959552
max_processes = 64
language = "{language}"
[asr.inventory]
asr-cli = "{zeros}"
asr-weights = "{zeros}"
asr-decode-policy = "{policy}"
{extra_row}"#,
zeros = zeros.as_str(),
)
};
let good = base("en", "", &policy_hash);
assert!(Registry::parse(&good, "converters.toml").is_ok());
let with_probe = base("en", &format!("asr-probe = \"{zeros}\"\n"), &policy_hash);
assert!(Registry::parse(&with_probe, "converters.toml").is_ok());
let err = Registry::parse(&base("fr", "", &policy_hash), "converters.toml")
.err()
.expect("a non-pinned language must be refused");
assert!(err.to_string().contains("language"), "{err}");
let unknown = base("en", &format!("asr-extra = \"{zeros}\"\n"), &policy_hash);
let err = Registry::parse(&unknown, "converters.toml")
.err()
.expect("an unknown role must be refused");
assert!(err.to_string().contains("role"), "{err}");
let missing: String = good
.lines()
.filter(|line| !line.starts_with("asr-weights"))
.collect::<Vec<_>>()
.join("\n");
let err = Registry::parse(&missing, "converters.toml")
.err()
.expect("a missing pinned role must be refused");
assert!(err.to_string().contains("missing"), "{err}");
let err = Registry::parse(&base("en", "", &"a".repeat(64)), "converters.toml")
.err()
.expect("a divergent decode-policy hash must be refused");
assert!(err.to_string().contains("decode"), "{err}");
let err = Registry::parse(&base("en", "", "SHOUTING"), "converters.toml")
.err()
.expect("a malformed hash must be refused");
assert!(err.to_string().contains("hex"), "{err}");
}
#[test]
#[cfg(unix)]
fn the_runtime_inventory_validates_roles_and_paths() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("engine");
std::fs::write(&file, b"pinned bytes").unwrap();
let mut inventory = RuntimeInventory::empty();
assert!(inventory.set("asr-cli", file.clone()).is_ok());
assert_eq!(inventory.path("asr-cli"), Some(file.as_path()));
assert!(inventory.path("asr-weights").is_none());
assert!(inventory.set("mystery-role", file.clone()).is_err());
assert!(inventory.set("asr-decode-policy", file.clone()).is_err());
let policy_row = format!("asr-decode-policy = \"{}\"\n", file.display());
assert!(RuntimeInventory::parse(&policy_row, "inventory.toml").is_err());
assert!(
inventory
.set("asr-weights", std::path::PathBuf::from("relative"))
.is_err()
);
assert!(
inventory
.set("asr-weights", dir.path().to_path_buf())
.is_err()
);
let link = dir.path().join("link");
std::os::unix::fs::symlink(&file, &link).unwrap();
assert!(inventory.set("asr-weights", link).is_err());
assert!(
inventory
.set("asr-weights", dir.path().join("absent"))
.is_err()
);
let toml_text = format!("asr-weights = \"{}\"\n", file.display());
let parsed = RuntimeInventory::parse(&toml_text, "inventory.toml").unwrap();
assert_eq!(parsed.path("asr-weights"), Some(file.as_path()));
assert!(RuntimeInventory::parse("nope = \"/x\"\n", "inventory.toml").is_err());
}
#[test]
fn registry_rejects_reserved_format_ids() {
for (section, id) in [
("converters", "symlink"),
("converters", "unknown"),
("unsupported", "symlink"),
("unsupported", "unknown"),
] {
let toml = if section == "converters" {
format!(
"version = \"1\"\n[[converters]]\nid = \"text-passthrough\"\nversion = \"1.2.0\"\nformats = [\"{id}\"]\n"
)
} else {
format!(
"version = \"1\"\nconverters = []\n[[unsupported]]\nreason = \"r\"\nformats = [\"{id}\"]\n"
)
};
let err = match Registry::parse(&toml, "converters.toml") {
Ok(_) => panic!("{section} {id}: reserved id was accepted"),
Err(e) => e,
};
assert!(
err.to_string().contains("reserved"),
"{section} {id}: {err}"
);
}
}
#[test]
fn registry_rejects_a_version_that_contradicts_the_implementation() {
let toml = r#"
version = "1"
[[converters]]
id = "text-passthrough"
version = "9.9.9"
formats = ["text"]
"#;
assert!(Registry::parse(toml, "converters.toml").is_err());
}
}