From 4a7d556a320f54e57158febbf3bbf49d714e0749 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:46:05 +0800 Subject: [PATCH] fix(cos): write whole reals past the 32-bit integer range as reals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_real printed a whole real below 1e15 as an integer, and a larger one through Display, which has no decimal point either. So 4e9 or 1e30 was written as an integer token outside the range ISO 32000-2 Annex C gives integers (±2^31-1), and read back as Int (or, in readers with 32-bit integers, overflowed). Whole reals now use the integer form only within that range; past it they keep a trailing '.' (§7.3.3 allows "4."). Raised in the review of #2877. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/cos/src/writer.rs | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index f8d8fd3d8..c95f12200 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -49,8 +49,10 @@ fn write_real(r: f64, out: &mut Vec) { out.push(b'0'); return; } - if r.fract() == 0.0 && r.abs() < 1e15 { - let _ = write!(out, "{}", r as i64); + // A whole number is written as an integer only within the integer range Annex C gives + // (±2³¹−1), so `4e9` or `1e30` stays a real token for readers with 32-bit integers. + if r.fract() == 0.0 && r.abs() <= f64::from(i32::MAX) { + let _ = write!(out, "{}", r as i32); return; } // Plain decimal notation (§7.3.3 has no exponents). Rust's `Display` for f64 prints the @@ -58,6 +60,10 @@ fn write_real(r: f64, out: &mut Vec) { // round-trip exactly (rounding to a fixed number of places visibly shifted shading colours). let s = format!("{r}"); out.extend_from_slice(if s == "-0" { b"0" } else { s.as_bytes() }); + // A whole number past that range prints without a point; the trailing `.` keeps it a real. + if !s.contains('.') && s != "-0" { + out.push(b'.'); + } } fn write_name(n: &[u8], out: &mut Vec) { @@ -662,14 +668,38 @@ mod tests { } } + #[test] + fn whole_reals_past_the_integer_limit_stay_reals() { + for (r, text) in [ + (2_147_483_647.0, "2147483647"), + (-2_147_483_647.0, "-2147483647"), + (2_147_483_648.0, "2147483648."), + (4e9, "4000000000."), + (-4e9, "-4000000000."), + (1e30, "1000000000000000000000000000000."), + (595.0, "595"), + (-0.0, "0"), + ] { + let mut out = Vec::new(); + serialize(&Object::Real(r), &mut out); + assert_eq!(String::from_utf8_lossy(&out), text, "{r}"); + let back = roundtrip(&Object::Real(r)); + if r.abs() > f64::from(i32::MAX) { + assert_eq!(back, Object::Real(r), "{r} must read back as a real"); + } + } + } + proptest! { #[test] fn any_finite_real_round_trips(r in proptest::num::f64::NORMAL | proptest::num::f64::SUBNORMAL | proptest::num::f64::ZERO) { - prop_assume!(r.abs() < 1e15 || r.fract() == 0.0); let back = roundtrip(&Object::Real(r)); match back { Object::Real(b) => prop_assert_eq!(b, r), - Object::Int(i) => prop_assert_eq!(i as f64, r), + Object::Int(i) => { + prop_assert!(i.unsigned_abs() <= i32::MAX as u64, "{r} came back as integer {i}"); + prop_assert_eq!(i as f64, r); + } other => prop_assert!(false, "unexpected {other:?}"), } }