From 16db8b6a1a2952ab854043d135a192a1a154b67c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 07:41:26 -0700 Subject: [PATCH] PDF export: bound a colorized image's color channel A colorized image's color channel is the paint's samples repeated once a pixel, and nothing bounded its size: an image colorized in Registration in a document with many spot swatches could allocate without limit, and an overflowing length panicked inside repeat. The length is now computed with checked arithmetic and refused above MAX_RASTER_ALLOC, the image's own decode budget, and the export leaves such an image out with the existing "an image couldn't be decoded" warning instead of drawing it uncolored. --- crates/pdf/src/export/images.rs | 25 +++++++++++++++++++++++-- crates/pdf/src/export/nodes.rs | 18 +++++++++++++----- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 63bf1e7d5..74fa5ba46 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -3,6 +3,12 @@ use super::{Arc, BlendMode, Color, Exporter, Image, NodeKind, Paint, Rect, Size, use krilla::color::device_n::{Colorant, DeviceNSpace, Process}; use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; +/// The byte count of a repeated color channel, within [`vectorcraft_doc::MAX_RASTER_ALLOC`]. +fn tinted_color_len(samples: usize, pixels: usize) -> Option { + let bytes = samples.checked_mul(pixels)?; + (u64::try_from(bytes).ok()? <= vectorcraft_doc::MAX_RASTER_ALLOC).then_some(bytes) +} + /// A colorized image's pixels: one color in every pixel, shown as much as its soft mask says. #[derive(Clone, Hash)] struct Tinted { @@ -52,8 +58,9 @@ impl Exporter<'_> { /// every process ink and every spot ink of the document, other colors in gray, RGB or CMYK as /// the Output settings write them. The coverage is resampled as the Compression settings ask /// ([`crate::images::coverage_sized`]). `None` when the paint isn't a solid color or the pixels - /// can't be decoded; cached like [`Self::load_image`]. With `interpolate` (and the export's own - /// setting), viewers may smooth it. + /// can't be decoded or its color channel exceeds [`vectorcraft_doc::MAX_RASTER_ALLOC`]; cached + /// like [`Self::load_image`]. With `interpolate` (and the export's own setting), viewers may + /// smooth it. pub(super) fn colorized_image(&mut self, im: &vectorcraft_doc::ImageObject, paint: &Paint, interpolate: bool) -> Option { let Paint::Solid { color, swatch, tint } = paint else { return None }; let size = placed_size(im); @@ -96,6 +103,7 @@ impl Exporter<'_> { other => (other.to_rgb().map(q).to_vec(), ImageColorspace::Rgb), }, }; + tinted_color_len(samples.len(), n)?; let color = Arc::new(samples.repeat(n)); let img = Image::from_custom(Tinted { color, alpha: Arc::new(alpha), width, height, space, device_n }, interpolate).ok(); self.images.insert(cache, img.clone()); @@ -245,3 +253,16 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee Some(Image::from_rgba8(rgba.into_raw(), w, h)) }) } + +#[cfg(test)] +mod tests { + use super::tinted_color_len; + + #[test] + fn tinted_color_rejects_oversized_and_overflowing_buffers() { + let max = usize::try_from(vectorcraft_doc::MAX_RASTER_ALLOC).unwrap_or(usize::MAX); + assert_eq!(tinted_color_len(4, max / 4), Some(max)); + assert_eq!(tinted_color_len(4, max / 4 + 1), None); + assert_eq!(tinted_color_len(4, usize::MAX), None); + } +} diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 3302766df..f2edf9468 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -1,4 +1,4 @@ -use super::exporter::{address, blend, color, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable, xf}; +use super::exporter::{UNDECODED, address, blend, color, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable, xf}; use super::{ Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32, Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke, @@ -182,10 +182,18 @@ impl Exporter<'_> { self.shape(s, n, &bp, *rule, page); } NodeKind::Text(t) => self.text(s, n, t, page), - NodeKind::Image(im) => { - let colorized = n.extras().colorize.as_deref().and_then(|p| self.colorized_image(im, p, !n.extras().pixelated)); - self.image(s, im, colorized.or(alpha), n.extras().pixelated) - } + NodeKind::Image(im) => match n.extras().colorize.as_deref() { + Some(p @ Paint::Solid { .. }) => { + if writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { + if let Some(colorized) = self.colorized_image(im, p, !n.extras().pixelated) { + self.image(s, im, Some(colorized), n.extras().pixelated); + } else { + self.warn(UNDECODED); + } + } + } + _ => self.image(s, im, alpha, n.extras().pixelated), + }, NodeKind::SymbolInstance { symbol, xf } => { // Within the objects a frame may draw of symbol instances, as on screen: nested // instances multiply.