1use std::borrow::Cow;
13use zpdf_core::{PdfDict, PdfObject};
14use zpdf_parser::PdfFile;
15
16#[derive(Debug, Clone)]
19pub struct Measure {
20 pub subtype: String,
22 pub bounds: Option<[f32; 4]>,
25 pub gpts: Option<Vec<f32>>,
28 pub gcs: Option<GeographicCoordinateSystem>,
30 pub pdu: Option<String>,
32 pub du: Option<String>,
34 pub a: Option<String>,
36}
37
38#[derive(Debug, Clone)]
40pub struct GeographicCoordinateSystem {
41 pub type_: String,
43 pub epsg: Option<i64>,
45 pub wkt: Option<String>,
47}
48
49const MAX_GPTS_VALUES: usize = 1024;
52const MAX_WKT_BYTES: usize = 32 * 1024; pub 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 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 let gpts = measure_dict
86 .get("GPTS")
87 .and_then(|g| resolve_number_array(file, g, 4, MAX_GPTS_VALUES));
88
89 let gcs = measure_dict.get("GCS").and_then(|g| parse_gcs(file, g));
91
92 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 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 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
149fn 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 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 match m {
251 None => {} Some(measure) => {
253 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}