use std::{fmt::Display, fs, path::Path};
use serde::{Deserialize, Serialize};
use crate::{
error::{RapidOcrError, Result},
model_registry::{DefaultModelSelection, ModelRegistry},
model_set::{ModelFileSpec, ModelRole, ModelSet},
};
pub const MANIFEST_FILE_NAME: &str = "manifest.json";
pub const SUPPORTED_MANIFEST_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestFile {
pub name: String,
pub role: ModelRole,
pub sha256: String,
#[serde(default)]
pub size_bytes: Option<u64>,
#[serde(default)]
pub source_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModelManifest {
pub schema_version: u32,
pub id: String,
pub family: String,
pub version: String,
#[serde(default)]
pub languages: Vec<String>,
pub files: Vec<ManifestFile>,
}
impl ModelManifest {
pub fn from_json_str(json: &str) -> Result<Self> {
parse_manifest_json(json).map_err(|reason| manifest_error(None, reason))
}
pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
let text = std::str::from_utf8(bytes)
.map_err(|error| manifest_error(None, format!("is not valid UTF-8: {error}")))?;
Self::from_json_str(text)
}
pub fn load(path: &Path) -> Result<Self> {
let bytes = fs::read(path)
.map_err(|error| manifest_error(Some(path), format!("cannot be read: {error}")))?;
let text = std::str::from_utf8(&bytes)
.map_err(|error| manifest_error(Some(path), format!("is not valid UTF-8: {error}")))?;
parse_manifest_json(text).map_err(|reason| manifest_error(Some(path), reason))
}
pub fn load_from_dir(dir: &Path) -> Result<Option<Self>> {
let path = dir.join(MANIFEST_FILE_NAME);
if !path.is_file() {
return Ok(None);
}
Self::load(&path).map(Some)
}
pub fn to_model_set(&self) -> Result<ModelSet> {
let mut files = Vec::with_capacity(self.files.len());
for file in &self.files {
if file.sha256.trim().is_empty() {
return Err(manifest_error(
None,
format!(
"file `{}` has an empty `sha256`; a local manifest must carry a verified \
hash for every file",
file.name
),
));
}
files.push(ModelFileSpec::new(
file.name.clone(),
file.role,
file.size_bytes,
file.sha256.clone(),
file.source_url.clone().unwrap_or_default(),
)?);
}
let set = ModelSet {
id: self.id.clone(),
family: self.family.clone(),
version: self.version.clone(),
files,
};
set.validate()?;
Ok(set)
}
pub fn validate_files(&self, root: impl AsRef<Path>) -> Result<()> {
let root = root.as_ref();
let status = self.to_model_set()?.status(root);
for (file, state) in &status.files {
match state {
crate::model_set::ModelFileState::Present => {}
crate::model_set::ModelFileState::Missing => {
return Err(RapidOcrError::FileNotFound(root.join(&file.name)));
}
crate::model_set::ModelFileState::Corrupt { expected, actual } => {
return Err(RapidOcrError::HashMismatch {
path: root.join(&file.name),
expected: expected.clone(),
actual: actual.clone(),
});
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelSourceKind {
DefaultTable,
LocalManifest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ModelRequest {
pub text: Option<DefaultModelSelection>,
pub formula: bool,
}
impl ModelRequest {
pub fn text_only(text: DefaultModelSelection) -> Self {
Self {
text: Some(text),
formula: false,
}
}
pub fn text_and_formula(text: DefaultModelSelection) -> Self {
Self {
text: Some(text),
formula: true,
}
}
pub fn formula_only() -> Self {
Self {
text: None,
formula: true,
}
}
pub fn text_roles(&self) -> Vec<ModelRole> {
let Some(selection) = &self.text else {
return Vec::new();
};
let mut roles = vec![ModelRole::Detector];
if selection.include_classifier {
roles.push(ModelRole::Classifier);
}
roles.push(ModelRole::Recognizer);
roles.push(ModelRole::Dictionary);
roles
}
pub fn formula_roles(&self) -> Vec<ModelRole> {
if self.formula {
vec![ModelRole::FormulaRecognizer]
} else {
Vec::new()
}
}
pub fn required_roles(&self) -> Vec<ModelRole> {
let mut roles = self.text_roles();
roles.extend(self.formula_roles());
roles
}
}
#[derive(Debug, Clone)]
pub struct ModelSource {
manifest: Option<ModelManifest>,
}
impl ModelSource {
pub fn select(model_dir: &Path) -> Result<Self> {
Ok(Self {
manifest: ModelManifest::load_from_dir(model_dir)?,
})
}
pub const fn kind(&self) -> ModelSourceKind {
match self.manifest {
Some(_) => ModelSourceKind::LocalManifest,
None => ModelSourceKind::DefaultTable,
}
}
pub fn manifest(&self) -> Option<&ModelManifest> {
self.manifest.as_ref()
}
pub fn model_sets(&self, request: &ModelRequest) -> Result<Vec<ModelSet>> {
match &self.manifest {
Some(manifest) => {
let set = manifest.to_model_set()?;
set.require_roles(&request.required_roles())?;
Ok(vec![set])
}
None => {
let registry = ModelRegistry::from_default_yaml()?;
let mut sets = Vec::new();
if let Some(selection) = &request.text {
let set = registry.text_model_set(selection)?;
set.require_roles(&request.text_roles())?;
sets.push(set);
}
if request.formula {
let set = registry.formula_model_set()?;
set.require_roles(&request.formula_roles())?;
sets.push(set);
}
Ok(sets)
}
}
}
}
fn parse_manifest_json(json: &str) -> std::result::Result<ModelManifest, String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|error| format!("is not valid JSON: {error}"))?;
let object = value
.as_object()
.ok_or_else(|| "must be a JSON object".to_string())?;
match object.get("schema_version") {
None => {
let legacy_keys = ["detector", "recognizer", "dictionary", "classifier"];
if legacy_keys.iter().any(|key| object.contains_key(*key)) {
return Err(format!(
"has no `schema_version` and uses the legacy four-field shape \
(`detector`/`recognizer`/`dictionary`/`classifier`), which is no longer \
supported; migrate to `schema_version: {SUPPORTED_MANIFEST_SCHEMA_VERSION}` \
with a `files` array of `{{ name, role, sha256, size_bytes, source_url }}`"
));
}
Err(format!(
"has no `schema_version`; every manifest must declare \
`schema_version: {SUPPORTED_MANIFEST_SCHEMA_VERSION}`"
))
}
Some(declared) => {
let declared = declared
.as_u64()
.ok_or_else(|| "`schema_version` must be an unsigned integer".to_string())?;
if declared != u64::from(SUPPORTED_MANIFEST_SCHEMA_VERSION) {
return Err(format!(
"declares unsupported `schema_version` {declared}; this build supports \
{SUPPORTED_MANIFEST_SCHEMA_VERSION}"
));
}
serde_json::from_value::<ModelManifest>(value).map_err(|error| {
format!(
"does not match `schema_version` {SUPPORTED_MANIFEST_SCHEMA_VERSION}: {error}"
)
})
}
}
}
fn manifest_error(path: Option<&Path>, reason: impl Display) -> RapidOcrError {
match path {
Some(path) => {
RapidOcrError::ModelResolve(format!("model manifest {}: {reason}", path.display()))
}
None => RapidOcrError::ModelResolve(format!("model manifest: {reason}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::{LangDet, LangRec, ModelType, OcrVersion},
model_registry::DefaultModelSelection,
model_set::{ModelFileState, ModelRole},
test_support::TempDir,
};
const LEGACY_MANIFEST: &str = r#"{
"id": "pp-ocrv6-medium-multilingual",
"family": "PP-OCR",
"version": "v6",
"languages": ["ch", "en"],
"detector": {
"file_name": "PP-OCRv6_det_medium.onnx",
"sha256": "92078b7355007ccfffcd4c8cd441a3afd4538904d06881b29a155e1e679907c2",
"source_url": "https://www.modelscope.cn/models/RapidAI/RapidOCR"
},
"recognizer": {
"file_name": "PP-OCRv6_rec_medium.onnx",
"sha256": "eef444829dbbe18d7fea59a3f6eb75647518d2b3a9568d27c92e42940204894b",
"source_url": "https://www.modelscope.cn/models/RapidAI/RapidOCR"
},
"dictionary": {
"file_name": "ppocrv6_dict.txt",
"sha256": "b5f2bfe2bdd9448429e3e82b51c789775d9b42f2403d082b00662eb77e401c5d",
"source_url": "https://www.modelscope.cn/models/RapidAI/RapidOCR"
},
"classifier": null
}"#;
fn current_manifest() -> String {
r#"{
"schema_version": 1,
"id": "pp-ocrv6-medium",
"family": "PP-OCR",
"version": "PP-OCRv6",
"languages": ["ch"],
"files": [
{ "name": "PP-OCRv6_det_medium.onnx", "role": "detector",
"sha256": "92078b7355007ccfffcd4c8cd441a3afd4538904d06881b29a155e1e679907c2",
"size_bytes": 62119454,
"source_url": "https://www.modelscope.cn/models/RapidAI/RapidOCR/resolve/v3.9.1/onnx/PP-OCRv6/det/PP-OCRv6_det_medium.onnx" },
{ "name": "PP-OCRv6_rec_medium.onnx", "role": "recognizer",
"sha256": "eef444829dbbe18d7fea59a3f6eb75647518d2b3a9568d27c92e42940204894b",
"size_bytes": 76629984,
"source_url": "https://www.modelscope.cn/models/RapidAI/RapidOCR/resolve/v3.9.1/onnx/PP-OCRv6/rec/PP-OCRv6_rec_medium.onnx" },
{ "name": "ppocrv6_dict.txt", "role": "dictionary",
"sha256": "b5f2bfe2bdd9448429e3e82b51c789775d9b42f2403d082b00662eb77e401c5d",
"size_bytes": 74947 }
]
}"#
.to_string()
}
fn text_selection() -> DefaultModelSelection {
DefaultModelSelection {
ocr_version: OcrVersion::PPocrV6,
det_lang: LangDet::Ch,
rec_lang: LangRec::Ch,
model_type: ModelType::Medium,
..DefaultModelSelection::default()
}
}
#[test]
fn legacy_four_field_manifest_is_rejected_with_a_migration_hint() {
let error = ModelManifest::from_json_str(LEGACY_MANIFEST)
.expect_err("the legacy four-field shape must be rejected");
let message = error.to_string();
assert!(message.contains("schema_version"), "{message}");
assert!(message.contains("legacy"), "{message}");
assert!(message.contains("files"), "{message}");
}
#[test]
fn an_unknown_schema_version_is_rejected() {
let json = current_manifest().replace("\"schema_version\": 1", "\"schema_version\": 2");
let error = ModelManifest::from_json_str(&json)
.expect_err("an unknown schema version must be rejected");
let message = error.to_string();
assert!(
message.contains("unsupported `schema_version` 2"),
"{message}"
);
assert!(message.contains("supports 1"), "{message}");
let json = current_manifest().replace("\"schema_version\": 1", "\"schema_version\": \"1\"");
let error =
ModelManifest::from_json_str(&json).expect_err("a non-integer version must fail");
assert!(error.to_string().contains("unsigned integer"), "{error}");
let error = ModelManifest::from_json_str(r#"{ "id": "x", "family": "f", "version": "v" }"#)
.expect_err("a manifest without schema_version must fail");
assert!(
error.to_string().contains("has no `schema_version`"),
"{error}"
);
assert!(!error.to_string().contains("legacy"), "{error}");
}
#[test]
fn the_current_schema_converts_to_a_model_set() {
let manifest = ModelManifest::from_json_str(¤t_manifest())
.expect("the current schema must parse");
assert_eq!(manifest.schema_version, SUPPORTED_MANIFEST_SCHEMA_VERSION);
assert_eq!(manifest.languages, vec!["ch"]);
let set = manifest.to_model_set().expect("conversion must succeed");
assert_eq!(set.id, "pp-ocrv6-medium");
assert_eq!(
set.declared_roles(),
vec![
ModelRole::Detector,
ModelRole::Recognizer,
ModelRole::Dictionary
]
);
let dictionary = set
.files
.iter()
.find(|file| file.role == ModelRole::Dictionary)
.expect("dictionary must be present");
assert!(!dictionary.has_source_url());
assert!(dictionary.has_hash());
let dir = TempDir::new("manifest-validate-missing");
let status = set.status(dir.path());
assert!(
status
.files
.iter()
.all(|(_, state)| *state == ModelFileState::Missing)
);
assert!(!status.complete);
assert_eq!(
status.download_bytes_total,
Some(62_119_454 + 76_629_984 + 74_947)
);
let error = manifest
.validate_files(dir.path())
.expect_err("missing files must fail");
let message = error.to_string();
assert!(message.contains("file not found"), "{message}");
assert!(
message.contains("PP-OCRv6_det_medium.onnx"),
"the error must locate the first missing file: {message}"
);
dir.write("PP-OCRv6_det_medium.onnx", b"not the real model");
let error = manifest
.validate_files(dir.path())
.expect_err("a hash mismatch must fail");
match error {
RapidOcrError::HashMismatch {
path,
expected,
actual,
} => {
assert!(path.ends_with("PP-OCRv6_det_medium.onnx"));
assert_eq!(
expected,
"92078b7355007ccfffcd4c8cd441a3afd4538904d06881b29a155e1e679907c2"
);
assert_ne!(actual, expected);
}
other => panic!("expected HashMismatch, got {other}"),
}
}
#[test]
fn a_manifest_file_must_carry_a_hash_and_a_bare_name() {
let no_hash = current_manifest().replace(
"92078b7355007ccfffcd4c8cd441a3afd4538904d06881b29a155e1e679907c2",
"",
);
let manifest = ModelManifest::from_json_str(&no_hash).expect("json must still parse");
let error = manifest
.to_model_set()
.expect_err("an empty hash must be rejected");
assert!(error.to_string().contains("empty `sha256`"), "{error}");
let escaped = current_manifest()
.replace("PP-OCRv6_det_medium.onnx", "../../PP-OCRv6_det_medium.onnx");
let manifest = ModelManifest::from_json_str(&escaped).expect("json must still parse");
let error = manifest
.to_model_set()
.expect_err("a path-escaping name must be rejected");
assert!(
error.to_string().contains("bare relative file name"),
"{error}"
);
}
#[test]
fn unknown_manifest_fields_are_rejected() {
let json = current_manifest().replace("\"files\":", "\"classifier\": null,\n\"files\":");
let error =
ModelManifest::from_json_str(&json).expect_err("unknown fields must not be ignored");
assert!(error.to_string().contains("classifier"), "{error}");
}
#[test]
fn the_shipped_manifest_example_matches_the_current_schema() {
let manifest =
ModelManifest::from_json_str(include_str!("../assets/manifest.example.json"))
.expect("the shipped manifest example must load");
assert_eq!(manifest.schema_version, SUPPORTED_MANIFEST_SCHEMA_VERSION);
let set = manifest
.to_model_set()
.expect("the shipped manifest example must convert");
assert!(set.has_role(ModelRole::Detector));
assert!(set.has_role(ModelRole::Recognizer));
assert!(set.has_role(ModelRole::Dictionary));
for file in &set.files {
assert!(file.has_hash(), "{file:?}");
assert!(file.has_source_url(), "{file:?}");
}
}
#[test]
fn the_selected_source_is_the_local_manifest_when_present() {
let empty = TempDir::new("source-empty");
let source = ModelSource::select(empty.path()).expect("selection must succeed");
assert_eq!(source.kind(), ModelSourceKind::DefaultTable);
assert!(source.manifest().is_none());
let with_manifest = TempDir::new("source-manifest");
with_manifest.write(MANIFEST_FILE_NAME, current_manifest().as_bytes());
let source = ModelSource::select(with_manifest.path()).expect("selection must succeed");
assert_eq!(source.kind(), ModelSourceKind::LocalManifest);
assert_eq!(
source.manifest().map(|manifest| manifest.id.as_str()),
Some("pp-ocrv6-medium")
);
}
#[test]
fn a_broken_manifest_is_an_error_not_a_fallback_to_the_default_table() {
let dir = TempDir::new("source-broken");
dir.write(MANIFEST_FILE_NAME, b"{ not json");
let error = ModelSource::select(dir.path()).expect_err("a broken manifest must fail");
assert!(error.to_string().contains(MANIFEST_FILE_NAME), "{error}");
dir.write(MANIFEST_FILE_NAME, LEGACY_MANIFEST.as_bytes());
let error =
ModelSource::select(dir.path()).expect_err("a legacy manifest must fail, not degrade");
assert!(error.to_string().contains("legacy"), "{error}");
}
#[test]
fn a_local_manifest_is_never_merged_with_the_default_table() {
let dir = TempDir::new("source-no-merge");
let manifest = r#"{
"schema_version": 1,
"id": "formula-only",
"family": "RapidDoc",
"version": "v1.0.0",
"files": [
{ "name": "pp_formulanet_plus_m.onnx", "role": "formula_recognizer",
"sha256": "71b6d389cf7b857e45252a4b98cfced1a3ffca7bf24d9497d02d052a41d9493b",
"size_bytes": 593915961 }
]
}"#;
dir.write(MANIFEST_FILE_NAME, manifest.as_bytes());
let source = ModelSource::select(dir.path()).expect("selection must succeed");
let sets = source
.model_sets(&ModelRequest::formula_only())
.expect("the formula role is declared by the manifest");
assert_eq!(sets.len(), 1);
assert_eq!(sets[0].id, "formula-only");
assert_eq!(sets[0].files.len(), 1);
let error = source
.model_sets(&ModelRequest::text_only(text_selection()))
.expect_err("the manifest cannot satisfy the text pipeline");
let message = error.to_string();
assert!(message.contains("detector"), "{message}");
assert!(message.contains("recognizer"), "{message}");
assert!(message.contains("dictionary"), "{message}");
}
#[test]
fn the_default_table_source_builds_the_text_and_formula_sets() {
let dir = TempDir::new("source-default");
let source = ModelSource::select(dir.path()).expect("selection must succeed");
let sets = source
.model_sets(&ModelRequest::text_and_formula(text_selection()))
.expect("the default table satisfies both pipelines");
assert_eq!(sets.len(), 2);
assert_eq!(sets[0].id, "PP-OCRv6-medium-ch");
assert_eq!(sets[1].id, "PP-FormulaNet_plus-M");
let statuses: Vec<_> = sets.iter().map(|set| set.status(dir.path())).collect();
for status in &statuses {
assert!(!status.complete, "no file exists in an empty model dir");
assert!(
status.files.iter().all(|(file, _)| file.has_hash()),
"every default-table file must carry a hash: {status:?}"
);
}
assert_eq!(statuses[1].download_bytes_total, Some(593_916_167));
assert_eq!(statuses[0].download_bytes_total, Some(138_824_385));
let registry = ModelRegistry::from_default_yaml().expect("registry should parse");
let v4 = registry
.text_model_set(&DefaultModelSelection {
ocr_version: OcrVersion::PPocrV4,
model_type: ModelType::Mobile,
..text_selection()
})
.expect("v4 text model set should build");
assert_eq!(v4.status(dir.path()).download_bytes_total, None);
source
.model_sets(&ModelRequest::text_only(text_selection()))
.expect("the text roles must all be declared");
}
#[test]
fn the_default_table_set_is_complete_against_real_v6_medium_models() {
let Some(root) = crate::test_support::ocr_model_root() else {
return;
};
let dir = root.join("medium");
if !dir.is_dir() {
eprintln!(
"skipping test: {} does not contain the v6 medium models",
dir.display()
);
return;
}
let registry = ModelRegistry::from_default_yaml().expect("registry should parse");
let set = registry
.text_model_set(&DefaultModelSelection {
ocr_version: OcrVersion::PPocrV6,
model_type: ModelType::Medium,
..text_selection()
})
.expect("the v6 medium text model set should build");
let status = set.status(&dir);
let names: Vec<&str> = status
.files
.iter()
.map(|(file, _)| file.name.as_str())
.collect();
assert_eq!(
names,
vec![
"PP-OCRv6_det_medium.onnx",
"PP-OCRv6_rec_medium.onnx",
"ppocrv6_dict.txt"
],
"the set names must be the on-disk names derived from the URLs"
);
assert!(
status
.files
.iter()
.all(|(_, state)| *state == ModelFileState::Present),
"every real v6 medium file must hash-match the default table: {status:?}"
);
assert!(status.complete);
assert_eq!(status.download_bytes_total, Some(0));
}
}