ifc_lite_geometry/processors/texture/
raster.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Raster decoding for IFC surface textures (#961): STEP `BINARY` literals
6//! plus PNG/JPEG → RGBA8, with hostile-header dimension bounds. Split from the
7//! entity-resolution half (`texture`) so each stays within the module-size
8//! house rule; no behaviour differences.
9
10/// Decode a STEP binary literal as surfaced by the decoder (the parser strips
11/// the surrounding double quotes; the first hex character is the count of
12/// unused leading bits — `0` for the byte-aligned data every IFC texture
13/// fixture uses). Strips that leading character and hex-decodes the remainder
14/// pairwise. Returns the raw bytes (e.g. a complete PNG file for a blob, or one
15/// pixel's colour components for a pixel literal).
16pub fn decode_step_binary(s: &str) -> Vec<u8> {
17    let s = s.trim().trim_matches('"');
18    if s.len() < 3 {
19        return Vec::new();
20    }
21    let hex = s.as_bytes();
22    let mut out = Vec::with_capacity(hex.len() / 2);
23    // Skip index 0 (the unused-bits indicator); decode the rest in pairs.
24    let mut i = 1;
25    while i + 1 < hex.len() {
26        match (hex_val(hex[i]), hex_val(hex[i + 1])) {
27            (Some(h), Some(l)) => out.push((h << 4) | l),
28            _ => break,
29        }
30        i += 2;
31    }
32    out
33}
34
35#[inline]
36fn hex_val(b: u8) -> Option<u8> {
37    match b {
38        b'0'..=b'9' => Some(b - b'0'),
39        b'a'..=b'f' => Some(b - b'a' + 10),
40        b'A'..=b'F' => Some(b - b'A' + 10),
41        _ => None,
42    }
43}
44
45/// Upper bound on a decoded texture's width/height. 16384² RGBA ≈ 1 GiB — a
46/// hostile/garbage image header claiming larger dimensions is rejected BEFORE
47/// any pixel buffer is allocated, so a crafted file can't drive an OOM. Matches
48/// the `IfcPixelTexture` bound.
49pub const MAX_TEX_DIM: u32 = 16384;
50
51/// Decode a PNG byte buffer to RGBA8. Returns `(rgba, width, height)`.
52fn decode_png(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
53    // png 0.18 requires the reader to be `BufRead + Seek`. `&[u8]` is `BufRead`
54    // but not `Seek`, so wrap it in a `Cursor`, which satisfies both.
55    let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
56    // EXPAND: palette → RGB, sub-8-bit grayscale → 8-bit, tRNS → alpha.
57    // STRIP_16: 16-bit channels → 8-bit. Leaves Rgb/Rgba/Grayscale/GA at 8-bit.
58    decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
59    let mut reader = decoder.read_info().ok()?;
60    // Reject an oversized header before `output_buffer_size()` allocates.
61    let png_info = reader.info();
62    if png_info.width == 0
63        || png_info.height == 0
64        || png_info.width > MAX_TEX_DIM
65        || png_info.height > MAX_TEX_DIM
66    {
67        return None;
68    }
69    // png 0.18 returns Option here (None on size overflow); propagate as a decode failure.
70    let mut buf = vec![0u8; reader.output_buffer_size()?];
71    let info = reader.next_frame(&mut buf).ok()?;
72    let (w, h) = (info.width, info.height);
73    let px = (w as usize) * (h as usize);
74    let src = &buf[..info.buffer_size()];
75    let mut rgba = Vec::with_capacity(px * 4);
76    match info.color_type {
77        png::ColorType::Rgba => rgba.extend_from_slice(&src[..px * 4]),
78        png::ColorType::Rgb => {
79            for c in src.chunks_exact(3) {
80                rgba.extend_from_slice(&[c[0], c[1], c[2], 255]);
81            }
82        }
83        png::ColorType::Grayscale => {
84            for &g in src.iter() {
85                rgba.extend_from_slice(&[g, g, g, 255]);
86            }
87        }
88        png::ColorType::GrayscaleAlpha => {
89            for c in src.chunks_exact(2) {
90                rgba.extend_from_slice(&[c[0], c[0], c[0], c[1]]);
91            }
92        }
93        // EXPAND should have removed Indexed; bail if a decoder ever leaves it.
94        png::ColorType::Indexed => return None,
95    }
96    if rgba.len() != px * 4 {
97        return None;
98    }
99    Some((rgba, w, h))
100}
101
102/// Decode a JPEG byte buffer to RGBA8. Returns `(rgba, width, height)`.
103fn decode_jpeg(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
104    let mut decoder = jpeg_decoder::Decoder::new(bytes);
105    // Read just the headers first so an oversized image is rejected before the
106    // full `decode()` allocates its pixel buffer.
107    decoder.read_info().ok()?;
108    let info = decoder.info()?;
109    if info.width == 0
110        || info.height == 0
111        || info.width as u32 > MAX_TEX_DIM
112        || info.height as u32 > MAX_TEX_DIM
113    {
114        return None;
115    }
116    let pixels = decoder.decode().ok()?;
117    let (w, h) = (info.width as usize, info.height as usize);
118    let px = w * h;
119    let mut rgba = Vec::with_capacity(px * 4);
120    match info.pixel_format {
121        jpeg_decoder::PixelFormat::RGB24 => {
122            for c in pixels.chunks_exact(3) {
123                rgba.extend_from_slice(&[c[0], c[1], c[2], 255]);
124            }
125        }
126        jpeg_decoder::PixelFormat::L8 => {
127            for &g in pixels.iter() {
128                rgba.extend_from_slice(&[g, g, g, 255]);
129            }
130        }
131        // L16 / CMYK32 are rare for IFC textures; bail to the white fallback.
132        _ => return None,
133    }
134    if rgba.len() != px * 4 {
135        return None;
136    }
137    Some((rgba, w as u32, h as u32))
138}
139
140/// Decode raster image bytes to RGBA8 by sniffing the magic bytes (PNG or
141/// JPEG), so any `RasterFormat` string spelling resolves correctly.
142pub(super) fn decode_raster_image(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
143    const PNG_MAGIC: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
144    if bytes.len() >= 8 && bytes[..8] == PNG_MAGIC {
145        return decode_png(bytes);
146    }
147    // JPEG: starts with FF D8 FF.
148    if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
149        return decode_jpeg(bytes);
150    }
151    None
152}
153
154/// Inspect supported raster headers without decoding pixel buffers. Hosts that
155/// aggregate many embedded images can budget their allocations before decoding.
156/// A successful header read does not certify the compressed pixel stream.
157pub fn embedded_raster_dimensions(step_binary: &str) -> Option<(u32, u32)> {
158    let hex = step_binary.trim().trim_matches('"').as_bytes();
159    if hex.first() != Some(&b'0') { return None; }
160    super::raster_header::dimensions(hex.len().saturating_sub(1) / 2, |i| {
161        Some((hex_val(*hex.get(1 + i * 2)?)? << 4) | hex_val(*hex.get(2 + i * 2)?)?)
162    })
163}
164
165#[cfg(test)]
166fn raster_image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
167    super::raster_header::dimensions(bytes.len(), |i| bytes.get(i).copied())
168}
169
170#[cfg(test)]
171#[path = "raster_budget_tests.rs"]
172mod budget_tests;