use std::path::Path;
use crate::error::{RapidOcrError, Result};
pub const STRUCTURE_SPECIAL_TOKEN_COUNT: usize = 2;
pub fn parse_character_metadata(raw: &str) -> Result<Vec<String>> {
if raw.is_empty() {
return Err(RapidOcrError::Config(
"table structure model `character` metadata is empty".to_string(),
));
}
if !raw.contains('\n') {
return Err(RapidOcrError::Config(format!(
"table structure model `character` metadata must be newline-separated, got a single \
line of {} characters; the formula domain's JSON tokenizer metadata cannot be \
reused here",
raw.chars().count()
)));
}
let mut tokens = Vec::new();
for (index, line) in raw.split('\n').enumerate() {
let token = line.strip_suffix('\r').unwrap_or(line);
if token.is_empty() {
return Err(RapidOcrError::Config(format!(
"table structure model `character` metadata has an empty token at line {index}"
)));
}
tokens.push(token.to_string());
}
Ok(tokens)
}
pub fn validate_vocabulary_width(
tokens: &[String],
structure_last_dim: usize,
model_path: &Path,
) -> Result<()> {
let expected = tokens.len() + STRUCTURE_SPECIAL_TOKEN_COUNT;
if structure_last_dim != expected {
return Err(RapidOcrError::Config(format!(
"table structure model vocabulary width mismatch: `character` metadata has {} tokens \
(+{STRUCTURE_SPECIAL_TOKEN_COUNT} special = {expected} classes) but the structure \
output's last dim is {structure_last_dim} (model={})",
tokens.len(),
model_path.display()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
const REAL_METADATA: &str = concat!(
"<thead>\n</thead>\n<tbody>\n</tbody>\n<tr>\n</tr>\n<td\n>\n</td>\n",
" colspan=\"2\"\n colspan=\"3\"\n colspan=\"4\"\n colspan=\"5\"\n colspan=\"6\"\n",
" colspan=\"7\"\n colspan=\"8\"\n colspan=\"9\"\n colspan=\"10\"\n colspan=\"11\"\n",
" colspan=\"12\"\n colspan=\"13\"\n colspan=\"14\"\n colspan=\"15\"\n colspan=\"16\"\n",
" colspan=\"17\"\n colspan=\"18\"\n colspan=\"19\"\n colspan=\"20\"\n",
" rowspan=\"2\"\n rowspan=\"3\"\n rowspan=\"4\"\n rowspan=\"5\"\n rowspan=\"6\"\n",
" rowspan=\"7\"\n rowspan=\"8\"\n rowspan=\"9\"\n rowspan=\"10\"\n rowspan=\"11\"\n",
" rowspan=\"12\"\n rowspan=\"13\"\n rowspan=\"14\"\n rowspan=\"15\"\n rowspan=\"16\"\n",
" rowspan=\"17\"\n rowspan=\"18\"\n rowspan=\"19\"\n rowspan=\"20\"\n<td></td>",
);
#[test]
fn real_vocabulary_parses_into_forty_eight_fragments() {
let tokens = parse_character_metadata(REAL_METADATA).expect("real metadata must parse");
assert_eq!(tokens.len(), 48);
assert_eq!(REAL_METADATA.chars().count(), 582);
assert!(validate_vocabulary_width(&tokens, 50, Path::new("slanet-plus.onnx")).is_ok());
}
#[test]
fn tags_are_split_into_fragments() {
let tokens = parse_character_metadata(REAL_METADATA).unwrap();
assert_eq!(tokens[6], "<td");
assert_eq!(tokens[7], ">");
assert_eq!(tokens[8], "</td>");
assert_eq!(tokens[47], "<td></td>");
assert!(
!tokens.iter().any(|t| t == "<td>"),
"the vocabulary must not contain a whole `<td>` token"
);
assert_eq!(tokens[9], " colspan=\"2\"");
assert_eq!(tokens[28], " rowspan=\"2\"");
}
#[test]
fn json_metadata_is_rejected() {
let error =
parse_character_metadata(r#"{"fast_tokenizer_file":{"model":{"type":"WordLevel"}}}"#)
.expect_err("single-line JSON must be rejected");
let message = error.to_string();
assert!(message.contains("newline-separated"), "{message}");
assert!(message.contains("JSON"), "{message}");
}
#[test]
fn empty_and_blank_token_metadata_is_rejected() {
assert!(parse_character_metadata("").is_err());
assert!(parse_character_metadata("only-one-line").is_err());
let error = parse_character_metadata("<td\n\n</td>").expect_err("blank token rejected");
assert!(error.to_string().contains("empty token"), "{error}");
assert!(parse_character_metadata("<td\n</td>\n").is_err());
}
#[test]
fn windows_line_endings_are_tolerated() {
let tokens = parse_character_metadata("<td\r\n>\r\n</td>").expect("crlf must parse");
assert_eq!(tokens, vec!["<td", ">", "</td>"]);
}
#[test]
fn vocabulary_width_mismatch_is_rejected() {
let tokens = parse_character_metadata(REAL_METADATA).unwrap();
let error = validate_vocabulary_width(&tokens, 51, Path::new("slanet-plus.onnx"))
.expect_err("51 classes cannot match a 48-token vocabulary");
let message = error.to_string();
assert!(message.contains("48 tokens"), "{message}");
assert!(message.contains("50 classes"), "{message}");
assert!(message.contains("last dim is 51"), "{message}");
assert!(message.contains("slanet-plus.onnx"), "{message}");
}
}