oxigeo/format.rs
1//! Dataset format detection and identification.
2//!
3//! Exposes [`DatasetFormat`], the enum that tags every supported geospatial
4//! format together with its detection helpers (extension-based, magic-byte,
5//! and on-disk variants) and a stable human-readable driver name.
6
7/// Detected format of a geospatial dataset.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum DatasetFormat {
10 /// GeoTIFF / Cloud-Optimized GeoTIFF (.tif, .tiff)
11 GeoTiff,
12 /// GeoJSON (.geojson, .json)
13 GeoJson,
14 /// ESRI Shapefile (.shp)
15 Shapefile,
16 /// GeoParquet (.parquet, .geoparquet)
17 GeoParquet,
18 /// NetCDF (.nc, .nc4)
19 NetCdf,
20 /// HDF5 (.h5, .hdf5, .he5)
21 Hdf5,
22 /// Zarr (.zarr directory)
23 Zarr,
24 /// GRIB/GRIB2 (.grib, .grib2, .grb, .grb2)
25 Grib,
26 /// STAC catalog (.json with STAC metadata)
27 Stac,
28 /// Terrain formats
29 Terrain,
30 /// Virtual Raster Tiles (.vrt)
31 Vrt,
32 /// FlatGeobuf (.fgb)
33 FlatGeobuf,
34 /// JPEG2000 (.jp2, .j2k)
35 Jpeg2000,
36 /// GeoPackage (.gpkg, SQLite-based)
37 GeoPackage,
38 /// PMTiles v3 single-file tile archive (.pmtiles)
39 PMTiles,
40 /// MBTiles SQLite tile archive (.mbtiles)
41 MBTiles,
42 /// Cloud Optimized Point Cloud (.copc.laz)
43 Copc,
44 /// Plain LAS / LAZ point cloud (.las, .laz) — not COPC-structured.
45 ///
46 /// COPC is a specific LAZ 1.4 profile requiring an octree-hierarchy VLR and
47 /// chunked layout. A generic `.las`/`.laz` file does not have that
48 /// structure, so it is tagged `Las` rather than `Copc`. Only the compound
49 /// `.copc.laz` extension (or a verified COPC VLR) is reported as `Copc`.
50 Las,
51 /// Unknown / user-specified
52 Unknown,
53}
54
55impl DatasetFormat {
56 /// Detect format from file extension.
57 ///
58 /// Returns `DatasetFormat::Unknown` if the extension is not recognized.
59 /// For `.copc.laz` files, the compound extension is checked first.
60 pub fn from_extension(path: &str) -> Self {
61 // Check compound extensions first (e.g. .copc.laz)
62 let lower = path.to_lowercase();
63 if lower.ends_with(".copc.laz") {
64 return Self::Copc;
65 }
66
67 let ext = std::path::Path::new(path)
68 .extension()
69 .and_then(|e| e.to_str())
70 .map(|e| e.to_lowercase())
71 .unwrap_or_default();
72
73 match ext.as_str() {
74 "tif" | "tiff" => Self::GeoTiff,
75 "geojson" => Self::GeoJson,
76 "shp" => Self::Shapefile,
77 "parquet" | "geoparquet" => Self::GeoParquet,
78 "nc" | "nc4" => Self::NetCdf,
79 "h5" | "hdf5" | "he5" => Self::Hdf5,
80 "zarr" => Self::Zarr,
81 "grib" | "grib2" | "grb" | "grb2" => Self::Grib,
82 "vrt" => Self::Vrt,
83 "fgb" => Self::FlatGeobuf,
84 "jp2" | "j2k" => Self::Jpeg2000,
85 "gpkg" => Self::GeoPackage,
86 "pmtiles" => Self::PMTiles,
87 "mbtiles" => Self::MBTiles,
88 // Plain LAS/LAZ — COPC is only assumed for the compound `.copc.laz`
89 // extension handled above.
90 "laz" | "las" => Self::Las,
91 _ => Self::Unknown,
92 }
93 }
94
95 /// Detect format purely from a byte slice — no file I/O.
96 ///
97 /// Pass the first 72 bytes of a file (`MAGIC_READ_SIZE`).
98 /// Returns `None` when no known magic signature is matched.
99 ///
100 /// Note: ZIP magic (`PK\x03\x04`) and SQLite magic are mapped to
101 /// [`DatasetFormat::GeoPackage`] as a conservative default; callers
102 /// can refine the choice with the file extension when needed.
103 pub fn detect_from_magic_bytes(bytes: &[u8]) -> Option<Self> {
104 use crate::magic::*;
105
106 if bytes.len() < 2 {
107 return None;
108 }
109
110 // TIFF / BigTIFF — little-endian or big-endian
111 if bytes.starts_with(&TIFF_LE_MAGIC) || bytes.starts_with(&TIFF_BE_MAGIC) {
112 if bytes.len() >= 4 {
113 let version = if bytes[0] == 0x49 {
114 u16::from_le_bytes([bytes[2], bytes[3]])
115 } else {
116 u16::from_be_bytes([bytes[2], bytes[3]])
117 };
118 if version == TIFF_VERSION || version == BIGTIFF_VERSION {
119 return Some(Self::GeoTiff);
120 }
121 }
122 return Some(Self::GeoTiff);
123 }
124
125 // JPEG 2000
126 if bytes.len() >= 12 && bytes[..12] == JP2_MAGIC {
127 return Some(Self::Jpeg2000);
128 }
129
130 // HDF5
131 if bytes.len() >= 8 && bytes[..8] == HDF5_MAGIC {
132 return Some(Self::Hdf5);
133 }
134
135 // NetCDF (CDF\x01, CDF\x02, or CDF\x05 for NetCDF-4)
136 if bytes.len() >= 4 && bytes[..3] == NETCDF_MAGIC && matches!(bytes[3], 0x01 | 0x02 | 0x05)
137 {
138 return Some(Self::NetCdf);
139 }
140
141 // FlatGeobuf — checked before ZIP to avoid mis-classification
142 if bytes.len() >= 8 && bytes[..8] == FLATGEOBUF_MAGIC {
143 return Some(Self::FlatGeobuf);
144 }
145
146 // PMTiles v3
147 if bytes.len() >= 7 && bytes[..7] == PMTILES_MAGIC {
148 return Some(Self::PMTiles);
149 }
150
151 // LAS / LAZ. The generic `LASF` magic is present in every LAS/LAZ file,
152 // COPC or not — and the COPC-identifying octree VLR sits far past the
153 // 72-byte magic window we read here — so classify as plain `Las`.
154 // Callers that need COPC disambiguate via the `.copc.laz` extension (see
155 // `detect`) or a full VLR scan.
156 if bytes.len() >= 4 && bytes[..4] == LAS_MAGIC {
157 return Some(Self::Las);
158 }
159
160 // GRIB / GRIB2
161 if bytes.len() >= 4 && bytes[..4] == GRIB_MAGIC {
162 return Some(Self::Grib);
163 }
164
165 // GeoParquet (Parquet PAR1)
166 if bytes.len() >= 4 && bytes[..4] == GEOPARQUET_MAGIC {
167 return Some(Self::GeoParquet);
168 }
169
170 // SQLite database (full 16-byte header)
171 if bytes.len() >= 16 && bytes[..16] == SQLITE_MAGIC {
172 return Some(Self::GeoPackage);
173 }
174
175 // ZIP local-file header (PK\x03\x04) — conservative: assume GeoPackage
176 if bytes.len() >= 4 && bytes[..4] == ZIP_MAGIC {
177 return Some(Self::GeoPackage);
178 }
179
180 None
181 }
182
183 /// Detect format by reading magic bytes from a file on disk.
184 ///
185 /// Opens the file, reads 72 bytes (`MAGIC_READ_SIZE`), then calls
186 /// [`DatasetFormat::detect_from_magic_bytes`]. When the magic check yields
187 /// [`DatasetFormat::GeoPackage`] the file extension is used to disambiguate
188 /// between GeoPackage (`.gpkg`), MBTiles (`.mbtiles`), and generic SQLite.
189 ///
190 /// # Errors
191 ///
192 /// Returns `std::io::Error` if the file cannot be opened or read.
193 pub fn detect(path: &std::path::Path) -> std::io::Result<Self> {
194 use crate::magic::MAGIC_READ_SIZE;
195 use std::io::Read as _;
196
197 let mut file = std::fs::File::open(path)?;
198 let mut buf = vec![0u8; MAGIC_READ_SIZE];
199 let n = file.read(&mut buf)?;
200 buf.truncate(n);
201
202 let magic_fmt = Self::detect_from_magic_bytes(&buf);
203
204 let resolved = match magic_fmt {
205 // ZIP / SQLite: cross-check extension to pick the right variant
206 Some(Self::GeoPackage) => {
207 let ext = path
208 .extension()
209 .and_then(|e| e.to_str())
210 .map(str::to_lowercase)
211 .unwrap_or_default();
212 match ext.as_str() {
213 "mbtiles" => Self::MBTiles,
214 "gpkg" => Self::GeoPackage,
215 _ => Self::GeoPackage,
216 }
217 }
218 // Plain LASF magic can't reveal the COPC octree VLR (it lives past
219 // the magic window). Promote to COPC only when the compound
220 // `.copc.laz` extension says so.
221 Some(Self::Las) => {
222 let is_copc = path
223 .to_str()
224 .map(|s| s.to_lowercase().ends_with(".copc.laz"))
225 .unwrap_or(false);
226 if is_copc { Self::Copc } else { Self::Las }
227 }
228 Some(fmt) => fmt,
229 None => {
230 // Fall back to extension
231 let path_str = path.to_str().unwrap_or("");
232 Self::from_extension(path_str)
233 }
234 };
235
236 Ok(resolved)
237 }
238
239 /// Human-readable driver name (matches GDAL naming convention).
240 pub fn driver_name(&self) -> &'static str {
241 match self {
242 Self::GeoTiff => "GTiff",
243 Self::GeoJson => "GeoJSON",
244 Self::Shapefile => "ESRI Shapefile",
245 Self::GeoParquet => "GeoParquet",
246 Self::NetCdf => "netCDF",
247 Self::Hdf5 => "HDF5",
248 Self::Zarr => "Zarr",
249 Self::Grib => "GRIB",
250 Self::Stac => "STAC",
251 Self::Terrain => "Terrain",
252 Self::Vrt => "VRT",
253 Self::FlatGeobuf => "FlatGeobuf",
254 Self::Jpeg2000 => "JPEG2000",
255 Self::GeoPackage => "GPKG",
256 Self::PMTiles => "PMTiles",
257 Self::MBTiles => "MBTiles",
258 Self::Copc => "COPC",
259 Self::Las => "LAS",
260 Self::Unknown => "Unknown",
261 }
262 }
263}
264
265impl core::fmt::Display for DatasetFormat {
266 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
267 f.write_str(self.driver_name())
268 }
269}