Skip to main content

pdf_interpret/
util.rs

1//! A number of utility methods.
2
3use crate::{InterpreterWarning, WarningSinkFn};
4use kurbo::{Affine, BezPath, PathEl, Rect};
5use log::warn;
6use pdf_syntax::object::Stream;
7use pdf_syntax::object::stream::DecodeFailure;
8use pdf_syntax::page::{Page, Rotation};
9use siphasher::sip128::{Hasher128, SipHasher13};
10use std::hash::Hash;
11use std::ops::Sub;
12
13pub(crate) fn decode_or_warn(stream: &Stream<'_>, sink: &WarningSinkFn) -> Option<Vec<u8>> {
14    match stream.decoded() {
15        Ok(data) => Some(data),
16        Err(DecodeFailure::StreamTooLarge { observed, limit }) => {
17            sink(InterpreterWarning::StreamTooLarge { observed, limit });
18            None
19        }
20        Err(_) => None,
21    }
22}
23
24pub(crate) trait OptionLog {
25    fn warn_none(self, f: &str) -> Self;
26}
27
28impl<T> OptionLog for Option<T> {
29    #[inline]
30    fn warn_none(self, f: &str) -> Self {
31        self.or_else(|| {
32            warn!("{f}");
33
34            None
35        })
36    }
37}
38
39const SCALAR_NEARLY_ZERO: f32 = 1.0 / (1 << 8) as f32;
40
41/// A number of useful methods for f32 numbers.
42pub trait Float32Ext: Sized + Sub<f32, Output = f32> + Copy + PartialOrd<f32> {
43    /// Whether the number is approximately 0.
44    fn is_nearly_zero(&self) -> bool {
45        self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO)
46    }
47
48    /// Whether the number is nearly equal to another number.
49    fn is_nearly_equal(&self, other: f32) -> bool {
50        (*self - other).is_nearly_zero()
51    }
52
53    /// Whether the number is nearly equal to another number.
54    fn is_nearly_less_or_equal(&self, other: f32) -> bool {
55        (*self - other).is_nearly_zero() || *self < other
56    }
57
58    /// Whether the number is nearly equal to another number.
59    fn is_nearly_greater_or_equal(&self, other: f32) -> bool {
60        (*self - other).is_nearly_zero() || *self > other
61    }
62
63    /// Whether the number is approximately 0, with a given tolerance.
64    fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool;
65}
66
67impl Float32Ext for f32 {
68    fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool {
69        debug_assert!(tolerance >= 0.0, "tolerance must be non-negative");
70
71        self.abs() <= tolerance
72    }
73}
74
75/// A number of useful methods for f64 numbers.
76pub trait Float64Ext: Sized + Sub<f64, Output = f64> + Copy + PartialOrd<f64> {
77    /// Whether the number is approximately 0.
78    fn is_nearly_zero(&self) -> bool {
79        self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO as f64)
80    }
81
82    /// Whether the number is nearly equal to another number.
83    fn is_nearly_equal(&self, other: f64) -> bool {
84        (*self - other).is_nearly_zero()
85    }
86
87    /// Whether the number is nearly equal to another number.
88    fn is_nearly_less_or_equal(&self, other: f64) -> bool {
89        (*self - other).is_nearly_zero() || *self < other
90    }
91
92    /// Whether the number is nearly equal to another number.
93    fn is_nearly_greater_or_equal(&self, other: f64) -> bool {
94        (*self - other).is_nearly_zero() || *self > other
95    }
96
97    /// Whether the number is approximately 0, with a given tolerance.
98    fn is_nearly_zero_within_tolerance(&self, tolerance: f64) -> bool;
99}
100
101impl Float64Ext for f64 {
102    fn is_nearly_zero_within_tolerance(&self, tolerance: f64) -> bool {
103        debug_assert!(tolerance >= 0.0, "tolerance must be non-negative");
104
105        self.abs() <= tolerance
106    }
107}
108
109pub(crate) trait PointExt: Sized {
110    fn x(&self) -> f32;
111    fn y(&self) -> f32;
112
113    fn nearly_same(&self, other: Self) -> bool {
114        self.x().is_nearly_equal(other.x()) && self.y().is_nearly_equal(other.y())
115    }
116}
117
118impl PointExt for kurbo::Point {
119    fn x(&self) -> f32 {
120        self.x as f32
121    }
122
123    fn y(&self) -> f32 {
124        self.y as f32
125    }
126}
127
128/// Calculate a 128-bit siphash of a value.
129pub(crate) fn hash128<T: Hash + ?Sized>(value: &T) -> u128 {
130    let mut state = SipHasher13::new();
131    value.hash(&mut state);
132    state.finish128().as_u128()
133}
134
135pub(crate) trait BezPathExt {
136    fn fast_bounding_box(&self) -> Rect;
137}
138
139impl BezPathExt for BezPath {
140    fn fast_bounding_box(&self) -> Rect {
141        let mut min_x = f64::INFINITY;
142        let mut min_y = f64::INFINITY;
143        let mut max_x = f64::NEG_INFINITY;
144        let mut max_y = f64::NEG_INFINITY;
145
146        let mut include = |x: f64, y: f64| {
147            min_x = min_x.min(x);
148            min_y = min_y.min(y);
149            max_x = max_x.max(x);
150            max_y = max_y.max(y);
151        };
152
153        for el in self.elements() {
154            match *el {
155                PathEl::MoveTo(p) | PathEl::LineTo(p) => include(p.x, p.y),
156                PathEl::QuadTo(p1, p2) => {
157                    include(p1.x, p1.y);
158                    include(p2.x, p2.y);
159                }
160                PathEl::CurveTo(p1, p2, p3) => {
161                    include(p1.x, p1.y);
162                    include(p2.x, p2.y);
163                    include(p3.x, p3.y);
164                }
165                PathEl::ClosePath => {}
166            }
167        }
168
169        if min_x > max_x {
170            Rect::ZERO
171        } else {
172            Rect::new(min_x, min_y, max_x, max_y)
173        }
174    }
175}
176
177/// Extension methods for rectangles.
178pub trait RectExt {
179    /// Convert the rectangle to a `kurbo` rectangle.
180    fn to_kurbo(&self) -> Rect;
181}
182
183impl RectExt for pdf_syntax::object::Rect {
184    fn to_kurbo(&self) -> Rect {
185        Rect::new(self.x0, self.y0, self.x1, self.y1)
186    }
187}
188
189// Note: Keep in sync with `pdf-interpret-write`.
190/// Extension methods for PDF pages.
191pub trait PageExt {
192    /// Return the initial transform that should be applied when rendering. This accounts for a
193    /// number of factors, such as the mismatch between PDF's y-up and most renderers' y-down
194    /// coordinate system, the rotation of the page and the offset of the crop box.
195    fn initial_transform(&self, invert_y: bool) -> Affine;
196}
197
198impl PageExt for Page<'_> {
199    fn initial_transform(&self, invert_y: bool) -> Affine {
200        // Use the raw CropBox origin for the coordinate-system translation.
201        // MuPDF maps (CropBox.x0, CropBox.y0) → canvas (0, 0).  For normal
202        // PDFs (CropBox ⊆ MediaBox) intersected_crop_box and crop_box share
203        // the same origin, so there is no change.  For unusual documents where
204        // CropBox extends beyond MediaBox (e.g. gen-802: CropBox=[0,0,684,864]
205        // but MediaBox=[36,36,648,828]) the intersected origin was (36,36),
206        // producing a 75-pixel content offset vs MuPDF. (#544 follow-up)
207        let crop_box = self.crop_box();
208        let (_, base_height) = self.base_dimensions();
209        let (width, height) = self.render_dimensions();
210
211        let horizontal_t =
212            Affine::rotate(90.0_f64.to_radians()) * Affine::translate((0.0, -width as f64));
213        let flipped_horizontal_t =
214            Affine::translate((0.0, height as f64)) * Affine::rotate(-90.0_f64.to_radians());
215
216        let rotation_transform = match self.rotation() {
217            Rotation::None => Affine::IDENTITY,
218            Rotation::Horizontal => {
219                if invert_y {
220                    horizontal_t
221                } else {
222                    flipped_horizontal_t
223                }
224            }
225            Rotation::Flipped => {
226                Affine::scale(-1.0) * Affine::translate((-width as f64, -height as f64))
227            }
228            Rotation::FlippedHorizontal => {
229                if invert_y {
230                    flipped_horizontal_t
231                } else {
232                    horizontal_t
233                }
234            }
235        };
236
237        let inversion_transform = if invert_y {
238            Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, base_height as f64])
239        } else {
240            Affine::IDENTITY
241        };
242
243        rotation_transform * inversion_transform * Affine::translate((-crop_box.x0, -crop_box.y0))
244    }
245}
246
247/// The slots where PDFDocEncoding (PDF 32000-1 Annex D.2) does not agree with
248/// Latin-1: `0x18`-`0x1F`, `0x7F`, and `0x80`-`0xA0`. Every other byte is its
249/// own code point, which is why only this much of the table is carried.
250///
251/// `None` is an undefined slot. The specification says a conforming writer
252/// produces none, so a string carrying one is malformed, and the alternative
253/// -- emitting the C1 control the byte names in Latin-1 -- is exactly the
254/// class of code point this change exists to keep out of the text.
255const PDF_DOC_ENCODING_DIVERGENCES: [(u8, Option<char>); 42] = [
256    (0x18, Some('\u{02D8}')), // breve
257    (0x19, Some('\u{02C7}')), // caron
258    (0x1A, Some('\u{02C6}')), // circumflex
259    (0x1B, Some('\u{02D9}')), // dotaccent
260    (0x1C, Some('\u{02DD}')), // hungarumlaut
261    (0x1D, Some('\u{02DB}')), // ogonek
262    (0x1E, Some('\u{02DA}')), // ring
263    (0x1F, Some('\u{02DC}')), // tilde
264    (0x7F, None),
265    (0x80, Some('\u{2022}')), // bullet
266    (0x81, Some('\u{2020}')), // dagger
267    (0x82, Some('\u{2021}')), // daggerdbl
268    (0x83, Some('\u{2026}')), // ellipsis
269    (0x84, Some('\u{2014}')), // emdash
270    (0x85, Some('\u{2013}')), // endash
271    (0x86, Some('\u{0192}')), // florin
272    (0x87, Some('\u{2044}')), // fraction
273    (0x88, Some('\u{2039}')), // guilsinglleft
274    (0x89, Some('\u{203A}')), // guilsinglright
275    (0x8A, Some('\u{2212}')), // minus
276    (0x8B, Some('\u{2030}')), // perthousand
277    (0x8C, Some('\u{201E}')), // quotedblbase
278    (0x8D, Some('\u{201C}')), // quotedblleft
279    (0x8E, Some('\u{201D}')), // quotedblright
280    (0x8F, Some('\u{2018}')), // quoteleft
281    (0x90, Some('\u{2019}')), // quoteright
282    (0x91, Some('\u{201A}')), // quotesinglbase
283    (0x92, Some('\u{2122}')), // trademark
284    (0x93, Some('\u{FB01}')), // fi
285    (0x94, Some('\u{FB02}')), // fl
286    (0x95, Some('\u{0141}')), // Lslash
287    (0x96, Some('\u{0152}')), // OE
288    (0x97, Some('\u{0160}')), // Scaron
289    (0x98, Some('\u{0178}')), // Ydieresis
290    (0x99, Some('\u{017D}')), // Zcaron
291    (0x9A, Some('\u{0131}')), // dotlessi
292    (0x9B, Some('\u{0142}')), // lslash
293    (0x9C, Some('\u{0153}')), // oe
294    (0x9D, Some('\u{0161}')), // scaron
295    (0x9E, Some('\u{017E}')), // zcaron
296    (0x9F, None),
297    (0xA0, Some('\u{20AC}')), // Euro
298];
299
300/// The character a PDFDocEncoding byte stands for, or `None` for the three
301/// undefined slots (`0x7F`, `0x9F`, `0xAD`).
302fn pdf_doc_encoding(byte: u8) -> Option<char> {
303    if byte == 0xAD {
304        return None;
305    }
306    match PDF_DOC_ENCODING_DIVERGENCES
307        .iter()
308        .find(|(code, _)| *code == byte)
309    {
310        Some((_, mapped)) => *mapped,
311        None => Some(byte as char),
312    }
313}
314
315/// Decode a PDF text string (PDF 32000-1 §7.9.2.2, PDF 2.0 §7.9.2.2) into
316/// Rust's `String`.
317///
318/// Three encodings, told apart by a byte-order mark: UTF-16BE behind `FE FF`,
319/// UTF-8 behind `EF BB BF` (PDF 2.0), and PDFDocEncoding otherwise.
320/// PDFDocEncoding is Latin-1 over most of its range and diverges in
321/// `0x18`-`0x1F`, `0x7F` and `0x80`-`0xA0`, where reading it as Latin-1 turns
322/// per-mille into the C1 control U+008B -- one of the code points this change
323/// exists to keep out of extracted text. Those slots are in
324/// [`PDF_DOC_ENCODING_DIVERGENCES`]; an undefined one drops its byte rather
325/// than inventing a character for it.
326///
327/// An empty result is a result. `/ActualText ()` says this sequence stands for
328/// no characters at all, which is how a producer suppresses decorative text,
329/// and answering `None` there would hand the glyphs back instead. `None` means
330/// only that the bytes did not decode.
331pub(crate) fn decode_text_string(bytes: &[u8]) -> Option<String> {
332    if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
333        let units: Vec<u16> = rest
334            .chunks_exact(2)
335            .map(|pair| u16::from_be_bytes([pair[0], pair[1]]))
336            .collect();
337        return String::from_utf16(&units).ok();
338    }
339
340    if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
341        return std::str::from_utf8(rest).ok().map(str::to_string);
342    }
343
344    Some(bytes.iter().copied().filter_map(pdf_doc_encoding).collect())
345}
346
347#[cfg(test)]
348mod decode_text_string_tests {
349    use super::decode_text_string;
350
351    #[test]
352    fn utf16be_behind_the_mark() {
353        assert_eq!(
354            decode_text_string(&[0xFE, 0xFF, 0x00, 0x41, 0x00, 0x42, 0x00, 0x43]),
355            Some("ABC".to_string())
356        );
357    }
358
359    #[test]
360    fn pdf_doc_encoding_without_one() {
361        assert_eq!(decode_text_string(b"Caf\xe9"), Some("Café".to_string()));
362    }
363
364    /// The slots where PDFDocEncoding is not Latin-1. Read as Latin-1 these
365    /// are the C1 controls and a no-break space, which is the class of code
366    /// point this change exists to keep out of the text.
367    #[test]
368    fn the_slots_where_it_is_not_latin1() {
369        assert_eq!(decode_text_string(b"\x8b"), Some("\u{2030}".to_string()));
370        assert_eq!(
371            decode_text_string(b"\x93\x94"),
372            Some("\u{FB01}\u{FB02}".to_string())
373        );
374        assert_eq!(decode_text_string(b"\xa0"), Some("\u{20AC}".to_string()));
375        assert_eq!(decode_text_string(b"\x1a"), Some("\u{02C6}".to_string()));
376    }
377
378    /// The three undefined slots. A conforming writer produces none of them,
379    /// and the byte is dropped rather than read as the control it would be in
380    /// Latin-1.
381    #[test]
382    fn an_undefined_slot_drops_its_byte() {
383        assert_eq!(
384            decode_text_string(b"A\x7fB\x9fC\xadD"),
385            Some("ABCD".to_string())
386        );
387        assert_eq!(decode_text_string(b"\x7f"), Some(String::new()));
388    }
389
390    /// PDF 2.0 allows a UTF-8 text string behind its own mark.
391    #[test]
392    fn utf8_behind_its_mark() {
393        assert_eq!(
394            decode_text_string(&[0xEF, 0xBB, 0xBF, 0xC3, 0xA9, 0x41]),
395            Some("éA".to_string())
396        );
397        assert_eq!(decode_text_string(&[0xEF, 0xBB, 0xBF, 0xFF]), None);
398    }
399
400    /// An empty string decoded successfully, and that is not the same answer
401    /// as "these bytes are not a string". `/ActualText ()` says the sequence
402    /// stands for nothing, which is a producer suppressing decorative text.
403    #[test]
404    fn an_empty_replacement_is_still_a_replacement() {
405        assert_eq!(decode_text_string(b""), Some(String::new()));
406        assert_eq!(decode_text_string(&[0xFE, 0xFF]), Some(String::new()));
407        assert_eq!(decode_text_string(&[0xEF, 0xBB, 0xBF]), Some(String::new()));
408    }
409
410    #[test]
411    fn an_unpaired_surrogate_is_refused_rather_than_mangled() {
412        assert_eq!(decode_text_string(&[0xFE, 0xFF, 0xD8, 0x00]), None);
413    }
414}