Root cause
Resource abuse & availability introduced
Initial commit: Native Rust PDF generation library
How AI contributed: Direct introductionPublished May 11, 2026 in
bzsanti/oxidizepdf
AI-generated color constructors in oxidizepdf documented a clamp to the [0,1] range but passed non-finite f64 values straight through, so a directly built Color::Rgb(f64::NAN, ...) reached the emission path unchanged. Color operators format each component with format!('{:.3}', v), which writes NaN or infinity into PDF content streams although ISO 32000-1 7.3.3 requires finite numbers, leaving consumers with a document that conformant readers reject. The fix adds a finite_or_zero helper that maps non-finite values to 0.0 before any color operator is written.
The Color::rgb, Color::gray and Color::cmyk constructors promise a [0,1] clamp but never test whether a component is finite, and the emission path formats components directly with format!('{:.3}', v). A NaN or infinite component therefore becomes an invalid PDF number token in the output stream. The fix routes every color write through shared helpers that apply finite_or_zero first.
Root cause
Initial commit: Native Rust PDF generation library
Fix
fix(graphics): sanitize non-finite floats in colour emission via shared helper — addresses #220, #221
AI-assisted fix: Claude Code · Bytaro
Case facts
@@ -0,0 +1,272 @@+use crate::document::Document;+use crate::error::Result;+use crate::objects::{Dictionary, Object, ObjectId};+use std::collections::HashMap;+use std::io::{BufWriter, Write};+use std::path::Path;++pub struct PdfWriter {+ writer: BufWriter<std::fs::File>,+ xref_positions: HashMap<ObjectId, u64>,+ current_position: u64,+}++impl PdfWriter {+ pub fn new(path: impl AsRef<Path>) -> Result<Self> {+ let file = std::fs::File::create(path)?;+ let writer = BufWriter::new(file);+ + Ok(Self {+ writer,+ xref_positions: HashMap::new(),+ current_position: 0,+ })+ }+ + pub fn write_document(&mut self, document: &mut Document) -> Result<()> {+ self.write_header()?;+ + // Write all objects and collect their positions+ let catalog_id = self.write_catalog()?;+ let _pages_id = self.write_pages(document)?;+ let info_id = self.write_info(document)?;+ + // Write xref table+ let xref_position = self.current_position;+ self.write_xref()?;+ + // Write trailer+ self.write_trailer(catalog_id, info_id, xref_position)?;+ + self.writer.flush()?;+ Ok(())+ }+ + fn write_header(&mut self) -> Result<()> {+ self.write_bytes(b"%PDF-1.7\n")?;+ // Binary comment to ensure file is treated as binary+ self.write_bytes(&[b'%', 0xE2, 0xE3, 0xCF, 0xD3, b'\n'])?;+ Ok(())+ }+ + fn write_catalog(&mut self) -> Result<ObjectId> {+ let catalog_id = ObjectId::new(1, 0);+ let pages_id = ObjectId::new(2, 0);+ + let mut catalog = Dictionary::new();+ catalog.set("Type", Object::Name("Catalog".to_string()));+ catalog.set("Pages", Object::Reference(pages_id));+ + self.write_object(catalog_id, Object::Dictionary(catalog))?;+ Ok(catalog_id)+ }+ + fn write_pages(&mut self, document: &Document) -> Result<ObjectId> {+ let pages_id = ObjectId::new(2, 0);+ let mut pages_dict = Dictionary::new();+ pages_dict.set("Type", Object::Name("Pages".to_string()));+ pages_dict.set("Count", Object::Integer(document.pages.len() as i64));+ + let mut kids = Vec::new();+ let next_id = 3;+ + for (i, _page) in document.pages.iter().enumerate() {+ let page_id = ObjectId::new(next_id + i as u32 * 2, 0);+ kids.push(Object::Reference(page_id));+ }+ + pages_dict.set("Kids", Object::Array(kids));+ + self.write_object(pages_id, Object::Dictionary(pages_dict))?;+ + // Write individual pages+ for (i, page) in document.pages.iter().enumerate() {+ let page_id = ObjectId::new(next_id + i as u32 * 2, 0);+ let content_id = ObjectId::new(next_id + i as u32 * 2 + 1, 0);+ + self.write_page(page_id, pages_id, content_id, page)?;+ self.write_page_content(content_id, page)?;+ }@@ -0,0 +1,107 @@+mod font;+mod encoding;++pub use font::{Font, FontFamily};+pub use encoding::TextEncoding;++use crate::error::Result;+use std::fmt::Write;++#[derive(Clone)]+pub struct TextContext {+ operations: String,+ current_font: Font,+ font_size: f64,+ text_matrix: [f64; 6],+}++impl TextContext {+ pub fn new() -> Self {+ Self {+ operations: String::new(),+ current_font: Font::Helvetica,+ font_size: 12.0,+ text_matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],+ }+ }+ + pub fn set_font(&mut self, font: Font, size: f64) -> &mut Self {+ self.current_font = font;+ self.font_size = size;+ self+ }+ + pub fn at(&mut self, x: f64, y: f64) -> &mut Self {+ self.text_matrix[4] = x;+ self.text_matrix[5] = y;+ self+ }+ + pub fn write(&mut self, text: &str) -> Result<&mut Self> {+ // Begin text object+ self.operations.push_str("BT\n");+ + // Set font+ write!(&mut self.operations, "/{} {} Tf\n", + self.current_font.pdf_name(), self.font_size).unwrap();+ + // Set text position+ write!(&mut self.operations, "{:.2} {:.2} Td\n", + self.text_matrix[4], self.text_matrix[5]).unwrap();+ + // Show text+ self.operations.push('(');+ for ch in text.chars() {+ match ch {+ '(' => self.operations.push_str("\\("),+ ')' => self.operations.push_str("\\)"),+ '\\' => self.operations.push_str("\\\\"),+ '\n' => self.operations.push_str("\\n"),+ '\r' => self.operations.push_str("\\r"),+ '\t' => self.operations.push_str("\\t"),+ _ => self.operations.push(ch),+ }+ }+ self.operations.push_str(") Tj\n");+ + // End text object+ self.operations.push_str("ET\n");+ + Ok(self)+ }+ + pub fn write_line(&mut self, text: &str) -> Result<&mut Self> {+ self.write(text)?;+ self.text_matrix[5] -= self.font_size * 1.2; // Move down for next line+ Ok(self)+ }+ + pub fn set_character_spacing(&mut self, spacing: f64) -> &mut Self {+ write!(&mut self.operations, "{:.2} Tc\n", spacing).unwrap();+ self+ }+ + pub fn set_word_spacing(&mut self, spacing: f64) -> &mut Self {+ write!(&mut self.operations, "{:.2} Tw\n", spacing).unwrap();+ self+ }+ + pub fn set_horizontal_scaling(&mut self, scale: f64) -> &mut Self {@@ -0,0 +1,84 @@+#[derive(Debug, Clone, Copy, PartialEq)]+pub enum Font {+ // Standard 14 PDF fonts+ Helvetica,+ HelveticaBold,+ HelveticaOblique,+ HelveticaBoldOblique,+ TimesRoman,+ TimesBold,+ TimesItalic,+ TimesBoldItalic,+ Courier,+ CourierBold,+ CourierOblique,+ CourierBoldOblique,+ Symbol,+ ZapfDingbats,+}++impl Font {+ pub fn pdf_name(&self) -> &'static str {+ match self {+ Font::Helvetica => "Helvetica",+ Font::HelveticaBold => "Helvetica-Bold",+ Font::HelveticaOblique => "Helvetica-Oblique",+ Font::HelveticaBoldOblique => "Helvetica-BoldOblique",+ Font::TimesRoman => "Times-Roman",+ Font::TimesBold => "Times-Bold",+ Font::TimesItalic => "Times-Italic",+ Font::TimesBoldItalic => "Times-BoldItalic",+ Font::Courier => "Courier",+ Font::CourierBold => "Courier-Bold",+ Font::CourierOblique => "Courier-Oblique",+ Font::CourierBoldOblique => "Courier-BoldOblique",+ Font::Symbol => "Symbol",+ Font::ZapfDingbats => "ZapfDingbats",+ }+ }+ + pub fn is_symbolic(&self) -> bool {+ matches!(self, Font::Symbol | Font::ZapfDingbats)+ }+}++#[derive(Debug, Clone, Copy, PartialEq)]+pub enum FontFamily {+ Helvetica,+ Times,+ Courier,+}++impl FontFamily {+ pub fn regular(self) -> Font {+ match self {+ FontFamily::Helvetica => Font::Helvetica,+ FontFamily::Times => Font::TimesRoman,+ FontFamily::Courier => Font::Courier,+ }+ }+ + pub fn bold(self) -> Font {+ match self {+ FontFamily::Helvetica => Font::HelveticaBold,+ FontFamily::Times => Font::TimesBold,+ FontFamily::Courier => Font::CourierBold,+ }+ }+ + pub fn italic(self) -> Font {+ match self {+ FontFamily::Helvetica => Font::HelveticaOblique,+ FontFamily::Times => Font::TimesItalic,+ FontFamily::Courier => Font::CourierOblique,+ }+ }+ + pub fn bold_italic(self) -> Font {+ match self {+ FontFamily::Helvetica => Font::HelveticaBoldOblique,+ FontFamily::Times => Font::TimesBoldItalic,+ FontFamily::Courier => Font::CourierBoldOblique,+ }+ }+}@@ -224,15 +224,22 @@ impl FormXObjectBuilder { self } - /// Set fill color (RGB)+ /// Set fill color (RGB) — routed through the shared NaN-sanitising+ /// helper (issues #220 + #221) so non-finite inputs cannot produce+ /// `NaN`/`inf` tokens that ISO 32000-1 §7.3.3 rejects. pub fn fill_color(mut self, r: f64, g: f64, b: f64) -> Self {- self.operations.push(format!("{} {} {} rg", r, g, b));+ self.operations.push(crate::graphics::color::fill_color_op(+ crate::graphics::Color::Rgb(r, g, b),+ )); self } - /// Set stroke color (RGB)+ /// Set stroke color (RGB) — sanitised via shared helper. pub fn stroke_color(mut self, r: f64, g: f64, b: f64) -> Self {- self.operations.push(format!("{} {} {} RG", r, g, b));+ self.operations+ .push(crate::graphics::color::stroke_color_op(+ crate::graphics::Color::Rgb(r, g, b),+ )); self } @@ -575,7 +582,9 @@ mod tests { .build(); let content = String::from_utf8(form.content).unwrap();- assert!(content.contains("1 0 0 rg"));+ // After issue #220/#221 helper migration, fill_color/stroke_color+ // emit `.3`-precision tokens — `1.000 0.000 0.000 rg`, not `1 0 0 rg`.+ assert!(content.contains("1.000 0.000 0.000 rg")); assert!(content.contains("10 10 80 80 re")); assert!(content.contains("f")); }@@ -597,7 +606,7 @@ mod tests { let content = String::from_utf8(form.content.clone()).unwrap(); assert!(content.contains("q")); assert!(content.contains("Q"));- assert!(content.contains("0 0 1 RG"));+ assert!(content.contains("0.000 0.000 1.000 RG")); assert!(form.has_transparency()); } @@ -609,7 +618,7 @@ mod tests { assert_eq!(form.bbox.height(), 20.0); let content = String::from_utf8(form.content).unwrap();- assert!(content.contains("0 0.5 0 RG")); // Green color+ assert!(content.contains("0.000 0.500 0.000 RG")); // Green color } #[test]@@ -619,7 +628,7 @@ mod tests { assert_eq!(form.bbox.width(), 30.0); let content = String::from_utf8(form.content).unwrap();- assert!(content.contains("0.8 0 0 RG")); // Red color+ assert!(content.contains("0.800 0.000 0.000 RG")); // Red color } #[test]@@ -644,7 +653,7 @@ mod tests { assert_eq!(star.bbox.width(), 100.0); let content = String::from_utf8(star.content).unwrap();- assert!(content.contains("1 0.8 0 rg")); // Gold color+ assert!(content.contains("1.000 0.800 0.000 rg")); // Gold color } #[test]@@ -222,6 +222,199 @@ impl Color { } } +/// Substitute non-finite floats with `0.0` for safe content-stream emission+/// (issue #220). Direct enum construction such as `Color::Rgb(f64::NAN, …)`+/// bypasses the `Color::rgb`/`gray`/`cmyk` clamps; emitting the raw value with+/// `{:.3}` would produce `NaN`/`inf`/`-inf` tokens, which ISO 32000-1 §7.3.3+/// rejects as numeric values and conformant viewers reject the entire content+/// stream.+///+/// This is a finite-only check — finite values pass through unchanged+/// (including values outside `[0.0, 1.0]`, which the renderer is responsible+/// for clamping). The aim is strictly to keep the wire format syntactically+/// valid, not to enforce colour-value semantics here.+#[inline]+pub(crate) fn finite_or_zero(val: f64) -> f64 {+ if val.is_finite() {+ val+ } else {+ 0.0+ }+}++/// Single source of truth for non-stroking colour-operator emission (issue+/// #221). Returns the operator as a `String` *without* trailing newline,+/// with NaN/inf sanitisation (issue #220) and `.3`-precision formatting.+///+/// Operators emitted (ISO 32000-1 §8.6.8):+/// - `Color::Rgb(r, g, b)` → `"r g b rg"`+/// - `Color::Gray(g)` → `"g g"`+/// - `Color::Cmyk(c, m, y, k)` → `"c m y k k"`+///+/// Use this when embedding the operator inside a larger PDF expression —+/// e.g. an annotation `/DA` default-appearance string `/Helv 12 Tf 0 0 0 rg`.+/// For content-stream emission with trailing newline, prefer+/// [`write_fill_color`] (writes to `&mut String`) or+/// [`write_fill_color_bytes`] (writes to `&mut Vec<u8>`).+pub(crate) fn fill_color_op(color: Color) -> String {+ use std::fmt::Write;+ let mut s = String::new();+ match color {+ Color::Rgb(r, g, b) => write!(+ &mut s,+ "{:.3} {:.3} {:.3} rg",+ finite_or_zero(r),+ finite_or_zero(g),+ finite_or_zero(b)+ ),+ Color::Gray(gray) => write!(&mut s, "{:.3} g", finite_or_zero(gray)),+ Color::Cmyk(c, m, y, k) => write!(+ &mut s,+ "{:.3} {:.3} {:.3} {:.3} k",+ finite_or_zero(c),+ finite_or_zero(m),+ finite_or_zero(y),+ finite_or_zero(k)+ ),+ }+ .expect("writing to a String never fails");+ s+}++/// Single source of truth for stroking colour-operator emission. Companion+/// to [`fill_color_op`] (uppercase `RG` / `G` / `K`). No trailing newline,+/// NaN/inf sanitised.+pub(crate) fn stroke_color_op(color: Color) -> String {+ use std::fmt::Write;+ let mut s = String::new();+ match color {+ Color::Rgb(r, g, b) => write!(+ &mut s,+ "{:.3} {:.3} {:.3} RG",+ finite_or_zero(r),+ finite_or_zero(g),+ finite_or_zero(b)+ ),+ Color::Gray(gray) => write!(&mut s, "{:.3} G", finite_or_zero(gray)),+ Color::Cmyk(c, m, y, k) => write!(+ &mut s,+ "{:.3} {:.3} {:.3} {:.3} K",+ finite_or_zero(c),+ finite_or_zero(m),+ finite_or_zero(y),+ finite_or_zero(k)+ ),+ }+ .expect("writing to a String never fails");+ s+}@@ -360,23 +360,15 @@ impl GeometricAppearance { // Set border width stream.extend(format!("{} w\n", border_width).as_bytes()); - // Set colors+ // Set colors. Routed through the shared sanitising helpers+ // (issues #220 + #221) so /AP appearance streams cannot emit+ // NaN/inf tokens that ISO 32000-1 §7.3.3 rejects. if let Some(color) = interior_color {- let fill_op = match color {- Color::Gray(g) => format!("{} g\n", g),- Color::Rgb(r, g, b) => format!("{} {} {} rg\n", r, g, b),- Color::Cmyk(c, m, y, k) => format!("{} {} {} {} k\n", c, m, y, k),- };- stream.extend(fill_op.as_bytes());+ crate::graphics::color::write_fill_color_bytes(&mut stream, *color); } if let Some(color) = border_color {- let stroke_op = match color {- Color::Gray(g) => format!("{} G\n", g),- Color::Rgb(r, g, b) => format!("{} {} {} RG\n", r, g, b),- Color::Cmyk(c, m, y, k) => format!("{} {} {} {} K\n", c, m, y, k),- };- stream.extend(stroke_op.as_bytes());+ crate::graphics::color::write_stroke_color_bytes(&mut stream, *color); } // Draw circle using Bézier curves@@ -467,23 +459,15 @@ impl GeometricAppearance { // Set border width stream.extend(format!("{} w\n", border_width).as_bytes()); - // Set colors+ // Set colors. Routed through the shared sanitising helpers+ // (issues #220 + #221) so /AP appearance streams cannot emit+ // NaN/inf tokens that ISO 32000-1 §7.3.3 rejects. if let Some(color) = interior_color {- let fill_op = match color {- Color::Gray(g) => format!("{} g\n", g),- Color::Rgb(r, g, b) => format!("{} {} {} rg\n", r, g, b),- Color::Cmyk(c, m, y, k) => format!("{} {} {} {} k\n", c, m, y, k),- };- stream.extend(fill_op.as_bytes());+ crate::graphics::color::write_fill_color_bytes(&mut stream, *color); } if let Some(color) = border_color {- let stroke_op = match color {- Color::Gray(g) => format!("{} G\n", g),- Color::Rgb(r, g, b) => format!("{} {} {} RG\n", r, g, b),- Color::Cmyk(c, m, y, k) => format!("{} {} {} {} K\n", c, m, y, k),- };- stream.extend(stroke_op.as_bytes());+ crate::graphics::color::write_stroke_color_bytes(&mut stream, *color); } // Draw rectangleAI-assisted change 742d8963c8bb86ddf4686a8604a799cf47aa66008b6f35ea8ff10ca6869ed3a6 · Fix 416cdc4529d8443eaf6527dbe677b851ae39112c2b970bc888c35bcd5c2836d8