use crate::error::FitsError;
use crate::error::Result;
use crate::header::Header;
use crate::keyword::key;
use crate::table::ColumnData;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AsciiKind {
Char,
Integer,
Float,
}
#[derive(Debug, Clone)]
pub struct AsciiColumn {
pub name: Option<String>,
pub unit: Option<String>,
pub kind: AsciiKind,
pub start: usize,
pub width: usize,
pub decimals: usize,
pub tscale: f64,
pub tzero: f64,
pub null: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AsciiTable {
pub nrows: usize,
pub columns: Vec<AsciiColumn>,
row_len: usize,
bytes: Vec<u8>,
}
impl AsciiTable {
pub(crate) fn from_data(header: &Header, data: Vec<u8>) -> Result<AsciiTable> {
let row_len = header
.get_integer("NAXIS1")
.ok_or(FitsError::MissingKeyword { name: "NAXIS1" })?
.max(0) as usize;
let nrows = header
.get_integer("NAXIS2")
.ok_or(FitsError::MissingKeyword { name: "NAXIS2" })?
.max(0) as usize;
let tfields = match header.get_integer("TFIELDS") {
Some(t) if (0..=999).contains(&t) => t as usize,
Some(_) => return Err(FitsError::KeywordOutOfRange { name: "TFIELDS" }),
None => return Err(FitsError::MissingKeyword { name: "TFIELDS" }),
};
let mut columns = Vec::with_capacity(tfields);
for n in 1..=tfields {
let tbcol = header
.get_integer(key!("TBCOL{n}").as_str())
.ok_or(FitsError::MissingKeyword { name: "TBCOLn" })?;
let tform = header
.get_text(key!("TFORM{n}").as_str())
.ok_or(FitsError::MissingKeyword { name: "TFORMn" })?;
let fmt = parse_ascii_tform(tform)?;
let start = (tbcol.max(1) - 1) as usize;
if start.checked_add(fmt.width).is_none_or(|end| end > row_len) {
return Err(FitsError::KeywordOutOfRange { name: "TBCOLn" });
}
columns.push(AsciiColumn {
name: header
.get_text(key!("TTYPE{n}").as_str())
.map(str::to_string)
.filter(|s| !s.is_empty()),
unit: header
.get_text(key!("TUNIT{n}").as_str())
.map(str::to_string)
.filter(|s| !s.is_empty()),
kind: fmt.kind,
start,
width: fmt.width,
decimals: fmt.decimals,
tscale: header.get_real(key!("TSCAL{n}").as_str()).unwrap_or(1.0),
tzero: header.get_real(key!("TZERO{n}").as_str()).unwrap_or(0.0),
null: header
.get_text(key!("TNULL{n}").as_str())
.map(|s| s.trim().to_string()),
});
}
let total = nrows.checked_mul(row_len).ok_or(FitsError::UnexpectedEof)?;
if data.len() < total {
return Err(FitsError::UnexpectedEof);
}
Ok(AsciiTable {
nrows,
columns,
row_len,
bytes: data,
})
}
pub fn column_index(&self, name: &str) -> Option<usize> {
self.columns.iter().position(|c| {
c.name
.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(name))
})
}
fn column_index_checked(&self, name: &str) -> Result<usize> {
self.column_index(name)
.ok_or_else(|| FitsError::ColumnNotFound {
name: name.to_string(),
})
}
pub fn column_by_idx(&self, index: usize) -> Result<AsciiColumnReader<'_>> {
if index >= self.columns.len() {
return Err(FitsError::ColumnIndexOutOfBounds {
index,
len: self.columns.len(),
});
}
Ok(AsciiColumnReader { table: self, index })
}
pub fn column_by_name(&self, name: &str) -> Result<AsciiColumnReader<'_>> {
let index = self.column_index_checked(name)?;
Ok(AsciiColumnReader { table: self, index })
}
fn field(&self, col: &AsciiColumn, r: usize) -> Result<&str> {
let row = &self.bytes[r * self.row_len..(r + 1) * self.row_len];
let end = (col.start + col.width).min(row.len());
let raw = if col.start < end {
&row[col.start..end]
} else {
&[]
};
let text = std::str::from_utf8(raw).map_err(|_| FitsError::InvalidValue {
card: "non-UTF-8 bytes in ASCII-table field".to_string(),
})?;
Ok(text.trim())
}
}
#[derive(Debug, Clone, Copy)]
pub struct AsciiColumnReader<'a> {
table: &'a AsciiTable,
index: usize,
}
impl<'a> AsciiColumnReader<'a> {
pub fn descriptor(&self) -> &'a AsciiColumn {
&self.table.columns[self.index]
}
pub fn raw(&self) -> Result<ColumnData> {
let table = self.table;
let col = self.descriptor();
match col.kind {
AsciiKind::Char => Ok(ColumnData::Text(
(0..table.nrows)
.map(|r| Ok(table.field(col, r)?.to_string()))
.collect::<Result<_>>()?,
)),
AsciiKind::Integer => {
let mut out = Vec::with_capacity(table.nrows);
for r in 0..table.nrows {
let s = table.field(col, r)?;
out.push(if s.is_empty() || col.is_null(s) {
0
} else {
s.parse().map_err(|_| FitsError::InvalidValue {
card: s.to_string(),
})?
});
}
Ok(ColumnData::I64(out))
}
AsciiKind::Float => {
let mut out = Vec::with_capacity(table.nrows);
for r in 0..table.nrows {
let s = table.field(col, r)?;
out.push(if s.is_empty() || col.is_null(s) {
0.0
} else {
parse_ascii_float(s, col.decimals).ok_or_else(|| {
FitsError::InvalidValue {
card: s.to_string(),
}
})?
});
}
Ok(ColumnData::F64(out))
}
}
}
pub fn physical(&self) -> Result<Vec<f64>> {
let table = self.table;
let col = self.descriptor();
if col.kind == AsciiKind::Char {
return Err(FitsError::NonNumericColumn { code: 'A' });
}
let mut out = Vec::with_capacity(table.nrows);
for r in 0..table.nrows {
let s = table.field(col, r)?;
if col.is_null(s) {
out.push(f64::NAN);
continue;
}
let raw = if s.is_empty() {
0.0
} else {
parse_ascii_float(s, col.decimals).ok_or_else(|| FitsError::InvalidValue {
card: s.to_string(),
})?
};
out.push(col.tzero + col.tscale * raw);
}
Ok(out)
}
}
impl AsciiColumn {
fn is_null(&self, field: &str) -> bool {
self.null.as_deref() == Some(field)
}
}
fn parse_ascii_float(field: &str, decimals: usize) -> Option<f64> {
let normalized = field.replace(['D', 'd'], "E");
let (mantissa, exponent) = match split_mantissa_exponent(&normalized) {
Some((m, e)) => (m, Some(e)),
None => (normalized.as_str(), None),
};
let mut value: f64 = if mantissa.contains('.') || decimals == 0 {
mantissa.parse().ok()?
} else {
mantissa.parse::<f64>().ok()? / 10f64.powi(decimals as i32)
};
if let Some(e) = exponent {
value *= 10f64.powi(e.trim().parse::<i32>().ok()?);
}
Some(value)
}
fn split_mantissa_exponent(s: &str) -> Option<(&str, &str)> {
if let Some(i) = s.find(['E', 'e']) {
return Some((&s[..i], &s[i + 1..]));
}
s.char_indices()
.find(|&(i, c)| i > 0 && (c == '+' || c == '-'))
.map(|(i, _)| (&s[..i], &s[i..]))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AsciiFormat {
kind: AsciiKind,
width: usize,
decimals: usize,
}
fn parse_ascii_tform(value: &str) -> Result<AsciiFormat> {
let s = value.trim();
let invalid = || FitsError::InvalidTform {
tform: value.to_string(),
};
let letter = s.bytes().next().ok_or_else(invalid)?;
let kind = match letter {
b'A' => AsciiKind::Char,
b'I' => AsciiKind::Integer,
b'F' | b'E' | b'D' => AsciiKind::Float,
_ => return Err(invalid()),
};
let rest = &s[1..];
let (width, decimals) = match rest.split_once('.') {
Some((w, d)) => (
w.trim().parse().map_err(|_| invalid())?,
d.trim().parse().map_err(|_| invalid())?,
),
None => (rest.trim().parse().map_err(|_| invalid())?, 0),
};
Ok(AsciiFormat {
kind,
width,
decimals,
})
}
#[cfg(test)]
mod tests;