diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..ac5d315 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df1..828f8fb 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -145,7 +145,8 @@ impl Font { /// Width of `text` in points when set in `font` at `size` points. pub fn text_width(font: Font, size: f32, text: &str) -> f32 { - let units: u32 = text.chars().map(|c| font.char_width(c) as u32).sum(); + // u64: a u32 sum overflows for ~8 million characters. + let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum(); units as f32 * size / 1000.0 } @@ -221,6 +222,32 @@ fn n(v: f32) -> String { } } +/// True when every value is finite (NaN and infinities are rejected). +fn finite(v: &[f32]) -> bool { + v.iter().all(|x| x.is_finite()) +} + +/// A positive, finite extent (a width, height or radius). +fn extent(v: f32) -> bool { + v > 0.0 && v.is_finite() +} + +/// A stroke width: negative, NaN and infinite become 0 (the thinnest line). +fn stroke_width(w: f32) -> f32 { + if w.is_finite() { w.max(0.0) } else { 0.0 } +} + +/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification +/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is +/// replaced by an empty array, i.e. a solid line. +fn dash_values(dash: &[f32]) -> String { + let d: Vec = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect(); + if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) { + return String::new(); + } + d.iter().map(|v| n(*v)).collect::>().join(" ") +} + fn rgb_op(c: Rgb, stroke: bool) -> String { let f = |v: u8| n(v as f32 / 255.0); format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" }) @@ -288,7 +315,7 @@ impl Page { } pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) { - if w <= 0.0 || h <= 0.0 { + if !(extent(w) && extent(h) && finite(&[x, y])) { return; } let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h)); @@ -296,7 +323,10 @@ impl Page { } pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) { - let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h)); + if !finite(&[x, y, w, h]) { + return; + } + let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h)); self.op(&s); } @@ -306,19 +336,28 @@ impl Page { /// A line with a dash pattern (`[on, off, …]` in points; empty = solid). pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) { - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let s = - format!("{}\n{} w [{}] 0 d\n{} {} m {} {} l S", rgb_op(color, true), n(width), d.join(" "), n(x0), n(self.y(y0)), n(x1), n(self.y(y1))); + if !finite(&[x0, y0, x1, y1]) { + return; + } + let s = format!( + "{}\n{} w [{}] 0 d\n{} {} m {} {} l S", + rgb_op(color, true), + n(stroke_width(width)), + dash_values(dash), + n(x0), + n(self.y(y0)), + n(x1), + n(self.y(y1)) + ); self.op(&s); } /// An open polyline. pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) { - if pts.len() < 2 { + if pts.len() < 2 || !pts.iter().all(|p| finite(p)) { return; } - let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); - let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(width), d.join(" ")); + let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash)); self.path(&mut s, pts, false); s.push_str(" S"); self.op(&s); @@ -335,7 +374,7 @@ impl Page { /// A closed polygon, filled and/or stroked. pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option, stroke: Option<(Rgb, f32)>) { - if pts.len() < 2 || (fill.is_none() && stroke.is_none()) { + if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) { return; } let mut s = String::new(); @@ -344,7 +383,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w))); } self.path(&mut s, pts, true); s.push_str(match (fill.is_some(), stroke.is_some()) { @@ -357,7 +396,7 @@ impl Page { /// A circle approximated by four cubic Béziers. pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option, stroke: Option<(Rgb, f32)>) { - if r <= 0.0 || (fill.is_none() && stroke.is_none()) { + if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) { return; } const K: f32 = 0.552_284_8; @@ -369,7 +408,7 @@ impl Page { s.push('\n'); } if let Some((c, w)) = stroke { - let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(w)); + let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w))); } let _ = write!( s, @@ -417,7 +456,7 @@ impl Page { /// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate /// system) about its baseline origin. pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) { - if text.is_empty() || !(size.is_finite() && size > 0.0) { + if text.is_empty() || !extent(size) || !finite(&[x, y]) { return; } let fi = font.index(); @@ -446,7 +485,7 @@ impl Page { /// `(x, y, w, h)`. Malformed or oversized buffers are ignored. pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) { let px = pw as u64 * ph as u64; - if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || w <= 0.0 || h <= 0.0 { + if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) { return; } let px = px as usize; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e7b488a..176176b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -157,3 +157,83 @@ fn hostile_inputs_never_panic() { let pdf = d.finish(); check_xref(&pdf); } + +fn page_content(d: &PdfDoc) -> &str { + &d.pages[0].content +} + +#[test] +fn text_width_does_not_overflow() { + // 8,000,000 x 556 units exceeds u32::MAX: a u32 sum panics in debug builds. + let long = "a".repeat(8_000_000); + let w = text_width(Font::Helvetica, 10.0, &long); + assert!(w.is_finite() && w > 0.0); + assert!(w > text_width(Font::Helvetica, 10.0, &"a".repeat(7_000_000))); +} + +#[test] +fn all_zero_dash_is_drawn_solid() { + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[0.0, 0.0]); + p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]); + p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[3.0, 2.0]); + let c = page_content(&d); + assert_eq!(c.matches("[] 0 d").count(), 2); + assert!(!c.contains("[0 0]")); + assert!(c.contains("[3 2] 0 d")); +} + +#[test] +fn negative_and_nan_stroke_widths_become_zero() { + let mut d = PdfDoc::new(); + let p = d.add_page(100.0, 100.0); + p.rect_stroke(1.0, 1.0, 10.0, 10.0, -3.0, [0, 0, 0]); + p.stroke_line(0.0, 0.0, 5.0, 5.0, f32::NAN, [0, 0, 0]); + p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0))); + p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY))); + let c = page_content(&d); + assert_eq!(c.matches("\n0 w ").count(), 4); + assert!(!c.contains("-1 w") && !c.contains("-3 w")); +} + +#[test] +fn non_finite_image_size_stores_nothing() { + let mut d = PdfDoc::new(); + let p = d.add_page(200.0, 200.0); + let px = [255u8; 16]; + for (w, h) in [(f32::NAN, 10.0), (10.0, f32::NAN), (f32::INFINITY, 10.0), (-1.0, 10.0)] { + p.image_rgba(0.0, 0.0, w, h, &px, 2, 2); + } + p.image_rgba(f32::NAN, 0.0, 10.0, 10.0, &px, 2, 2); + assert!(p.images.is_empty()); + assert!(p.content.is_empty()); +} + +#[test] +fn every_draw_method_survives_hostile_numbers() { + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -7.0, 0.0, 1.0e30, -1.0e30]; + let px = [200u8; 16]; + let mut d = PdfDoc::new(); + let p = d.add_page(300.0, 300.0); + for &a in &bad { + for &b in &bad { + p.clip_rect(a, b, a, b); + p.fill_rect(a, b, b, a, [1, 2, 3]); + p.rect_stroke(a, b, a, b, a, [1, 2, 3]); + p.stroke_line(a, b, b, a, a, [1, 2, 3]); + p.stroke_line_dashed(a, b, b, a, b, [1, 2, 3], &[a, b, 0.0]); + p.polyline(&[[a, b], [b, a], [1.0, 1.0]], a, [1, 2, 3], &[b, a]); + p.polygon(&[[a, b], [b, a], [1.0, 1.0]], Some([1, 2, 3]), Some(([4, 5, 6], a))); + p.circle(a, b, b, Some([1, 2, 3]), Some(([4, 5, 6], a))); + p.text(a, b, a, Font::Helvetica, [0, 0, 0], "x"); + p.text_rotated(a, b, 10.0, Font::TimesRoman, [0, 0, 0], "y", b); + p.image_rgba(a, b, b, a, &px, 2, 2); + } + } + p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]); + let c = page_content(&d); + assert!(!c.contains("NaN") && !c.contains("inf")); + let pdf = d.finish(); + check_xref(&pdf); +}