Skip to main content

zpdf_document/
measure.rs

1//! Geospatial measure dictionaries (ISO 32000-1 §13.2, Table 261-265).
2//!
3//! A measure dictionary describes a coordinate system, measurement units, and
4//! geographic bounds for annotations that represent real-world locations —
5//! chiefly used with PDF 2.0 Projection annotations for mapping and GIS
6//! applications. This module parses these dictionaries into a read-only data
7//! model; it does not perform coordinate transformations or rendering.
8//!
9//! The measure info is exposed through [`Annotation::measure`] and can be
10//! displayed via `zpdf info` / `zpdf links` CLI commands.
11
12use std::borrow::Cow;
13use zpdf_core::{PdfDict, PdfObject};
14use zpdf_parser::PdfFile;
15
16/// A measure dictionary describing geospatial coordinate systems and units
17/// for an annotation (PDF §13.2, Table 261).
18#[derive(Debug, Clone)]
19pub struct Measure {
20    /// `/Subtype` - the measurement type (e.g., `GEO` for geographic).
21    pub subtype: String,
22    /// `/Bounds` - a rectangle in default user space defining the measurement
23    /// region (optional, defaults to annotation `/Rect`).
24    pub bounds: Option<[f32; 4]>,
25    /// `/GPTS` - geospatial points array defining the mapping between PDF
26    /// coordinates and real-world coordinates (lat/lon pairs).
27    pub gpts: Option<Vec<f32>>,
28    /// `/GCS` - the geographic coordinate system dictionary (Table 262).
29    pub gcs: Option<GeographicCoordinateSystem>,
30    /// `/PDU` - point distance units (e.g., `KM`, `MI`).
31    pub pdu: Option<String>,
32    /// `/DU` - display units for measurements (e.g., `M`, `FT`).
33    pub du: Option<String>,
34    /// `/A` - area units (e.g., `SQKM`, `HA`).
35    pub a: Option<String>,
36}
37
38/// Geographic coordinate system info (Table 262).
39#[derive(Debug, Clone)]
40pub struct GeographicCoordinateSystem {
41    /// `/Type` - should be `GEOGCS`.
42    pub type_: String,
43    /// `/EPSG` - EPSG code (e.g., `4326` for WGS 84).
44    pub epsg: Option<i64>,
45    /// `/WKT` - Well-Known Text coordinate system definition.
46    pub wkt: Option<String>,
47}
48
49/// Maximum array sizes to prevent adversarial input from consuming unbounded
50/// memory (consistent with existing parse limits).
51const MAX_GPTS_VALUES: usize = 1024;
52const MAX_WKT_BYTES: usize = 32 * 1024; // 32 KiB
53
54/// Parse a `/Measure` dictionary from an annotation, returning `None` if the
55/// dictionary is absent, malformed, or exceeds safety limits.
56pub fn parse_measure(file: &PdfFile, annot_dict: &PdfDict) -> Option<Measure> {
57    let measure_dict: Cow<'_, PdfDict> = match annot_dict.get("Measure")? {
58        PdfObject::Dict(d) => Cow::Borrowed(d),
59        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
60            PdfObject::Dict(d) => Cow::Owned(d),
61            _ => return None,
62        },
63        _ => return None,
64    };
65
66    let subtype = measure_dict
67        .get_name("Subtype")
68        .ok()
69        .unwrap_or("Unknown")
70        .to_string();
71
72    // Bounds: [x1 y1 x2 y2] rectangle in default user space.
73    let bounds = measure_dict
74        .get("Bounds")
75        .and_then(|b| resolve_number_array(file, b, 4, 4))
76        .and_then(|v| {
77            if v.len() == 4 {
78                Some([v[0], v[1], v[2], v[3]])
79            } else {
80                None
81            }
82        });
83
84    // GPTS: array of geospatial points (latitude, longitude pairs).
85    let gpts = measure_dict
86        .get("GPTS")
87        .and_then(|g| resolve_number_array(file, g, 4, MAX_GPTS_VALUES));
88
89    // GCS: geographic coordinate system.
90    let gcs = measure_dict.get("GCS").and_then(|g| parse_gcs(file, g));
91
92    // Units: PDU (point distance), DU (display), A (area).
93    let pdu = measure_dict.get_name("PDU").ok().map(|s| s.to_string());
94    let du = measure_dict.get_name("DU").ok().map(|s| s.to_string());
95    let a = measure_dict.get_name("A").ok().map(|s| s.to_string());
96
97    Some(Measure {
98        subtype,
99        bounds,
100        gpts,
101        gcs,
102        pdu,
103        du,
104        a,
105    })
106}
107
108fn parse_gcs(file: &PdfFile, obj: &PdfObject) -> Option<GeographicCoordinateSystem> {
109    let dict: Cow<'_, PdfDict> = match obj {
110        PdfObject::Dict(d) => Cow::Borrowed(d),
111        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
112            PdfObject::Dict(d) => Cow::Owned(d),
113            _ => return None,
114        },
115        _ => return None,
116    };
117
118    let type_ = dict.get_name("Type").ok().unwrap_or("Unknown").to_string();
119
120    // EPSG code (integer).
121    let epsg = dict.get("EPSG").and_then(|e| match e {
122        PdfObject::Integer(n) => Some(*n),
123        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
124            PdfObject::Integer(n) => Some(n),
125            _ => None,
126        },
127        _ => None,
128    });
129
130    // WKT string (can be large, apply limit).
131    let wkt = dict.get("WKT").and_then(|w| {
132        let bytes: Vec<u8> = match w {
133            PdfObject::String(s) => s.as_bytes().to_vec(),
134            PdfObject::Ref(r) => match file.resolve(*r).ok()? {
135                PdfObject::String(s) => s.as_bytes().to_vec(),
136                _ => return None,
137            },
138            _ => return None,
139        };
140        if bytes.len() > MAX_WKT_BYTES {
141            return None;
142        }
143        String::from_utf8(bytes).ok()
144    });
145
146    Some(GeographicCoordinateSystem { type_, epsg, wkt })
147}
148
149/// Resolve a numeric array (direct or indirect), returning `None` if the array
150/// is malformed, contains non-numeric values, or exceeds `max_len`.
151fn resolve_number_array(
152    file: &PdfFile,
153    obj: &PdfObject,
154    min_len: usize,
155    max_len: usize,
156) -> Option<Vec<f32>> {
157    let arr: Cow<'_, [PdfObject]> = match obj {
158        PdfObject::Array(a) => Cow::Borrowed(a.as_slice()),
159        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
160            PdfObject::Array(a) => Cow::Owned(a),
161            _ => return None,
162        },
163        _ => return None,
164    };
165
166    if arr.len() < min_len || arr.len() > max_len {
167        return None;
168    }
169
170    let mut nums = Vec::with_capacity(arr.len());
171    for elem in arr.iter() {
172        let n = match elem {
173            PdfObject::Integer(i) => *i as f32,
174            PdfObject::Real(f) => *f as f32,
175            PdfObject::Ref(r) => match file.resolve(*r).ok()? {
176                PdfObject::Integer(i) => i as f32,
177                PdfObject::Real(f) => f as f32,
178                _ => return None,
179            },
180            _ => return None,
181        };
182        if !n.is_finite() {
183            return None;
184        }
185        nums.push(n);
186    }
187
188    Some(nums)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use zpdf_core::ObjectId;
195    use zpdf_parser::PdfFile;
196
197    fn measure_of(measure_str: &str) -> Option<Measure> {
198        let pdf = format!(
199            "%PDF-1.7\n1 0 obj\n<< /Type /Annot /Subtype /Projection \
200             /Rect [0 0 100 100] /Measure {} >>\nendobj\n\
201             xref\n0 2\n0000000000 65535 f\n0000000009 00000 n\ntrailer\n\
202             << /Size 2 /Root << >> >>\nstartxref\n0\n%%EOF",
203            measure_str
204        );
205        let file = PdfFile::parse(pdf.as_bytes()).ok()?;
206        let obj = file.resolve(ObjectId(1, 0)).ok()?;
207        let annot_dict = obj.as_dict().ok()?;
208        parse_measure(&file, annot_dict)
209    }
210
211    #[test]
212    fn parses_geo_measure_with_epsg() {
213        let m = measure_of(
214            "<< /Subtype /GEO /GPTS [0.0 0.0 100.0 0.0 100.0 100.0 0.0 100.0] \
215             /GCS << /Type /GEOGCS /EPSG 4326 >> /PDU /KM /DU /M >>",
216        )
217        .expect("measure");
218
219        assert_eq!(m.subtype, "GEO");
220        assert_eq!(m.gpts.as_ref().unwrap().len(), 8);
221        assert_eq!(m.pdu.as_deref(), Some("KM"));
222        assert_eq!(m.du.as_deref(), Some("M"));
223
224        let gcs = m.gcs.as_ref().expect("GCS");
225        assert_eq!(gcs.type_, "GEOGCS");
226        assert_eq!(gcs.epsg, Some(4326));
227    }
228
229    #[test]
230    fn parses_bounds() {
231        let m = measure_of(
232            "<< /Subtype /GEO /Bounds [10.0 20.0 90.0 80.0] \
233             /GPTS [0.0 0.0 100.0 100.0] >>",
234        )
235        .expect("measure");
236
237        assert_eq!(m.bounds, Some([10.0, 20.0, 90.0, 80.0]));
238    }
239
240    #[test]
241    fn rejects_oversized_gpts() {
242        // MAX_GPTS_VALUES is 1024; test that we reject arrays beyond that limit.
243        // Use a smaller test case that the PDF parser can handle.
244        let large_gpts = (0..1025)
245            .map(|i| format!("{}.0", i))
246            .collect::<Vec<_>>()
247            .join(" ");
248        let m = measure_of(&format!("<< /Subtype /GEO /GPTS [{}] >>", large_gpts));
249        // If the parser accepts it, check that our measure parser rejects it.
250        match m {
251            None => {} // Good - rejected
252            Some(measure) => {
253                // If it parsed, GPTS should be None due to oversized array rejection.
254                assert!(
255                    measure.gpts.is_none(),
256                    "GPTS should be None when array exceeds MAX_GPTS_VALUES, got: {:?}",
257                    measure.gpts.as_ref().map(|v| v.len())
258                );
259            }
260        }
261    }
262
263    #[test]
264    fn handles_missing_measure() {
265        let pdf = "%PDF-1.7\n1 0 obj\n<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] >>\nendobj\n\
266                   xref\n0 2\n0000000000 65535 f\n0000000009 00000 n\ntrailer\n<< /Size 2 /Root << >> >>\n\
267                   startxref\n0\n%%EOF";
268        let file = PdfFile::parse(pdf.as_bytes()).expect("parse");
269        let obj = file.resolve(ObjectId(1, 0)).ok().unwrap();
270        let annot_dict = obj.as_dict().ok().unwrap();
271        let m = parse_measure(&file, annot_dict);
272        assert!(m.is_none(), "no measure dict should return None");
273    }
274}