PDF: text width cannot overflow; dash, stroke width and NaN sizes are sanitised (#812)

text_width sums in u64; an all-zero or non-finite dash array is written as
solid ([] 0 d); stroke widths are clamped to >= 0 (NaN -> 0); drawing calls
with non-finite or non-positive geometry return before storing anything.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-12 22:18:58 +02:00
parent fc45b7f6d8
commit a540a7138a
3 changed files with 135 additions and 15 deletions

View File

@ -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) |

View File

@ -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<f32> = 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::<Vec<_>>().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<String> = 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<String> = 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<Rgb>, 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<Rgb>, 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;

View File

@ -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);
}