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:
parent
fc45b7f6d8
commit
a540a7138a
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue