Skip to main content

pdfrum_object/
number.rs

1//! Number semantics: the two views a PDF integer has, and the
2//! float-to-decimal spelling the writer uses.
3//!
4//! A file that writes `4294967295` for a permissions word means `-1` when
5//! the value is read as an integer and `4294967296.0` when it is read as a
6//! number, and both readings are observable in real documents. One variant,
7//! [`Object::Int`](crate::Object::Int), holds the *mathematical* value in an
8//! `i64`; [`narrow_to_signed32`] is the integer view and [`widen_to_f32`]
9//! the numeric one. Every integer a lexer can produce lies in
10//! `-2^31 ..= 2^32 - 1` (see [`INT_RANGE`]).
11
12// # Why there are two integer views
13//
14// PDFium stores a parsed integer as *either* `uint32_t` or `int32_t`
15// depending on whether the token carried a sign, and its two accessors
16// disagree about large unsigned values: the integer accessor reinterprets
17// the `uint32_t` bit pattern as signed (so `4294967295` reads back as `-1`)
18// while the numeric accessor widens it (`4294967296.0`). Both are observable
19// in real files — `/P` in an encryption dictionary is written unsigned and
20// read signed. The `INT_RANGE` bound is PDFium's parse rules folding
21// anything wider to 0.
22
23use core::ops::RangeInclusive;
24
25/// The range of integer values a conforming lexer may store in
26/// [`Object::Int`](crate::Object::Int).
27///
28/// An unsigned token accumulates into a `u32` and yields `0` on overflow; a
29/// signed token beyond `i32` range yields `0`. Nothing outside this range is
30/// reachable from parsing, and the accessors are only faithful within it.
31pub const INT_RANGE: RangeInclusive<i64> = -2_147_483_648..=4_294_967_295;
32
33/// The integer view of a stored integer: keep the low 32 bits and read them
34/// as signed.
35///
36/// It neither clamps nor saturates: a value above `i32::MAX` came from an
37/// unsigned token, and its bit pattern is reinterpreted. Within
38/// [`INT_RANGE`] the only inputs that move are those above `i32::MAX`.
39///
40/// ```
41/// use pdfrum_object::narrow_to_signed32;
42///
43/// assert_eq!(narrow_to_signed32(1245), 1245);
44/// assert_eq!(narrow_to_signed32(-99), -99);
45/// assert_eq!(narrow_to_signed32(4_294_967_295), -1);
46/// assert_eq!(narrow_to_signed32(2_147_483_648), -2_147_483_648);
47/// ```
48#[must_use]
49pub fn narrow_to_signed32(v: i64) -> i64 {
50    debug_assert!(
51        INT_RANGE.contains(&v),
52        "Object::Int outside the reachable parse range"
53    );
54    // Narrow to 32 bits, then reinterpret those bits as signed — the two
55    // steps C++ performs implicitly when a `uint32_t` reaches an `int`.
56    #[expect(
57        clippy::cast_possible_truncation,
58        clippy::cast_sign_loss,
59        reason = "reproducing the C++ narrowing exactly is the point"
60    )]
61    let bits = v as u32;
62    i64::from(bits.cast_signed())
63}
64
65/// The numeric view of a stored integer: widen it to `f32`.
66///
67/// No wrapping here — `4294967295` widens to `4294967296.0` because that is
68/// the nearest `f32`.
69///
70/// ```
71/// use pdfrum_object::widen_to_f32;
72///
73/// assert_eq!(widen_to_f32(1245), 1245.0);
74/// assert_eq!(widen_to_f32(4_294_967_295), 4_294_967_296.0);
75/// ```
76#[must_use]
77#[expect(
78    clippy::cast_precision_loss,
79    reason = "matching C++ uint32->float / int32->float, which rounds the same way"
80)]
81pub fn widen_to_f32(v: i64) -> f32 {
82    debug_assert!(
83        INT_RANGE.contains(&v),
84        "Object::Int outside the reachable parse range"
85    );
86    v as f32
87}
88
89/// The integer view of a real: truncate toward zero, saturating at the
90/// signed 32-bit bounds, with NaN mapping to 0.
91///
92/// Unlike [`narrow_to_signed32`] this one really does saturate — a real out
93/// of range clamps rather than wrapping.
94///
95/// ```
96/// use pdfrum_object::truncate_to_signed32;
97///
98/// assert_eq!(truncate_to_signed32(5.2), 5);
99/// assert_eq!(truncate_to_signed32(-5.9), -5);
100/// assert_eq!(truncate_to_signed32(f32::NAN), 0);
101/// assert_eq!(truncate_to_signed32(f32::INFINITY), i64::from(i32::MAX));
102/// ```
103#[must_use]
104pub fn truncate_to_signed32(v: f32) -> i64 {
105    if v.is_nan() {
106        return 0;
107    }
108    // Rust's `as` on floats already saturates at the target's bounds.
109    #[expect(
110        clippy::cast_possible_truncation,
111        reason = "saturating float->int is exactly the C++ behavior being matched"
112    )]
113    i64::from(v as i32)
114}
115
116/// The digits and base-10 exponent of the shortest decimal that round-trips
117/// to `v`, with trailing zeros removed: `v == digits * 10^exponent`.
118///
119/// `v` must be finite, non-zero and positive. Derived from `ryu`'s shortest
120/// representation, which produces the same digit string as the oracle's
121/// dragonbox.
122fn shortest_decimal(v: f32) -> (Vec<u8>, i32) {
123    let mut buf = ryu::Buffer::new();
124    let s = buf.format_finite(v);
125    let s = s.strip_prefix('-').unwrap_or(s);
126
127    let (mantissa, exp10) = match s.split_once(['e', 'E']) {
128        Some((m, e)) => (m, e.parse::<i32>().unwrap_or(0)),
129        None => (s, 0),
130    };
131    let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
132
133    let mut digits: Vec<u8> = int_part
134        .bytes()
135        .chain(frac_part.bytes())
136        .skip_while(|b| *b == b'0')
137        .collect();
138    let frac_len = i32::try_from(frac_part.len()).unwrap_or(i32::MAX);
139    let mut exponent = exp10.saturating_sub(frac_len);
140
141    while digits.len() > 1 && digits.last() == Some(&b'0') {
142        digits.pop();
143        exponent = exponent.saturating_add(1);
144    }
145    (digits, exponent)
146}
147
148// The oracle sizes its buffer at 49 including the terminating NUL, so 48
149// characters of payload.
150/// Longest output the float writer will produce, in bytes.
151///
152/// The cap only ever binds for `-f32::MIN` denormals.
153const MAX_FLOAT_DECIMAL_LEN: usize = 48;
154
155/// Spell a real the way the content-stream and object writers do.
156///
157/// Never scientific notation, never a leading zero before the point, NaN and
158/// both zeros render as `"0"`, and the infinities render as the finite
159/// extremes (PDF has no syntax for either).
160///
161/// ```
162/// use pdfrum_object::fmt_number;
163///
164/// assert_eq!(fmt_number(0.0), "0");
165/// assert_eq!(fmt_number(-0.0), "0");
166/// assert_eq!(fmt_number(-7.5), "-7.5");
167/// assert_eq!(fmt_number(0.5), ".5");
168/// assert_eq!(fmt_number(f32::NAN), "0");
169/// assert_eq!(fmt_number(f32::MAX), fmt_number(f32::INFINITY));
170/// ```
171#[must_use]
172pub fn fmt_number(value: f32) -> String {
173    if value.is_nan() || value == 0.0 {
174        return "0".to_owned();
175    }
176
177    let mut magnitude = if value == f32::INFINITY {
178        f32::MAX
179    } else if value == f32::NEG_INFINITY {
180        f32::MIN
181    } else {
182        value
183    };
184
185    let mut out = String::new();
186    if magnitude < 0.0 {
187        out.push('-');
188        magnitude = -magnitude;
189    }
190
191    let (digits, exponent) = shortest_decimal(magnitude);
192    let digit_count = i32::try_from(digits.len()).unwrap_or(i32::MAX);
193
194    if exponent >= 0 {
195        out.push_str(&String::from_utf8_lossy(&digits));
196        for _ in 0..exponent {
197            out.push('0');
198        }
199        return out;
200    }
201
202    let places_before_point = digit_count + exponent;
203    if places_before_point > 0 {
204        let split = usize::try_from(places_before_point).unwrap_or(0);
205        let (whole, fraction) = digits.split_at(split.min(digits.len()));
206        out.push_str(&String::from_utf8_lossy(whole));
207        out.push('.');
208        out.push_str(&String::from_utf8_lossy(fraction));
209        return out;
210    }
211
212    // Value below 1: no leading zero, then the zeros the exponent asks for,
213    // then the digits — truncated at the writer's buffer size.
214    out.push('.');
215    for _ in 0..-places_before_point {
216        out.push('0');
217    }
218    for d in digits {
219        out.push(char::from(d));
220        if out.len() >= MAX_FLOAT_DECIMAL_LEN {
221            break;
222        }
223    }
224    out
225}
226
227/// Spell an integer the way the writers do: through the signed 32-bit view,
228/// so a stored `4294967295` writes as `-1`.
229///
230/// ```
231/// use pdfrum_object::fmt_int;
232///
233/// assert_eq!(fmt_int(1234), "1234");
234/// assert_eq!(fmt_int(-54321), "-54321");
235/// assert_eq!(fmt_int(4_294_967_295), "-1");
236/// ```
237#[must_use]
238pub fn fmt_int(value: i64) -> String {
239    narrow_to_signed32(value).to_string()
240}
241
242#[cfg(test)]
243#[expect(
244    clippy::float_cmp,
245    reason = "these assertions pin exact bit patterns the oracle produces"
246)]
247mod tests {
248    use super::{fmt_int, fmt_number, narrow_to_signed32, truncate_to_signed32, widen_to_f32};
249
250    // Restated from FX_Number's tri-state semantics (fx_number.cpp:87-115):
251    // the same stored value reads differently through the two accessors.
252    #[test]
253    fn integer_view_wraps_the_unsigned_range() {
254        assert_eq!(narrow_to_signed32(0), 0);
255        assert_eq!(narrow_to_signed32(1245), 1245);
256        assert_eq!(narrow_to_signed32(-2_147_483_648), -2_147_483_648);
257        assert_eq!(narrow_to_signed32(2_147_483_647), 2_147_483_647);
258        // Beyond i32::MAX the token was unsigned, so the bits reinterpret.
259        assert_eq!(narrow_to_signed32(2_147_483_648), -2_147_483_648);
260        assert_eq!(narrow_to_signed32(4_294_967_295), -1);
261        assert_eq!(narrow_to_signed32(4_294_967_294), -2);
262    }
263
264    #[test]
265    fn numeric_view_widens_instead_of_wrapping() {
266        assert_eq!(widen_to_f32(0), 0.0);
267        assert_eq!(widen_to_f32(1245), 1245.0);
268        assert_eq!(widen_to_f32(-2_147_483_648), -2_147_483_648.0);
269        assert_eq!(widen_to_f32(4_294_967_295), 4_294_967_296.0);
270    }
271
272    #[test]
273    fn real_to_integer_saturates_and_zeroes_nan() {
274        assert_eq!(truncate_to_signed32(5.2), 5);
275        assert_eq!(truncate_to_signed32(9.003_45), 9);
276        assert_eq!(truncate_to_signed32(-0.5), 0);
277        assert_eq!(truncate_to_signed32(f32::NAN), 0);
278        assert_eq!(truncate_to_signed32(f32::INFINITY), i64::from(i32::MAX));
279        assert_eq!(truncate_to_signed32(f32::NEG_INFINITY), i64::from(i32::MIN));
280        assert_eq!(truncate_to_signed32(1e30), i64::from(i32::MAX));
281    }
282
283    // Goldens from cpdf_contentstream_write_utils_unittest.cpp:26-50 and
284    // cpdf_number_unittest.cpp:37-131 — the dragonbox-parity anchors.
285    #[test]
286    fn float_spelling_matches_the_oracle() {
287        let cases: &[(f32, &str)] = &[
288            (0.0, "0"),
289            (-0.0, "0"),
290            (1.0, "1"),
291            (-1.0, "-1"),
292            (0.5, ".5"),
293            (-0.5, "-.5"),
294            (0.001_25, ".00125"),
295            (123.45, "123.45"),
296            (-7.5, "-7.5"),
297            (38.895_285, "38.895287"),
298            (-77.037_23, "-77.03723"),
299            (9.003_45, "9.00345"),
300            (0.23, ".23"),
301            (f32::MAX, "340282350000000000000000000000000000000"),
302            (-f32::MAX, "-340282350000000000000000000000000000000"),
303            (
304                f32::MIN_POSITIVE,
305                ".000000000000000000000000000000000000011754944",
306            ),
307            (
308                -f32::MIN_POSITIVE,
309                "-.000000000000000000000000000000000000011754944",
310            ),
311            (f32::INFINITY, "340282350000000000000000000000000000000"),
312            (
313                f32::NEG_INFINITY,
314                "-340282350000000000000000000000000000000",
315            ),
316            (f32::NAN, "0"),
317        ];
318        for (value, want) in cases {
319            assert_eq!(&fmt_number(*value), want, "spelling {value}");
320        }
321    }
322
323    #[test]
324    fn smallest_denormal_hits_the_writer_length_cap() {
325        // The one input where C++'s 49-byte buffer truncates the digits.
326        let smallest = f32::from_bits(1);
327        assert_eq!(fmt_number(-smallest).len(), 47);
328        assert!(fmt_number(smallest).len() <= 48);
329    }
330
331    // From cpdf_number_unittest.cpp:105-131.
332    #[test]
333    fn integer_spelling_matches_the_oracle() {
334        assert_eq!(fmt_int(0), "0");
335        assert_eq!(fmt_int(1), "1");
336        assert_eq!(fmt_int(-99), "-99");
337        assert_eq!(fmt_int(1234), "1234");
338        assert_eq!(fmt_int(-54321), "-54321");
339        assert_eq!(fmt_int(2_147_483_647), "2147483647");
340        assert_eq!(fmt_int(-2_147_483_648), "-2147483648");
341        assert_eq!(fmt_int(4_294_967_295), "-1");
342    }
343}