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}