use crate::{Result, XbergError};
const VALID_BINARIZATION_METHODS: &[&str] = &["none", "off", "otsu", "adaptive", "sauvola"];
const VALID_TOKEN_REDUCTION_LEVELS: &[&str] = &["off", "light", "moderate", "aggressive", "maximum"];
const VALID_OCR_BACKENDS: &[&str] = &[
"tesseract",
"paddleocr",
"paddle-ocr",
"sceptre",
"vlm",
"candle-trocr",
"candle-paddleocr-vl",
"candle-glm-ocr",
"candle-deepseek-ocr",
];
const VALID_LANGUAGE_CODES: &[&str] = &[
"en",
"de",
"fr",
"es",
"it",
"pt",
"nl",
"pl",
"ru",
"zh",
"ja",
"ko",
"bg",
"cs",
"da",
"el",
"et",
"fi",
"hu",
"lt",
"lv",
"ro",
"sk",
"sl",
"sv",
"uk",
"ar",
"hi",
"th",
"tr",
"vi",
"eng",
"deu",
"fra",
"spa",
"ita",
"por",
"nld",
"pol",
"rus",
"zho",
"jpn",
"jpn_vert",
"kor",
"ces",
"dan",
"ell",
"est",
"fin",
"hun",
"lit",
"lav",
"ron",
"slk",
"slv",
"swe",
"tur",
"ch",
"chinese_cht",
"latin",
"cyrillic",
"english",
"japanese",
"korean",
"telugu",
"kannada",
"chinese-simplified",
"simplified-chinese",
"devanagari",
"arabic",
"af",
"afr",
"az",
"aze",
"bel",
"be",
"bul",
"bs",
"bos",
"ca",
"cat",
"cy",
"cym",
"cze",
"wel",
"ger",
"fre",
"ga",
"fil",
"gle",
"hr",
"hrv",
"id",
"ind",
"is",
"isl",
"ice",
"kk",
"kaz",
"ky",
"kir",
"mk",
"mkd",
"mac",
"mn",
"mon",
"mi",
"mri",
"mao",
"ms",
"msa",
"may",
"mt",
"mlt",
"dut",
"no",
"nor",
"rum",
"slo",
"sq",
"sqi",
"alb",
"sr",
"srp",
"sw",
"swa",
"tl",
"tg",
"tgk",
"ukr",
"uz",
"uzb",
"vie",
"ku",
"kur",
"la",
"lat",
"oc",
"oci",
"pi",
"pli",
"te",
"tel",
"kn",
"kan",
"abq",
"ady",
"kbd",
"ava",
"dar",
"inh",
"che",
"lbe",
"lez",
"tab",
"tjk",
"ch_sim",
"ch-sim",
"rs_latin",
"rs-latin",
"rs_cyrillic",
"rs-cyrillic",
"jpn-vert",
"sr-latn",
"srp-latn",
"sr-cyrl",
"srp-cyrl",
"zh-cn",
"zh-hans",
"chi",
"chs",
];
pub(crate) const TESSERACT_LANGUAGE_CODES: &[&str] = &[
"afr", "amh", "ara", "asm", "aze", "aze_cyrl", "bel", "ben", "bod", "bos", "bre", "bul", "cat", "ceb", "ces",
"chi_sim", "chi_tra", "chr", "cos", "cym", "dan", "deu", "div", "dzo", "ell", "eng", "enm", "epo", "equ", "est",
"eus", "fao", "fas", "fil", "fin", "fra", "frk", "frm", "fry", "gla", "gle", "glg", "grc", "guj", "hat", "heb",
"hin", "hrv", "hun", "hye", "iku", "ind", "isl", "ita", "ita_old", "jav", "jpn", "kan", "kat", "kat_old", "kaz",
"khm", "kir", "kmr", "kor", "lao", "lat", "lav", "lit", "ltz", "mal", "mar", "mkd", "mlt", "mon", "mri", "msa",
"mya", "nep", "nld", "nor", "oci", "ori", "osd", "pan", "pol", "por", "pus", "que", "ron", "rus", "san", "sin",
"slk", "slv", "snd", "spa", "spa_old", "sqi", "srp", "srp_latn", "sun", "swa", "swe", "syr", "tam", "tat", "tel",
"tgk", "tha", "tir", "ton", "tur", "uig", "ukr", "urd", "uzb", "uzb_cyrl", "vie", "yid", "yor",
];
const VALID_TESSERACT_PSM: &[i32] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
const VALID_TESSERACT_OEM: &[i32] = &[0, 1, 2, 3];
#[cfg(test)]
const VALID_OUTPUT_FORMATS: &[&str] = &["plain", "text", "markdown", "md", "djot", "html", "structured", "json"];
pub(crate) fn validate_binarization_method(method: &str) -> Result<()> {
let method = method.to_lowercase();
if VALID_BINARIZATION_METHODS.contains(&method.as_str()) {
Ok(())
} else {
Err(XbergError::Validation {
message: format!(
"Invalid binarization method '{}'. Valid options are: {}",
method,
VALID_BINARIZATION_METHODS.join(", ")
),
source: None,
})
}
}
pub(crate) fn validate_image_preprocessing_config(config: &crate::types::ImagePreprocessingConfig) -> Result<()> {
validate_binarization_method(&config.binarization_method)?;
let method = config.binarization_method.to_ascii_lowercase();
if matches!(method.as_str(), "none" | "off") && config.deskew {
return Err(XbergError::Validation {
message: "deskew must be false when binarization_method is none or off".to_string(),
source: None,
});
}
Ok(())
}
pub(crate) fn validate_token_reduction_level(level: &str) -> Result<()> {
let level = level.to_lowercase();
if VALID_TOKEN_REDUCTION_LEVELS.contains(&level.as_str()) {
Ok(())
} else {
Err(XbergError::Validation {
message: format!(
"Invalid token reduction level '{}'. Valid options are: {}",
level,
VALID_TOKEN_REDUCTION_LEVELS.join(", ")
),
source: None,
})
}
}
pub(crate) fn validate_ocr_backend(backend: &str) -> Result<()> {
let normalized = backend.to_lowercase();
if VALID_OCR_BACKENDS.contains(&normalized.as_str()) || is_registered_ocr_backend(backend) {
Ok(())
} else {
Err(XbergError::Validation {
message: format!(
"Invalid OCR backend '{}'. Valid options are: {}, or the name of a backend \
registered through `register_ocr_backend`",
normalized,
VALID_OCR_BACKENDS.join(", ")
),
source: None,
})
}
}
fn is_registered_ocr_backend(backend: &str) -> bool {
crate::plugins::registry::get_ocr_backend_registry()
.read()
.list()
.iter()
.any(|name| name.eq_ignore_ascii_case(backend))
}
#[cfg_attr(alef, alef(skip))]
pub(crate) fn validate_language_code(code: &str) -> Result<()> {
let code_lower = code.to_lowercase();
let code_normalized = code_lower.replace('_', "-");
if code_lower == "all" || code_lower == "*" {
return Ok(());
}
if VALID_LANGUAGE_CODES.contains(&code_lower.as_str())
|| VALID_LANGUAGE_CODES.contains(&code_normalized.as_str())
|| TESSERACT_LANGUAGE_CODES.contains(&code_lower.as_str())
|| TESSERACT_LANGUAGE_CODES.contains(&code_normalized.as_str())
{
return Ok(());
}
Err(XbergError::Validation {
message: format!(
"Invalid language code '{}'. Use ISO 639-1 (2-letter, e.g., 'en', 'de') \
or ISO 639-3 (3-letter, e.g., 'eng', 'deu') codes. \
Common codes: en, de, fr, es, it, pt, nl, pl, ru, zh, ja, ko, ar, hi, th.",
code
),
source: None,
})
}
pub(crate) fn validate_tesseract_psm(psm: i32) -> Result<()> {
if VALID_TESSERACT_PSM.contains(&psm) {
return Ok(());
}
let message = if psm == 0 {
"Invalid tesseract PSM value '0'. PSM 0 is orientation and script detection (OSD) only: \
it performs no character recognition, so extraction returns no text. \
Use 3 (auto), 6 (single block), or 11 (sparse text); omit `psm` to let the pipeline choose."
.to_string()
} else {
format!(
"Invalid tesseract PSM value '{}'. Valid range is 1-13. \
Common values: 3 (auto), 6 (single block), 11 (sparse text).",
psm
)
};
Err(XbergError::Validation { message, source: None })
}
pub(crate) fn validate_tesseract_oem(oem: i32) -> Result<()> {
if VALID_TESSERACT_OEM.contains(&oem) {
Ok(())
} else {
Err(XbergError::Validation {
message: format!(
"Invalid tesseract OEM value '{}'. Valid range is 0-3. \
0=Legacy, 1=LSTM, 2=Legacy+LSTM, 3=Default",
oem
),
source: None,
})
}
}
#[cfg(test)]
pub(crate) fn validate_output_format(format: &str) -> Result<()> {
let format = format.to_lowercase();
if VALID_OUTPUT_FORMATS.contains(&format.as_str()) {
Ok(())
} else {
Err(XbergError::Validation {
message: format!(
"Invalid output format '{}'. Valid options are: {}",
format,
VALID_OUTPUT_FORMATS.join(", ")
),
source: None,
})
}
}
pub(crate) fn validate_confidence(confidence: f64) -> Result<()> {
if (0.0..=1.0).contains(&confidence) {
Ok(())
} else {
Err(XbergError::Validation {
message: format!(
"Invalid confidence threshold '{}'. Must be between 0.0 and 1.0.",
confidence
),
source: None,
})
}
}
pub(crate) fn validate_dpi(dpi: i32) -> Result<()> {
if dpi > 0 && dpi <= 2400 {
Ok(())
} else {
Err(XbergError::Validation {
message: format!(
"Invalid DPI value '{}'. Must be a positive integer, typically 72-600.",
dpi
),
source: None,
})
}
}
pub(crate) fn validate_chunking_params(max_chars: usize, max_overlap: usize) -> Result<()> {
if max_chars == 0 {
return Err(XbergError::Validation {
message: "max_chars must be greater than 0".to_string(),
source: None,
});
}
if max_overlap >= max_chars {
return Err(XbergError::Validation {
message: format!(
"max_overlap ({}) must be less than max_chars ({})",
max_overlap, max_chars
),
source: None,
});
}
Ok(())
}
pub(crate) fn validate_llm_config_model(model: &str) -> Result<()> {
if model.trim().is_empty() {
return Err(XbergError::Validation {
message: "LLM config 'model' must not be empty. Provide a model identifier (e.g., 'openai/gpt-4o')."
.to_string(),
source: None,
});
}
Ok(())
}
pub(crate) fn validate_csv_delimiter(delimiter: &str) -> Result<()> {
if delimiter.len() == 1 && delimiter.is_ascii() {
Ok(())
} else {
Err(XbergError::Validation {
message: format!(
"Invalid CSV delimiter '{}'. Must be exactly one ASCII character (e.g. ',', ';', '\\t', '|').",
delimiter
),
source: None,
})
}
}
#[cfg_attr(alef, alef(skip))]
pub fn layout_wastes_plain_output(layout_enabled: bool, output_format: &crate::OutputFormat) -> bool {
layout_enabled && *output_format == crate::OutputFormat::Plain
}
pub(crate) fn validate_vlm_backend_config(
backend: &str,
vlm_config: Option<&crate::core::config::LlmConfig>,
) -> Result<()> {
if backend.to_lowercase() == "vlm" {
match vlm_config {
None => {
return Err(XbergError::Validation {
message: "OCR backend 'vlm' requires 'vlm_config' to be set with model endpoint configuration."
.to_string(),
source: None,
});
}
Some(config) => {
validate_llm_config_model(&config.model)?;
}
}
}
Ok(())
}