Skip to main content

geonative_image/
worldfile.rs

1//! World file (.jgw / .pgw / .tfw / .wld) parser.
2//!
3//! ## Layout
4//!
5//! Six numeric lines, one number per line:
6//!
7//! ```text
8//! 0.5         ← line 1: pixel width (world units)
9//! 0           ← line 2: rotation y (axis 2)
10//! 0           ← line 3: rotation x (axis 1)
11//! -0.5        ← line 4: pixel height (negative for north-up)
12//! 144.96      ← line 5: world x at CENTRE of pixel (0, 0)
13//! -37.81      ← line 6: world y at CENTRE of pixel (0, 0)
14//! ```
15//!
16//! ## The half-pixel shift
17//!
18//! Lines 5 + 6 describe the world coords of the **centre** of pixel (0, 0).
19//! GDAL's `GeoTransform` convention (which we follow in `core::raster`)
20//! uses the **upper-left corner** of the image as origin. We convert at
21//! parse time by shifting the origin by half a pixel.
22
23use geonative_core::raster::GeoTransform;
24
25use crate::error::{ImageError, Result};
26
27/// Parse the 6-line world-file ASCII into a [`GeoTransform`].
28pub fn parse(text: &str) -> Result<GeoTransform> {
29    let mut nums = [0.0f64; 6];
30    let mut count = 0usize;
31    for (i, line) in text.lines().enumerate() {
32        let trimmed = line.trim();
33        if trimmed.is_empty() {
34            continue;
35        }
36        if count >= 6 {
37            return Err(ImageError::world_file(format!(
38                "world file has more than 6 numeric lines (extra on line {})",
39                i + 1
40            )));
41        }
42        nums[count] = trimmed
43            .parse::<f64>()
44            .map_err(|e| ImageError::world_file(format!("line {} ('{}'): {e}", i + 1, trimmed)))?;
45        count += 1;
46    }
47    if count != 6 {
48        return Err(ImageError::world_file(format!(
49            "world file needs 6 numeric lines, got {count}"
50        )));
51    }
52
53    let pixel_w = nums[0];
54    let rot_y = nums[1];
55    let rot_x = nums[2];
56    let pixel_h = nums[3];
57    let centre_x = nums[4];
58    let centre_y = nums[5];
59
60    // Shift from pixel-centre to upper-left-corner: subtract half a pixel.
61    let origin_x = centre_x - pixel_w * 0.5;
62    let origin_y = centre_y - pixel_h * 0.5;
63
64    Ok(GeoTransform {
65        origin: [origin_x, origin_y],
66        pixel_size: [pixel_w, pixel_h],
67        rotation: [rot_x, rot_y],
68    })
69}
70
71/// Look up the conventional world-file extension for an image. JGW for .jpg,
72/// PGW for .png, etc. Also returns `.wld` as a universal fallback.
73pub fn extensions_for(image_ext: &str) -> &'static [&'static str] {
74    match image_ext.to_ascii_lowercase().as_str() {
75        "jpg" | "jpeg" => &["jgw", "jpgw", "wld"],
76        "png" => &["pgw", "pngw", "wld"],
77        "tif" | "tiff" => &["tfw", "tifw", "wld"],
78        "bmp" => &["bpw", "wld"],
79        "gif" => &["gfw", "wld"],
80        _ => &["wld"],
81    }
82}
83
84/// Locate a world-file sidecar next to an image path.
85pub fn find_sidecar(image_path: &std::path::Path) -> Result<std::path::PathBuf> {
86    let stem = image_path.file_stem().ok_or_else(|| {
87        ImageError::malformed(format!("image path has no stem: {}", image_path.display()))
88    })?;
89    let parent = image_path.parent().unwrap_or(std::path::Path::new("."));
90    let image_ext = image_path
91        .extension()
92        .and_then(|s| s.to_str())
93        .unwrap_or("");
94    for ext in extensions_for(image_ext) {
95        for candidate in [
96            parent.join(format!("{}.{ext}", stem.to_string_lossy())),
97            parent.join(format!("{}.{}", stem.to_string_lossy(), ext.to_uppercase())),
98        ] {
99            if candidate.exists() {
100                return Ok(candidate);
101            }
102        }
103    }
104    Err(ImageError::MissingWorldFile {
105        image: image_path.display().to_string(),
106        expected: format!(
107            "{}/{}.{{{}}}",
108            parent.display(),
109            stem.to_string_lossy(),
110            extensions_for(image_ext).join(",")
111        ),
112    })
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn parses_canonical_north_up() {
121        let text = "0.5\n0\n0\n-0.5\n144.96\n-37.81\n";
122        let gt = parse(text).unwrap();
123        assert_eq!(gt.pixel_size, [0.5, -0.5]);
124        assert_eq!(gt.rotation, [0.0, 0.0]);
125        // Half-pixel shift: centre at (144.96, -37.81) → upper-left at
126        // (144.96 - 0.25, -37.81 - (-0.25)) = (144.71, -37.56)
127        assert!((gt.origin[0] - (144.96 - 0.25)).abs() < 1e-9);
128        assert!((gt.origin[1] - (-37.81 - (-0.25))).abs() < 1e-9);
129        assert!(gt.is_north_up());
130    }
131
132    #[test]
133    fn tolerates_blank_lines() {
134        let text = "0.5\n\n0\n0\n-0.5\n144\n-37\n\n";
135        let gt = parse(text).unwrap();
136        assert_eq!(gt.pixel_size[0], 0.5);
137    }
138
139    #[test]
140    fn rejects_too_few_numbers() {
141        let text = "0.5\n0\n0\n";
142        assert!(parse(text).is_err());
143    }
144
145    #[test]
146    fn rejects_garbage() {
147        let text = "not_a_number\n0\n0\n0\n0\n0\n";
148        assert!(parse(text).is_err());
149    }
150
151    #[test]
152    fn extensions_for_jpg() {
153        let exts = extensions_for("jpg");
154        assert!(exts.contains(&"jgw"));
155    }
156
157    #[test]
158    fn extensions_for_png() {
159        let exts = extensions_for("png");
160        assert!(exts.contains(&"pgw"));
161    }
162
163    #[test]
164    fn find_sidecar_works() {
165        let dir = std::env::temp_dir().join(format!("wf_test_{}", std::process::id()));
166        let _ = std::fs::remove_dir_all(&dir);
167        std::fs::create_dir_all(&dir).unwrap();
168
169        let img = dir.join("ortho.jpg");
170        std::fs::write(&img, b"").unwrap();
171        let wld = dir.join("ortho.jgw");
172        std::fs::write(&wld, "0.5\n0\n0\n-0.5\n144\n-37\n").unwrap();
173
174        let found = find_sidecar(&img).unwrap();
175        assert_eq!(found, wld);
176    }
177
178    #[test]
179    fn find_sidecar_missing_errors() {
180        let dir = std::env::temp_dir().join(format!("wf_test_miss_{}", std::process::id()));
181        let _ = std::fs::remove_dir_all(&dir);
182        std::fs::create_dir_all(&dir).unwrap();
183        let img = dir.join("noworld.jpg");
184        std::fs::write(&img, b"").unwrap();
185        let err = find_sidecar(&img).unwrap_err();
186        assert!(matches!(err, ImageError::MissingWorldFile { .. }));
187    }
188}