use alloc::vec::Vec;
use std::io::{Read, Seek, SeekFrom};
use binrw::{BinRead, Endian};
use crate::{
domain::{ChannelMetadata, GraphMetadata},
error::{BiopacError, Warning},
};
pub(crate) use dtype::SampleType;
pub mod channel;
pub mod dtype;
pub mod foreign;
pub mod graph;
use channel::{ChannelHeaderRaw, parse_channel_metadata};
use dtype::{ChannelDtypeRaw, parse_sample_type};
use foreign::ForeignDataRaw;
use graph::{
GraphHeaderPost4Raw, GraphHeaderPre4Raw, REVISION_POST4, detect_byte_order,
parse_graph_header_post4, parse_graph_header_pre4,
};
#[derive(Debug)]
pub(crate) struct ParsedHeaders {
pub graph_metadata: GraphMetadata,
pub channel_metadata: Vec<ChannelMetadata>,
pub foreign_data: Vec<u8>,
pub sample_types: Vec<SampleType>,
pub data_start_offset: u64,
pub warnings: Vec<Warning>,
}
impl ParsedHeaders {
pub(crate) fn uncompressed_data_byte_count(&self) -> Option<u64> {
self.channel_metadata
.iter()
.zip(self.sample_types.iter())
.try_fold(0u64, |acc, (meta, st)| {
if meta.sample_count == 0 {
None
} else {
let channel_bytes =
u64::from(meta.sample_count).checked_mul(st.byte_size() as u64)?;
acc.checked_add(channel_bytes)
}
})
}
}
const CHAN_DESC_OFFSET: u64 = 128;
const CHAN_DESC_MIN_LEN: i32 = 168;
const CHAN_VAR_SAMPLE_POST4_OFFSET: u64 = 152;
const CHAN_VAR_SAMPLE_POST4_MIN_LEN: i32 = 154;
const CHAN_VAR_SAMPLE_PRE4_OFFSET: u64 = 250;
const CHAN_VAR_SAMPLE_PRE4_MIN_LEN: i32 = 252;
const REVISION_V30R: i32 = 44;
pub(crate) fn parse_headers<R: Read + Seek>(reader: &mut R) -> Result<ParsedHeaders, BiopacError> {
let warnings: Vec<Warning> = Vec::new();
let (endian, version) = detect_byte_order(reader)?;
let pos = reader.stream_position().map_err(BiopacError::Io)?;
let (graph_metadata, graph_header_len, pre4_chan_header_len, expected_padding_count) =
if version < REVISION_POST4 {
let raw = GraphHeaderPre4Raw::read_options(reader, endian, ())
.map_err(|e| binrw_to_parse_error(&e, pos, "graph header (Pre-4)"))?;
let parsed = parse_graph_header_pre4(raw, endian)?;
(
parsed.metadata,
parsed.graph_header_len,
Some(parsed.chan_header_len),
0u16,
)
} else {
let raw = GraphHeaderPost4Raw::read_options(reader, endian, ())
.map_err(|e| binrw_to_parse_error(&e, pos, "graph header (Post-4)"))?;
let parsed = parse_graph_header_post4(raw, endian)?;
(
parsed.metadata,
parsed.graph_header_len,
None,
parsed.expected_padding_count,
)
};
reader
.seek(SeekFrom::Start(graph_header_len))
.map_err(BiopacError::Io)?;
skip_padding_blocks(reader, endian, expected_padding_count)?;
let n_channels = usize::from(graph_metadata.channel_count);
let mut channel_metadata = Vec::with_capacity(n_channels);
for i in 0..n_channels {
let meta = read_single_channel_header(reader, endian, version, pre4_chan_header_len, i)?;
channel_metadata.push(meta);
}
let fd_pos = reader.stream_position().map_err(BiopacError::Io)?;
let fd_raw = ForeignDataRaw::read_options(reader, endian, ())
.map_err(|e| binrw_to_parse_error(&e, fd_pos, "foreign data"))?;
let foreign_data = fd_raw.data;
let mut sample_types = Vec::with_capacity(n_channels);
for i in 0..n_channels {
let dt_pos = reader.stream_position().map_err(BiopacError::Io)?;
let raw = ChannelDtypeRaw::read_options(reader, endian, ())
.map_err(|e| binrw_to_parse_error(&e, dt_pos, "dtype header"))?;
#[expect(
clippy::cast_possible_truncation,
reason = "channel index bounded by MAX_CHANNELS (256)"
)]
let st = parse_sample_type(raw, i as u16, dt_pos)?;
sample_types.push(st);
}
let data_start_offset = reader.stream_position().map_err(BiopacError::Io)?;
Ok(ParsedHeaders {
graph_metadata,
channel_metadata,
foreign_data,
sample_types,
data_start_offset,
warnings,
})
}
fn skip_padding_blocks<R: Read + Seek>(
reader: &mut R,
endian: Endian,
count: u16,
) -> Result<(), BiopacError> {
for _ in 0..count {
let pad_start = reader.stream_position().map_err(BiopacError::Io)?;
let mut len_bytes = [0u8; 4];
reader.read_exact(&mut len_bytes).map_err(BiopacError::Io)?;
let pad_len = match endian {
Endian::Big => i32::from_be_bytes(len_bytes),
Endian::Little => i32::from_le_bytes(len_bytes),
};
let skip = u64::try_from(pad_len).unwrap_or(40).max(4);
reader
.seek(SeekFrom::Start(pad_start + skip))
.map_err(BiopacError::Io)?;
}
Ok(())
}
fn read_var_sample_divider<R: Read + Seek>(
reader: &mut R,
endian: Endian,
version: i32,
raw: &ChannelHeaderRaw,
ch_start: u64,
) -> Result<i16, BiopacError> {
if version >= REVISION_POST4 && raw.chan_header_len >= CHAN_VAR_SAMPLE_POST4_MIN_LEN {
reader
.seek(SeekFrom::Start(ch_start + CHAN_VAR_SAMPLE_POST4_OFFSET))
.map_err(BiopacError::Io)?;
let mut vsd = [0u8; 2];
reader.read_exact(&mut vsd).map_err(BiopacError::Io)?;
Ok(match endian {
Endian::Big => i16::from_be_bytes(vsd),
Endian::Little => i16::from_le_bytes(vsd),
})
} else if version >= REVISION_V30R && raw.chan_header_len >= CHAN_VAR_SAMPLE_PRE4_MIN_LEN {
reader
.seek(SeekFrom::Start(ch_start + CHAN_VAR_SAMPLE_PRE4_OFFSET))
.map_err(BiopacError::Io)?;
let mut vsd = [0u8; 2];
reader.read_exact(&mut vsd).map_err(BiopacError::Io)?;
Ok(match endian {
Endian::Big => i16::from_be_bytes(vsd),
Endian::Little => i16::from_le_bytes(vsd),
})
} else {
Ok(1i16)
}
}
fn read_single_channel_header<R: Read + Seek>(
reader: &mut R,
endian: Endian,
version: i32,
pre4_chan_header_len: Option<i32>,
index: usize,
) -> Result<ChannelMetadata, BiopacError> {
let ch_start = reader.stream_position().map_err(BiopacError::Io)?;
let raw = ChannelHeaderRaw::read_options(reader, endian, ())
.map_err(|e| binrw_to_parse_error(&e, ch_start, "channel header"))?;
if let Some(expected_len) = pre4_chan_header_len {
if raw.chan_header_len < expected_len {
} else if raw.chan_header_len != expected_len {
let _ = expected_len;
}
}
let var_sample_divider = read_var_sample_divider(reader, endian, version, &raw, ch_start)?;
#[expect(
clippy::cast_possible_truncation,
reason = "channel index bounded by MAX_CHANNELS (256) validated in graph header"
)]
let mut meta = parse_channel_metadata(raw, var_sample_divider, index as u16, ch_start)?;
#[expect(
clippy::cast_sign_loss,
reason = "chan_header_len validated >= CHANNEL_HEADER_MIN_LEN by parse_channel_metadata"
)]
let ch_end = ch_start + raw.chan_header_len as u64;
if raw.chan_header_len >= CHAN_DESC_MIN_LEN {
reader
.seek(SeekFrom::Start(ch_start + CHAN_DESC_OFFSET))
.map_err(BiopacError::Io)?;
let mut desc_buf = [0u8; 40];
reader.read_exact(&mut desc_buf).map_err(BiopacError::Io)?;
let end = desc_buf.iter().position(|&b| b == 0).unwrap_or(40);
meta.description =
alloc::string::String::from_utf8_lossy(desc_buf.get(..end).unwrap_or(&desc_buf))
.into_owned();
}
reader
.seek(SeekFrom::Start(ch_end))
.map_err(BiopacError::Io)?;
Ok(meta)
}
fn binrw_to_parse_error(e: &binrw::Error, byte_offset: u64, context: &str) -> BiopacError {
BiopacError::Validation(alloc::format!(
"binary read error at 0x{byte_offset:X} ({context}): {e}"
))
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::{boxed::Box, vec::Vec};
use std::io::Cursor;
#[expect(
clippy::indexing_slicing,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "test helper: n_channels bounded by tests; slices at fixed offsets within fixed-size arrays"
)]
fn make_pre4_acq(n_channels: usize, sample_time_ms: f64) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
let mut gh = [0u8; 256];
let version: i32 = 38;
let chan_header_len: i16 = 252;
gh[2..6].copy_from_slice(&version.to_le_bytes()); gh[6..10].copy_from_slice(&256i32.to_le_bytes()); gh[10..12].copy_from_slice(&(n_channels as i16).to_le_bytes()); gh[16..24].copy_from_slice(&sample_time_ms.to_le_bytes()); gh[252..254].copy_from_slice(&chan_header_len.to_le_bytes()); buf.extend_from_slice(&gh);
for i in 0..n_channels {
let mut ch = [0u8; 252];
ch[0..4].copy_from_slice(&252i32.to_le_bytes()); let name = alloc::format!("CH{i}");
let name_bytes = name.as_bytes();
let copy_len = name_bytes.len().min(39);
ch[6..6 + copy_len].copy_from_slice(&name_bytes[..copy_len]);
ch[88..92].copy_from_slice(&1000i32.to_le_bytes());
ch[92..100].copy_from_slice(&1.0f64.to_le_bytes());
ch[100..108].copy_from_slice(&0.0f64.to_le_bytes());
buf.extend_from_slice(&ch);
}
buf.extend_from_slice(&0i32.to_le_bytes());
for _ in 0..n_channels {
buf.extend_from_slice(&4u16.to_le_bytes()); buf.extend_from_slice(&2u16.to_le_bytes()); }
buf
}
#[test]
fn parse_headers_pre4_single_channel() -> Result<(), Box<dyn std::error::Error>> {
let buf = make_pre4_acq(1, 1.0); let mut cursor = Cursor::new(&buf);
let headers = parse_headers(&mut cursor)?;
assert_eq!(headers.graph_metadata.channel_count, 1);
assert!((headers.graph_metadata.samples_per_second - 1000.0).abs() < 1e-9);
assert_eq!(headers.channel_metadata.len(), 1);
assert_eq!(
headers.channel_metadata.first().map(|m| m.name.as_str()),
Some("CH0")
);
assert_eq!(headers.sample_types.len(), 1);
assert_eq!(headers.sample_types.first().copied(), Some(SampleType::I16));
assert_eq!(headers.data_start_offset, 516);
Ok(())
}
#[test]
fn parse_headers_pre4_multi_channel() -> Result<(), Box<dyn std::error::Error>> {
let buf = make_pre4_acq(3, 2.0); let mut cursor = Cursor::new(&buf);
let headers = parse_headers(&mut cursor)?;
assert_eq!(headers.graph_metadata.channel_count, 3);
assert!((headers.graph_metadata.samples_per_second - 500.0).abs() < 1e-9);
assert_eq!(headers.channel_metadata.len(), 3);
assert_eq!(headers.sample_types.len(), 3);
assert_eq!(headers.data_start_offset, 1028);
Ok(())
}
#[test]
#[expect(
clippy::indexing_slicing,
reason = "buf constructed by make_pre4_acq with known layout; fd_offset is within bounds"
)]
fn parse_headers_foreign_data_preserved() -> Result<(), Box<dyn std::error::Error>> {
let mut buf = make_pre4_acq(1, 1.0);
let fd_offset = 256 + 252;
buf[fd_offset..fd_offset + 4].copy_from_slice(&8i32.to_le_bytes());
buf.splice(fd_offset + 4..fd_offset + 4, [0xAA, 0xBB, 0xCC, 0xDD]);
let mut cursor = Cursor::new(&buf);
let headers = parse_headers(&mut cursor)?;
assert_eq!(headers.foreign_data.len(), 4);
assert_eq!(headers.foreign_data.first().copied(), Some(0xAA));
Ok(())
}
#[expect(
clippy::indexing_slicing,
clippy::cast_possible_truncation,
reason = "test helper: fixed-size arrays and bounded indices"
)]
fn make_pre4_acq_with_description(description: &str) -> Vec<u8> {
let chan_header_len: i32 = 252; let mut buf: Vec<u8> = Vec::new();
let mut gh = [0u8; 256];
gh[2..6].copy_from_slice(&38i32.to_le_bytes()); gh[6..10].copy_from_slice(&256i32.to_le_bytes()); gh[10..12].copy_from_slice(&1i16.to_le_bytes()); gh[16..24].copy_from_slice(&1.0f64.to_le_bytes()); gh[252..254].copy_from_slice(&(chan_header_len as i16).to_le_bytes());
buf.extend_from_slice(&gh);
let mut ch = [0u8; 252];
ch[0..4].copy_from_slice(&chan_header_len.to_le_bytes());
ch[6..9].copy_from_slice(b"ECG");
ch[88..92].copy_from_slice(&1000i32.to_le_bytes());
ch[92..100].copy_from_slice(&1.0f64.to_le_bytes());
ch[100..108].copy_from_slice(&0.0f64.to_le_bytes());
let desc_bytes = description.as_bytes();
let copy_len = desc_bytes.len().min(39);
ch[128..128 + copy_len].copy_from_slice(&desc_bytes[..copy_len]);
buf.extend_from_slice(&ch);
buf.extend_from_slice(&0i32.to_le_bytes());
buf.extend_from_slice(&4u16.to_le_bytes());
buf.extend_from_slice(&2u16.to_le_bytes());
buf
}
#[test]
fn parse_headers_channel_extended_description_read() -> Result<(), Box<dyn std::error::Error>> {
let buf = make_pre4_acq_with_description("Subject resting ECG recording");
let mut cursor = Cursor::new(&buf);
let headers = parse_headers(&mut cursor)?;
assert_eq!(headers.channel_metadata.len(), 1);
let desc = headers
.channel_metadata
.first()
.map(|m| m.description.as_str());
assert_eq!(desc, Some("Subject resting ECG recording"));
Ok(())
}
#[test]
fn parse_headers_channel_no_description_when_header_short()
-> Result<(), Box<dyn std::error::Error>> {
let buf = make_pre4_acq(1, 1.0);
let mut cursor = Cursor::new(&buf);
let headers = parse_headers(&mut cursor)?;
let desc = headers
.channel_metadata
.first()
.map(|m| m.description.as_str());
assert_eq!(
desc,
Some(""),
"description should be empty (all zero bytes)"
);
Ok(())
}
}