Skip to main content

stet_graphics/
image_limits.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Bounds on file-declared image dimensions.
6//!
7//! `/Width`, `/Height`, and `/BitsPerComponent` are arbitrary integers taken
8//! from the input, and every buffer size and loop bound downstream is derived
9//! from them. Two things go wrong without a ceiling: the products overflow —
10//! silently in release, where a wrapped size yields a buffer smaller than the
11//! loops that fill it — and even where the arithmetic survives, a declared
12//! size far larger than the data behind it is a denial of service. A 60-byte
13//! PostScript file requesting a 2000000000 x 2000000000 image asks for a
14//! 4 x 10^18 byte allocation, which aborts the process rather than failing.
15//!
16//! These live in `stet-graphics` because both input paths need them and
17//! neither can see the other: the PostScript operators are in `stet-ops`, the
18//! PDF image handler is in `stet-pdf-reader`, and `stet-pdf-reader`
19//! deliberately does not depend on the interpreter. Duplicating the constants
20//! would let two prepress-calibrated numbers drift apart.
21//!
22//! # Calibration
23//!
24//! **Sized for prepress, not for the sample corpus.** The corpus maximum is
25//! 151M pixels, but that is a sample of ordinary documents and is the wrong
26//! yardstick for a RIP. The sizes that matter:
27//!
28//! | Case | Pixels |
29//! |---|---|
30//! | 40x28 inch press sheet @ 600 dpi | 403M |
31//! | A0 poster (33x47 in) @ 600 dpi | 558M |
32//! | 60x40 inch grand format @ 600 dpi | 864M |
33//! | 60x40 inch grand format @ 1200 dpi | 3.46G |
34//!
35//! An earlier 400M ceiling, calibrated from the corpus, rejected all four.
36
37/// Largest accepted value for an image's width or height, in samples.
38///
39/// No prepress case comes close — the largest above is 72000 — while the
40/// bound keeps every dimension-derived product finite.
41pub const MAX_IMAGE_DIMENSION: i64 = 100_000;
42
43/// Largest accepted pixel count (`width * height`) for a single image.
44///
45/// 4e9 rather than something larger because it must stay under `2^32`: a
46/// number of sites compute `width * height` in `u32`, and that product is
47/// only exact while it fits. Anything multiplying further by a component
48/// count must use checked or saturating arithmetic instead.
49pub const MAX_IMAGE_PIXELS: u64 = 4_000_000_000;
50
51/// Largest accepted bits-per-component.
52///
53/// PDF 32000-1 permits 1, 2, 4, 8, and 16; PostScript adds 12. The value
54/// reaches shift expressions such as `1u32 << bpc`, which panic in debug
55/// builds at 32 or more.
56pub const MAX_BITS_PER_COMPONENT: i64 = 16;
57
58/// Validate one file-supplied image dimension.
59///
60/// Returns `None` for a missing, non-positive, or out-of-range value, so
61/// callers reject the image rather than truncating it with an `as u32` cast —
62/// a width of 4294967297 otherwise silently becomes a 1-pixel image.
63pub fn validate_image_dimension(value: Option<i64>) -> Option<u32> {
64    match value {
65        Some(n) if n > 0 && n <= MAX_IMAGE_DIMENSION => Some(n as u32),
66        _ => None,
67    }
68}
69
70/// Validate a `width`/`height` pair and return the pixel count.
71///
72/// Guarantees `width * height` fits in `u32` and in `usize` — including the
73/// 32-bit `usize` of the wasm32 target — so downstream sites may multiply the
74/// two directly.
75pub fn validate_image_size(width: u32, height: u32) -> Option<usize> {
76    let pixels = u64::from(width).checked_mul(u64::from(height))?;
77    if pixels == 0 || pixels > MAX_IMAGE_PIXELS {
78        return None;
79    }
80    usize::try_from(pixels).ok()
81}
82
83/// Validate a file-supplied bits-per-component, falling back to 8 when absent.
84pub fn validate_bits_per_component(value: Option<i64>) -> Option<u32> {
85    match value {
86        None => Some(8),
87        Some(n) if n > 0 && n <= MAX_BITS_PER_COMPONENT => Some(n as u32),
88        _ => None,
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn prepress_sizes_are_accepted() {
98        // The four cases from the table above.
99        for (label, w, h) in [
100            ("40x28in press sheet @ 600dpi", 24_000, 16_800),
101            ("A0 poster @ 600dpi", 19_800, 28_200),
102            ("60x40in grand format @ 600dpi", 36_000, 24_000),
103            ("60x40in grand format @ 1200dpi", 72_000, 48_000),
104        ] {
105            assert!(
106                validate_image_size(w, h).is_some(),
107                "{label} ({w}x{h}) must be accepted"
108            );
109        }
110    }
111
112    #[test]
113    fn overflowing_and_degenerate_sizes_are_rejected() {
114        // 65537 * 65536 overflows u32 to 65536 rather than wrapping to zero.
115        assert!(validate_image_size(65_537, 65_536).is_none());
116        assert!(validate_image_size(0, 100).is_none());
117        assert!(validate_image_dimension(Some(0)).is_none());
118        assert!(validate_image_dimension(Some(-1)).is_none());
119        assert!(validate_image_dimension(Some(4_294_967_297)).is_none());
120        assert!(validate_image_dimension(None).is_none());
121    }
122
123    #[test]
124    fn pixel_ceiling_stays_within_u32() {
125        assert!(
126            MAX_IMAGE_PIXELS < u64::from(u32::MAX),
127            "sites computing width * height in u32 depend on this"
128        );
129    }
130
131    #[test]
132    fn bits_per_component_is_bounded() {
133        assert_eq!(validate_bits_per_component(None), Some(8));
134        assert_eq!(validate_bits_per_component(Some(8)), Some(8));
135        assert!(validate_bits_per_component(Some(99)).is_none());
136        assert!(validate_bits_per_component(Some(0)).is_none());
137    }
138}