use super::common::{self, Interleave, Layout};
use crate::formats::RasterDriver;
use crate::{Error, RasterDataset, Result};
use std::path::{Path, PathBuf};
use wbraster::{CrsInfo, DataType, Raster};
const DATA_EXTENSIONS: &[&str] = &["", "dat", "img", "bsq", "bil", "bip", "raw", "bin", "envi"];
pub(crate) fn is_envi(path: &Path) -> bool {
!super::hfa::is_hfa(path) && header_path(path).is_some()
}
fn header_path(path: &Path) -> Option<PathBuf> {
let is_header = |p: &Path| std::fs::read(p).ok().is_some_and(|b| b.starts_with(b"ENVI"));
if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("hdr")) {
return is_header(path).then(|| path.to_path_buf());
}
let mut appended = path.as_os_str().to_owned();
appended.push(".hdr");
[PathBuf::from(appended), common::sibling(path, "hdr")].into_iter().find(|p| p.is_file() && is_header(p))
}
fn data_path(header: &Path) -> Result<PathBuf> {
let stem = header.with_extension("");
if stem.extension().is_some() && stem.is_file() {
return Ok(stem);
}
for ext in DATA_EXTENSIONS {
for e in [ext.to_string(), ext.to_ascii_uppercase()] {
let p = if e.is_empty() { stem.clone() } else { header.with_extension(&e) };
if p.is_file() && p != header {
return Ok(p);
}
}
}
Err(Error::InvalidData(format!("{}: no ENVI data file next to the header", header.display())))
}
fn parse_header(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut lines = text.lines().skip(1);
while let Some(line) = lines.next() {
let Some((k, v)) = line.split_once('=') else { continue };
let mut value = v.trim().to_string();
if value.starts_with('{') {
while !value.contains('}') {
match lines.next() {
Some(l) => {
value.push('\n');
value.push_str(l);
}
None => break,
}
}
value = value.trim_start_matches('{').trim().trim_end_matches('}').trim().to_string();
}
out.push((k.trim().to_ascii_lowercase(), value));
}
out
}
pub(crate) fn read(path: &Path) -> Result<RasterDataset> {
let header = header_path(path).ok_or_else(|| Error::InvalidData(format!("{}: no ENVI header", path.display())))?;
let data = if header == path { data_path(&header)? } else { path.to_path_buf() };
let h = parse_header(&std::fs::read_to_string(&header)?);
let get = |k: &str| h.iter().find(|(key, _)| key == k).map(|(_, v)| v.as_str());
let num = |k: &str| get(k).and_then(|v| v.trim().parse::<usize>().ok());
let bad = |what: &str| Error::InvalidData(format!("{}: missing or invalid '{what}'", header.display()));
let data_type = match num("data type").ok_or_else(|| bad("data type"))? {
1 => DataType::U8,
2 => DataType::I16,
3 => DataType::I32,
4 => DataType::F32,
5 => DataType::F64,
12 => DataType::U16,
13 => DataType::U32,
14 => DataType::I64,
15 => DataType::U64,
t => return Err(Error::Unsupported(format!("{}: ENVI data type {t} is not supported", header.display()))),
};
let interleave = match get("interleave").unwrap_or("bsq").to_ascii_lowercase().as_str() {
"bil" => Interleave::Bil,
"bip" => Interleave::Bip,
_ => Interleave::Bsq,
};
let layout = Layout {
cols: num("samples").ok_or_else(|| bad("samples"))?,
rows: num("lines").ok_or_else(|| bad("lines"))?,
bands: num("bands").unwrap_or(1),
data_type,
little_endian: num("byte order").unwrap_or(0) == 0,
interleave,
offset: num("header offset").unwrap_or(0),
bottom_up: false,
};
let pixels = common::decode(&std::fs::read(&data)?, &layout)?;
let nodata = get("data ignore value").and_then(common::parse_f64);
let (gt, map_crs) = get("map info").and_then(parse_map_info).unwrap_or((common::NO_GEOTRANSFORM, None));
let crs = get("coordinate system string").and_then(common::crs_from_text).or(map_crs).unwrap_or_default();
let mut raster = common::make_raster(layout.cols, layout.rows, layout.bands, pixels, gt, crs, nodata)?;
if let Some(names) = get("band names") {
for (i, name) in names.split(',').map(str::trim).filter(|n| !n.is_empty()).enumerate() {
raster.metadata.push((format!("band_{}_name", i + 1), name.to_string()));
}
}
Ok(RasterDataset::with_driver(raster, RasterDriver::ENVI, nodata.is_some()))
}
fn parse_map_info(v: &str) -> Option<([f64; 6], Option<CrsInfo>)> {
let f: Vec<&str> = v.split(',').map(str::trim).collect();
let n = |i: usize| f.get(i).and_then(|s| s.parse::<f64>().ok());
let (rx, ry, x, y, sx, sy) = (n(1)?, n(2)?, n(3)?, n(4)?, n(5)?, n(6)?);
let upside_down =
f.iter().any(|s| s.strip_prefix("rotation=").and_then(|r| r.trim().parse::<f64>().ok()).is_some_and(|r| r.abs() == 180.0));
let gt = if upside_down {
[x - (rx - 1.0) * sx, sx, 0.0, y - (ry - 1.0) * sy, 0.0, sy]
} else {
[x - (rx - 1.0) * sx, sx, 0.0, y + (ry - 1.0) * sy, 0.0, -sy]
};
let datum = f
.iter()
.skip(7)
.find(|s| !s.contains('=') && s.parse::<f64>().is_err() && !s.eq_ignore_ascii_case("north") && !s.eq_ignore_ascii_case("south"));
let datum = datum.map(|d| d.to_ascii_lowercase()).unwrap_or_default();
let projection = f[0].to_ascii_lowercase();
let crs = if projection == "utm" {
let zone = f.get(7).and_then(|s| s.parse::<u32>().ok())?;
let south = f.get(8).is_some_and(|s| s.eq_ignore_ascii_case("south"));
let code = match datum.as_str() {
"north america 1983" if !south => 26900 + zone,
"north america 1927" if !south => 26700 + zone,
_ if south => 32700 + zone,
_ => 32600 + zone,
};
Some(CrsInfo::from_epsg(code))
} else if projection == "geographic lat/lon" {
Some(CrsInfo::from_epsg(match datum.as_str() {
"north america 1983" => 4269,
"north america 1927" => 4267,
"wgs-72" => 4322,
_ => 4326,
}))
} else {
None
};
Some((gt, crs))
}
pub(crate) fn write(r: &Raster, nodata: Option<f64>, path: &Path) -> Result<()> {
let (data_path, header) = if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("hdr")) {
(path.with_extension("dat"), path.to_path_buf())
} else {
(path.to_path_buf(), path.with_extension("hdr"))
};
let widened;
let r = if r.data_type == DataType::I8 {
widened = common::convert(r, DataType::I16);
&widened
} else {
r
};
let code = match r.data_type {
DataType::U8 => 1,
DataType::I16 => 2,
DataType::I32 => 3,
DataType::F32 => 4,
DataType::F64 => 5,
DataType::U16 => 12,
DataType::U32 => 13,
DataType::I64 => 14,
DataType::U64 => 15,
DataType::I8 => 2,
};
let layout = Layout {
cols: r.cols,
rows: r.rows,
bands: r.bands,
data_type: r.data_type,
little_endian: true,
interleave: Interleave::Bsq,
offset: 0,
bottom_up: false,
};
std::fs::write(&data_path, common::encode(r, &layout))?;
let name = data_path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let mut h = format!(
"ENVI\ndescription = {{\n{name}}}\nsamples = {}\nlines = {}\nbands = {}\nheader offset = 0\nfile type = ENVI Standard\ndata type = {code}\ninterleave = bsq\nbyte order = 0\n",
r.cols, r.rows, r.bands
);
let location = format!("1, 1, {}, {}, {}, {}", r.x_min, r.y_max(), r.cell_size_x, r.cell_size_y);
let crs = super::common::spatial_ref(&r.crs);
let utm = crs.epsg.and_then(|c| match c {
32601..=32660 => Some((c - 32600, "North", "WGS-84")),
32701..=32760 => Some((c - 32700, "South", "WGS-84")),
26901..=26923 => Some((c - 26900, "North", "North America 1983")),
26701..=26722 => Some((c - 26700, "North", "North America 1927")),
_ => None,
});
if let Some((zone, hemisphere, datum)) = utm {
h.push_str(&format!("map info = {{UTM, {location}, {zone}, {hemisphere}, {datum}}}\n"));
} else if crs.epsg == Some(4326) {
h.push_str(&format!("map info = {{Geographic Lat/Lon, {location}, WGS-84}}\n"));
} else {
h.push_str(&format!("map info = {{Arbitrary, {location}, 0, North}}\n"));
}
if let Some(wkt) = common::esri_wkt(&r.crs) {
h.push_str(&format!("coordinate system string = {{{wkt}}}\n"));
}
if let Some(nd) = nodata {
h.push_str(&format!("data ignore value = {}\n", common::format_value(nd, r.data_type)));
}
std::fs::write(header, h)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_map_info() {
let (gt, crs) = parse_map_info("UTM, 1.000, 1.000, 500000.000, 4100000.000, 30, 30, 11, North, WGS-84, units=Meters").unwrap();
assert_eq!(gt, [500000.0, 30.0, 0.0, 4100000.0, 0.0, -30.0]);
assert_eq!(crs.unwrap().epsg, Some(32611));
let (gt, _) = parse_map_info("Arbitrary, 2, 3, 100, 200, 10, 10, 0, North").unwrap();
assert_eq!(gt, [90.0, 10.0, 0.0, 220.0, 0.0, -10.0]);
}
#[test]
fn round_trips_through_the_data_file_and_header() {
let dir = tempfile::tempdir().unwrap();
let data: Vec<u16> = (0..12).collect();
let r = common::make_raster(
3,
2,
2,
wbraster::raster::RasterData::U16(data.clone()),
[500000.0, 30.0, 0.0, 4100000.0, 0.0, -30.0],
CrsInfo::from_epsg(32611),
None,
)
.unwrap();
let p = dir.path().join("x.envi");
write(&r, Some(7.0), &p).unwrap();
for open in [p.clone(), dir.path().join("x.hdr")] {
let ds = read(&open).unwrap();
assert_eq!(ds.raster.data_u16().unwrap(), &data[..]);
assert_eq!(ds.nodata(), Some(7.0));
assert_eq!(ds.crs().epsg, Some(32611));
assert_eq!(ds.geo_transform(), [500000.0, 30.0, 0.0, 4100000.0, 0.0, -30.0]);
}
}
}