use std::io::Write;
use std::path::{Path, PathBuf};
use crate::ocr_error::OcrError;
const HF_BASE: &str = "https://huggingface.co/PaddlePaddle";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PpStructureModel {
TableCls,
TableStructureWired,
TableStructureWireless,
CellDetWireless,
DocOrientation,
DocUnwarp,
FormulaRec,
}
impl PpStructureModel {
fn hf_repo(self) -> &'static str {
match self {
Self::TableCls => "PP-LCNet_x1_0_table_cls_onnx",
Self::TableStructureWired => "SLANeXt_wired_onnx",
Self::TableStructureWireless => "SLANeXt_wireless_onnx",
Self::CellDetWireless => "RT-DETR-L_wireless_table_cell_det_onnx",
Self::DocOrientation => "PP-LCNet_x1_0_doc_ori_onnx",
Self::DocUnwarp => "UVDoc_onnx",
Self::FormulaRec => "PP-FormulaNet_plus-L_onnx",
}
}
fn dir_name(self) -> &'static str {
match self {
Self::TableCls => "table_cls",
Self::TableStructureWired => "slanext_wired",
Self::TableStructureWireless => "slanext_wireless",
Self::CellDetWireless => "cell_det_wireless",
Self::DocOrientation => "doc_orientation",
Self::DocUnwarp => "doc_unwarp",
Self::FormulaRec => "formula_rec",
}
}
fn has_yml(self) -> bool {
matches!(self, Self::TableStructureWired | Self::TableStructureWireless)
}
fn has_tokenizer(self) -> bool {
matches!(self, Self::FormulaRec)
}
}
#[derive(Debug, Clone)]
pub struct StructureModelPaths {
pub onnx: PathBuf,
pub dict_txt: Option<PathBuf>,
pub yml: Option<PathBuf>,
pub tokenizer_json: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PpOcrVersion {
V6Tiny,
V6Small,
V6Medium,
}
impl PpOcrVersion {
fn det_repo(self) -> &'static str {
match self {
Self::V6Tiny => "PP-OCRv6_tiny_det_onnx",
Self::V6Small => "PP-OCRv6_small_det_onnx",
Self::V6Medium => "PP-OCRv6_medium_det_onnx",
}
}
fn rec_repo(self) -> &'static str {
match self {
Self::V6Tiny => "PP-OCRv6_tiny_rec_onnx",
Self::V6Small => "PP-OCRv6_small_rec_onnx",
Self::V6Medium => "PP-OCRv6_medium_rec_onnx",
}
}
fn dir_name(self) -> &'static str {
match self {
Self::V6Tiny => "pp_ocrv6_tiny",
Self::V6Small => "pp_ocrv6_small",
Self::V6Medium => "pp_ocrv6_medium",
}
}
}
#[derive(Debug, Clone)]
pub struct ModelPaths {
pub det_onnx: PathBuf,
pub rec_onnx: PathBuf,
pub dict_txt: PathBuf,
pub rec_yml: PathBuf,
}
pub struct ModelHub {
cache_dir: PathBuf,
}
impl ModelHub {
pub fn new(cache_dir: impl Into<PathBuf>) -> Self {
Self { cache_dir: cache_dir.into() }
}
pub fn with_default_cache() -> Result<Self, OcrError> {
let base = Self::default_cache_dir()?;
Ok(Self::new(base.join("models")))
}
pub fn ensure(&self, version: PpOcrVersion) -> Result<ModelPaths, OcrError> {
let dir = self.cache_dir.join(version.dir_name());
std::fs::create_dir_all(&dir)?;
let det_path = dir.join("det.onnx");
let rec_path = dir.join("rec.onnx");
let rec_yml = dir.join("rec_inference.yml");
let dict_path = dir.join("dict.txt");
let det_url = format!("{}/{}/resolve/main/inference.onnx", HF_BASE, version.det_repo());
let rec_url = format!("{}/{}/resolve/main/inference.onnx", HF_BASE, version.rec_repo());
let rec_yml_url = format!("{}/{}/resolve/main/inference.yml", HF_BASE, version.rec_repo());
if !is_cached(&det_path) {
eprintln!("[ppocr-rs] download det → {}", det_path.display());
fetch_file(&det_url, &det_path)?;
}
if !is_cached(&rec_path) {
eprintln!("[ppocr-rs] download rec → {}", rec_path.display());
fetch_file(&rec_url, &rec_path)?;
}
if !is_cached(&rec_yml) {
eprintln!("[ppocr-rs] download rec yml → {}", rec_yml.display());
fetch_file(&rec_yml_url, &rec_yml)?;
}
if !is_cached(&dict_path) {
eprintln!("[ppocr-rs] estrai dict da yml → {}", dict_path.display());
extract_dict_from_yml(&rec_yml, &dict_path)?;
}
Ok(ModelPaths { det_onnx: det_path, rec_onnx: rec_path, dict_txt: dict_path, rec_yml })
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
pub fn ensure_single(&self, model: PpStructureModel) -> Result<StructureModelPaths, OcrError> {
let dir = self.cache_dir.join(model.dir_name());
std::fs::create_dir_all(&dir)?;
let onnx_path = dir.join("inference.onnx");
let onnx_url = format!("{}/{}/resolve/main/inference.onnx", HF_BASE, model.hf_repo());
if !is_cached(&onnx_path) {
eprintln!("[ppocr-rs] download {} → {}", model.hf_repo(), onnx_path.display());
fetch_file(&onnx_url, &onnx_path)?;
}
let (yml_out, dict_out) = if model.has_yml() {
let yml_path = dir.join("inference.yml");
let dict_path = dir.join("dict.txt");
let yml_url = format!("{}/{}/resolve/main/inference.yml", HF_BASE, model.hf_repo());
if !is_cached(&yml_path) {
eprintln!("[ppocr-rs] download yml → {}", yml_path.display());
let _ = fetch_file(&yml_url, &yml_path);
}
if is_cached(&yml_path) && !is_cached(&dict_path) {
eprintln!("[ppocr-rs] estrai dict SLANeXt → {}", dict_path.display());
if let Err(e) = extract_dict_from_yml(&yml_path, &dict_path) {
eprintln!("[ppocr-rs] warn: dict extraction fallita: {e}");
}
}
(
Some(yml_path).filter(|p| is_cached(p)),
Some(dict_path).filter(|p| is_cached(p)),
)
} else {
(None, None)
};
let tokenizer_out = if model.has_tokenizer() {
let tok_path = dir.join("tokenizer.json");
let tok_url = format!("{}/{}/resolve/main/tokenizer.json", HF_BASE, model.hf_repo());
if !is_cached(&tok_path) {
eprintln!("[ppocr-rs] download tokenizer → {}", tok_path.display());
let _ = fetch_file(&tok_url, &tok_path);
}
Some(tok_path).filter(|p| is_cached(p))
} else {
None
};
Ok(StructureModelPaths {
onnx: onnx_path,
dict_txt: dict_out,
yml: yml_out,
tokenizer_json: tokenizer_out,
})
}
fn default_cache_dir() -> Result<PathBuf, OcrError> {
#[cfg(windows)]
if let Some(v) = std::env::var_os("LOCALAPPDATA") {
return Ok(PathBuf::from(v).join("ppocr-rs"));
}
if let Some(v) = std::env::var_os("XDG_CACHE_HOME") {
return Ok(PathBuf::from(v).join("ppocr-rs"));
}
if let Some(v) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return Ok(PathBuf::from(v).join(".cache").join("ppocr-rs"));
}
Err(OcrError::ModelHubError(
"impossibile determinare la cache dir: HOME/LOCALAPPDATA non impostato".into(),
))
}
}
fn extract_dict_from_yml(yml_path: &Path, dict_path: &Path) -> Result<(), OcrError> {
let content = std::fs::read_to_string(yml_path)?;
let mut chars: Vec<String> = Vec::new();
let mut in_dict = false;
for line in content.lines() {
if !in_dict {
if line.trim_start().starts_with("character_dict:") {
in_dict = true;
}
continue;
}
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("- ") {
let rest_trimmed = rest.trim_end_matches('\r');
let ch = if rest_trimmed.starts_with('\'') && rest_trimmed.ends_with('\'') && rest_trimmed.len() >= 2 {
rest_trimmed[1..rest_trimmed.len() - 1].replace("''", "'")
} else {
rest_trimmed.to_string()
};
chars.push(ch);
} else if !trimmed.is_empty() && !trimmed.starts_with('-') {
break;
}
}
if chars.is_empty() {
return Err(OcrError::ModelHubError(
"character_dict non trovato in rec_inference.yml".into(),
));
}
let tmp_ext = format!("tmp_{:?}", std::thread::current().id())
.replace(['(', ')'], "");
let tmp = dict_path.with_extension(&tmp_ext);
{
let mut f = std::fs::File::create(&tmp)?;
for ch in &chars {
writeln!(f, "{ch}")?;
}
}
if let Err(e) = std::fs::rename(&tmp, dict_path) {
std::fs::remove_file(&tmp).ok();
if !is_cached(dict_path) {
return Err(OcrError::ModelHubError(format!("rename dict: {e}")));
}
}
eprintln!("[ppocr-rs] dict estratto: {} voci", chars.len());
Ok(())
}
fn is_cached(path: &Path) -> bool {
path.metadata().map(|m| m.len() > 0).unwrap_or(false)
}
#[cfg(feature = "fetch-models")]
fn fetch_file(url: &str, dest: &Path) -> Result<(), OcrError> {
let tid = format!("{:?}", std::thread::current().id())
.replace(['(', ')'], "");
let tmp = dest.with_extension(format!("tmp_{tid}"));
let response = ureq::get(url)
.call()
.map_err(|e| OcrError::ModelHubError(format!("GET {url}: {e}")))?;
let mut reader = response.into_reader();
let mut file = std::fs::File::create(&tmp)?;
let mut buf = [0u8; 65536];
let mut total = 0u64;
loop {
let n = reader.read(&mut buf)?;
if n == 0 { break; }
file.write_all(&buf[..n])?;
total += n as u64;
}
drop(file);
if total == 0 {
std::fs::remove_file(&tmp).ok();
return Err(OcrError::ModelHubError(format!("risposta vuota da {url}")));
}
if let Err(e) = std::fs::rename(&tmp, dest) {
std::fs::remove_file(&tmp).ok();
if !is_cached(dest) {
return Err(OcrError::ModelHubError(format!("rename tmp→dest: {e}")));
}
return Ok(());
}
eprintln!("[ppocr-rs] salvati {:.1} MB", total as f64 / 1_048_576.0);
Ok(())
}
#[cfg(not(feature = "fetch-models"))]
fn fetch_file(url: &str, _dest: &Path) -> Result<(), OcrError> {
Err(OcrError::ModelHubError(format!(
"download richiede --features fetch-models. URL: {url}"
)))
}