use std::path::{Path, PathBuf};
pub const FORMULA_MODEL_RELATIVE: &str = "formula/recognition/onnx/pp_formulanet_plus_m.onnx";
fn env_path(name: &str) -> Option<PathBuf> {
let raw = std::env::var(name).ok()?;
let trimmed = raw.trim().trim_matches('"');
if trimmed.is_empty() {
return None;
}
Some(PathBuf::from(trimmed))
}
fn require_external_assets() -> bool {
std::env::var("RAPID_STRUCTURE_REQUIRE_EXTERNAL_ASSETS")
.map(|value| value.trim() == "1")
.unwrap_or(false)
}
pub fn asset(description: &str, value: Option<PathBuf>) -> Option<PathBuf> {
match value {
Some(path) => Some(path),
None => {
if require_external_assets() {
panic!(
"missing external test asset: {description}; set the documented \
RAPID_STRUCTURE_MODEL_ROOT / RAPID_STRUCTURE_FORMULA_TEST_ROOT environment variables"
);
}
eprintln!("skipping test: {description} is not available in this environment");
None
}
}
}
pub fn fixture_dir(kind: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(kind)
}
pub fn formula_model_path() -> Option<PathBuf> {
let candidate = env_path("RAPID_STRUCTURE_FORMULA_MODEL")
.filter(|path| path.is_file())
.or_else(|| {
let root = env_path("RAPID_STRUCTURE_MODEL_ROOT")?;
if root.is_file() {
return Some(root);
}
let direct = root.join(FORMULA_MODEL_RELATIVE);
direct.is_file().then_some(direct)
});
asset(
"PP-FormulaNet_plus-M onnx (set RAPID_STRUCTURE_MODEL_ROOT or RAPID_STRUCTURE_FORMULA_MODEL)",
candidate,
)
}
pub fn formula_detector_path() -> Option<PathBuf> {
let candidate = env_path("RAPID_STRUCTURE_FORMULA_DETECT_MODEL")
.filter(|path| path.is_file())
.or_else(|| {
let root = env_path("RAPID_STRUCTURE_MODEL_ROOT")?;
let direct = root.join("formula/detection/pix2text-mfd-1.5.onnx");
direct.is_file().then_some(direct)
});
asset(
"pix2text-mfd-1.5 onnx (set RAPID_STRUCTURE_MODEL_ROOT or RAPID_STRUCTURE_FORMULA_DETECT_MODEL)",
candidate,
)
}
pub const TABLE_STRUCTURE_MODEL_RELATIVE: &str = "table/slanet-plus.onnx";
pub fn table_structure_model_path() -> Option<PathBuf> {
let candidate = env_path("RAPID_STRUCTURE_TABLE_STRUCTURE_MODEL")
.filter(|path| path.is_file())
.or_else(|| {
let direct = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("models")
.join(TABLE_STRUCTURE_MODEL_RELATIVE);
if direct.is_file() {
return Some(direct);
}
let root = env_path("RAPID_STRUCTURE_MODEL_ROOT")?;
let nested = root.join(TABLE_STRUCTURE_MODEL_RELATIVE);
nested.is_file().then_some(nested)
});
asset(
"SLANet-plus onnx (set RAPID_STRUCTURE_MODEL_ROOT or RAPID_STRUCTURE_TABLE_STRUCTURE_MODEL)",
candidate,
)
}
pub fn ocr_model_root() -> Option<PathBuf> {
let candidate = env_path("RAPID_STRUCTURE_MODEL_ROOT").filter(|path| path.is_dir());
asset("OCR model root (set RAPID_STRUCTURE_MODEL_ROOT)", candidate)
}
pub fn page_fixture(name: &str) -> Option<PathBuf> {
let candidate = env_path("RAPID_STRUCTURE_TEST_IMAGES")
.map(|root| root.join(name))
.filter(|path| path.is_file())
.or_else(|| {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("datasets")
.join("text")
.join(name);
root.is_file().then_some(root)
});
asset(
&format!(
"page fixture `{name}` (set RAPID_STRUCTURE_TEST_IMAGES or RAPID_STRUCTURE_MODEL_ROOT)"
),
candidate,
)
}
pub fn formula_dataset_root() -> Option<PathBuf> {
let candidate = env_path("RAPID_STRUCTURE_FORMULA_TEST_ROOT").filter(|path| path.is_dir());
asset(
"formula test set (set RAPID_STRUCTURE_FORMULA_TEST_ROOT)",
candidate,
)
}
pub fn table_detection_fixture(name: &str) -> Option<PathBuf> {
let candidate = env_path("RAPID_STRUCTURE_TABLE_DETECTION_IMAGES")
.map(|root| root.join(name))
.filter(|path| path.is_file())
.or_else(|| {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("refer")
.join("RapidTable-main")
.join("tests")
.join("test_files")
.join(name);
root.is_file().then_some(root)
})
.or_else(|| {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("datasets")
.join("text")
.join(name);
root.is_file().then_some(root)
});
asset(
&format!(
"table detection fixture `{name}` (set RAPID_STRUCTURE_TABLE_DETECTION_IMAGES, or place it \
under refer/RapidTable-main/tests/test_files or datasets/text)"
),
candidate,
)
}
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
pub fn new(label: &str) -> Self {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"rapid-structure-rs-{label}-{}-{suffix}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("temp dir should be creatable");
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn write(&self, name: &str, contents: &[u8]) {
std::fs::write(self.path.join(name), contents).expect("fixture should be writable");
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
pub struct HttpFixture {
addr: std::net::SocketAddr,
requests: std::sync::Arc<std::sync::atomic::AtomicUsize>,
shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
accept: Option<std::thread::JoinHandle<()>>,
}
pub struct FixtureRequest {
pub index: usize,
pub method: String,
pub target: String,
pub headers: Vec<(String, String)>,
}
impl FixtureRequest {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
}
pub enum FixtureResponse {
Body(Vec<u8>),
Status {
status: u16,
reason: &'static str,
headers: Vec<(String, String)>,
body: Vec<u8>,
},
Chunked(Vec<u8>),
CloseDelimited(Vec<u8>),
Stall {
head: Vec<u8>,
hold: std::time::Duration,
},
Silent { hold: std::time::Duration },
}
impl FixtureResponse {
pub fn ok(body: impl Into<Vec<u8>>) -> Self {
Self::Body(body.into())
}
pub fn status(status: u16, reason: &'static str, body: impl Into<Vec<u8>>) -> Self {
Self::Status {
status,
reason,
headers: Vec::new(),
body: body.into(),
}
}
pub fn redirect(location: &str) -> Self {
Self::Status {
status: 302,
reason: "Found",
headers: vec![("Location".to_string(), location.to_string())],
body: Vec::new(),
}
}
pub fn chunked(body: impl Into<Vec<u8>>) -> Self {
Self::Chunked(body.into())
}
pub fn close_delimited(body: impl Into<Vec<u8>>) -> Self {
Self::CloseDelimited(body.into())
}
pub fn stall_after_head(content_length: u64, hold: std::time::Duration) -> Self {
Self::Stall {
head: format!(
"HTTP/1.1 200 OK\r\nContent-Length: {content_length}\r\nConnection: close\r\n\r\n"
)
.into_bytes(),
hold,
}
}
pub fn silent(hold: std::time::Duration) -> Self {
Self::Silent { hold }
}
}
impl HttpFixture {
pub fn start<F>(handler: F) -> Self
where
F: Fn(&FixtureRequest) -> FixtureResponse + Send + Sync + 'static,
{
use std::{
net::TcpListener,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize},
},
thread,
time::Duration,
};
let listener =
TcpListener::bind("127.0.0.1:0").expect("the fixture server must bind a loopback port");
let addr = listener
.local_addr()
.expect("a bound listener must expose its address");
listener
.set_nonblocking(true)
.expect("the fixture listener must be non-blocking so it can be stopped");
let requests = Arc::new(AtomicUsize::new(0));
let shutdown = Arc::new(AtomicBool::new(false));
let handler = Arc::new(handler);
let accept = {
let requests = Arc::clone(&requests);
let shutdown = Arc::clone(&shutdown);
thread::spawn(move || {
while !shutdown.load(std::sync::atomic::Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let index = requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let handler = Arc::clone(&handler);
thread::spawn(move || {
serve_connection(stream, index, handler.as_ref())
});
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(2));
}
Err(_) => thread::sleep(Duration::from_millis(2)),
}
}
})
};
Self {
addr,
requests,
shutdown,
accept: Some(accept),
}
}
pub fn url(&self, path: &str) -> String {
format!("http://{}{path}", self.addr)
}
pub const fn addr(&self) -> std::net::SocketAddr {
self.addr
}
pub fn request_count(&self) -> usize {
self.requests.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Drop for HttpFixture {
fn drop(&mut self) {
self.shutdown
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(handle) = self.accept.take() {
let _ = handle.join();
}
}
}
fn serve_connection(
mut stream: std::net::TcpStream,
index: usize,
handler: &(dyn Fn(&FixtureRequest) -> FixtureResponse + Send + Sync),
) {
let _ = stream.set_nonblocking(false);
let _ = stream.set_nodelay(true);
let Some(request) = read_request(&mut stream, index) else {
return;
};
let response = handler(&request);
write_response(&mut stream, response);
}
fn read_request(stream: &mut std::net::TcpStream, index: usize) -> Option<FixtureRequest> {
use std::io::Read as _;
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
if buffer.windows(4).any(|window| window == b"\r\n\r\n") || buffer.len() > 64 * 1024 {
break;
}
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
Err(_) => return None,
}
}
let text = String::from_utf8_lossy(&buffer).into_owned();
let mut lines = text.split("\r\n");
let mut request_line = lines.next()?.split_whitespace();
let method = request_line.next()?.to_string();
let target = request_line.next()?.to_string();
let headers = lines
.take_while(|line| !line.is_empty())
.filter_map(|line| {
let (name, value) = line.split_once(':')?;
Some((name.trim().to_string(), value.trim().to_string()))
})
.collect();
Some(FixtureRequest {
index,
method,
target,
headers,
})
}
fn write_response(stream: &mut std::net::TcpStream, response: FixtureResponse) {
use std::io::Write as _;
match response {
FixtureResponse::Body(body) => {
write_head(stream, 200, "OK", &[], &body);
}
FixtureResponse::Status {
status,
reason,
headers,
body,
} => {
write_head(stream, status, reason, &headers, &body);
}
FixtureResponse::Chunked(body) => {
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n",
);
for chunk in body.chunks(1024) {
let _ = stream.write_all(format!("{:x}\r\n", chunk.len()).as_bytes());
let _ = stream.write_all(chunk);
let _ = stream.write_all(b"\r\n");
}
let _ = stream.write_all(b"0\r\n\r\n");
}
FixtureResponse::CloseDelimited(body) => {
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n");
let _ = stream.write_all(&body);
}
FixtureResponse::Stall { head, hold } => {
let _ = stream.write_all(&head);
let _ = stream.flush();
std::thread::sleep(hold);
}
FixtureResponse::Silent { hold } => {
std::thread::sleep(hold);
}
}
let _ = stream.flush();
}
fn write_head(
stream: &mut std::net::TcpStream,
status: u16,
reason: &str,
headers: &[(String, String)],
body: &[u8],
) {
use std::io::Write as _;
let mut head = format!("HTTP/1.1 {status} {reason}\r\n");
let declares_length = headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("content-length"));
for (name, value) in headers {
head.push_str(&format!("{name}: {value}\r\n"));
}
head.push_str("Connection: close\r\n");
if !declares_length {
head.push_str(&format!("Content-Length: {}\r\n", body.len()));
}
head.push_str("\r\n");
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
}
#[cfg(test)]
mod tests {
use std::{
io::{Read as _, Write as _},
net::TcpStream,
time::Duration,
};
use super::{FixtureResponse, HttpFixture};
#[test]
fn an_accepted_connection_waits_for_a_late_request() {
let server = HttpFixture::start(|_| FixtureResponse::ok(b"pong".to_vec()));
let mut stream = TcpStream::connect(server.addr()).expect("the fixture must accept");
std::thread::sleep(Duration::from_millis(150));
stream
.write_all(b"GET /late HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
.expect("the request must be writable");
let mut response = String::new();
stream
.read_to_string(&mut response)
.expect("the fixture must answer a late request");
assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}");
assert!(response.ends_with("pong"), "{response}");
assert_eq!(server.request_count(), 1);
}
}