Skip to main content

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}