mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* Add craft-raster-codecs: DDS, DICOM, FITS, IFF/ILBM, PCX, PNM and XPM from PhotoCraft One module per format, moved from photocraft-codecs at 63b641b5 with its tests. Decoders return one flat Image and take Limits; encoders take an image already in a layout the format stores. Layout planning, colour and the document mapping stay in the app. * craft-raster-codecs: PCX reads four 8-bit planes as RGBA and version 4 headers; pcx::dimensions Four 8-bit planes are red, green, blue and alpha: they decode to RGBA instead of being refused. Version 4 (PC Paintbrush for Windows) headers are accepted. pcx::dimensions gives the pixel size from the header without decoding. Ported from the PCX reader of storytold/designcraft#2011. --------- Co-authored-by: subha-2025 <218455239+subha-2025@users.noreply.github.com>
145 lines
6.3 KiB
Rust
145 lines
6.3 KiB
Rust
//! ZSoft PCX (Paintbrush) reader: strictly bounded scanline RLE, 16/256-colour
|
|
//! palettes, 8-bit three-plane RGB and four-plane RGBA. Read-only: modern saves should use PNG.
|
|
|
|
use crate::{ChannelLayout, CodecError, Image, Limits, SampleType};
|
|
|
|
const HEADER: usize = 128;
|
|
|
|
fn bad(reason: impl std::fmt::Display) -> CodecError {
|
|
CodecError::malformed("PCX", reason)
|
|
}
|
|
fn le16(b: &[u8], i: usize) -> u16 {
|
|
u16::from_le_bytes([b[i], b[i + 1]])
|
|
}
|
|
|
|
/// PCX lacks a strong magic number. Require plausible complete header fields before
|
|
/// identifying it, rather than classifying every file starting with 0x0a as PCX.
|
|
pub fn looks_like_pcx(b: &[u8]) -> bool {
|
|
if b.len() < HEADER || b[0] != 0x0a || !matches!(b[1], 0 | 2..=5) || b[2] != 1 {
|
|
return false;
|
|
}
|
|
let bits = b[3];
|
|
let planes = b[65];
|
|
let stride = u32::from(le16(b, 66));
|
|
let (x0, y0, x1, y1) = (le16(b, 4), le16(b, 6), le16(b, 8), le16(b, 10));
|
|
if x1 < x0 || y1 < y0 || !matches!(bits, 1 | 2 | 4 | 8) || !(1..=4).contains(&planes) {
|
|
return false;
|
|
}
|
|
let width = u32::from(x1 - x0) + 1;
|
|
let min_stride = (width * u32::from(bits)).div_ceil(8);
|
|
stride >= min_stride && stride > 0 && (bits != 8 || matches!(planes, 1 | 3 | 4)) && (bits == 8 || u32::from(bits) * u32::from(planes) <= 4)
|
|
}
|
|
|
|
/// Read one compressed scanline, including all planes. RLE packets never cross a scanline.
|
|
fn read_row(b: &[u8], pos: &mut usize, out: &mut [u8]) -> Result<(), CodecError> {
|
|
let mut n = 0;
|
|
while n < out.len() {
|
|
let code = *b.get(*pos).ok_or_else(|| bad("truncated pixel RLE"))?;
|
|
*pos += 1;
|
|
let (count, value) = if code >= 0xc0 {
|
|
let count = usize::from(code & 0x3f);
|
|
let value = *b.get(*pos).ok_or_else(|| bad("truncated RLE packet"))?;
|
|
*pos += 1;
|
|
(count, value)
|
|
} else {
|
|
(1, code)
|
|
};
|
|
if count == 0 || count > out.len() - n {
|
|
return Err(bad("RLE packet crosses a scanline or has zero length"));
|
|
}
|
|
out[n..n + count].fill(value);
|
|
n += count;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The pixel size a PCX header declares, without decoding; `None` when [`looks_like_pcx`] is false.
|
|
pub fn dimensions(b: &[u8]) -> Option<(u32, u32)> {
|
|
looks_like_pcx(b).then(|| (u32::from(le16(b, 8) - le16(b, 4)) + 1, u32::from(le16(b, 10) - le16(b, 6)) + 1))
|
|
}
|
|
|
|
/// Decodes to 8-bit RGB, or RGBA for four 8-bit planes (red, green, blue, alpha).
|
|
pub fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
|
let (w, h) = dimensions(b).ok_or_else(|| bad("invalid PCX header or unsupported pixel layout"))?;
|
|
let (bits, planes, stride) = (usize::from(b[3]), usize::from(b[65]), usize::from(le16(b, 66)));
|
|
let layout = if bits == 8 && planes == 4 { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
|
let ch = layout.channels();
|
|
limits.check(w, h, layout, SampleType::U8)?;
|
|
let row_len = stride.checked_mul(planes).ok_or_else(|| bad("row size overflow"))?;
|
|
let total = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(ch)).ok_or_else(|| bad("pixel size overflow"))?;
|
|
// The last 769 bytes are the optional VGA palette for 8-bit indexed images.
|
|
let vga = bits == 8 && planes == 1 && b.len() >= HEADER + 769 && b[b.len() - 769] == 0x0c;
|
|
if bits == 8 && planes == 1 && !vga && le16(b, 68) != 2 {
|
|
return Err(bad("8-bit indexed PCX has no 256-colour palette"));
|
|
}
|
|
let image_end = if vga { b.len() - 769 } else { b.len() };
|
|
// A 63-byte RLE packet takes 2 bytes; a trailing remainder of 1 byte below 0xC0
|
|
// can be encoded as a 1-byte literal, while any larger remainder takes at least 2 bytes.
|
|
// Because RLE packets may span planes within a scanline (`row_len`), compute the
|
|
// minimum encoded bytes across the full scanline (`row_len`) and multiply by `h`.
|
|
// Reject inputs that cannot possibly contain all scanlines before allocating
|
|
// the decoded image buffer.
|
|
let min_packets = (row_len / 63)
|
|
.checked_mul(2)
|
|
.and_then(|n| {
|
|
n.checked_add(match row_len % 63 {
|
|
0 => 0,
|
|
1 => 1,
|
|
_ => 2,
|
|
})
|
|
})
|
|
.ok_or_else(|| bad("compressed row size overflow"))?;
|
|
let min_encoded = min_packets.checked_mul(h as usize).ok_or_else(|| bad("compressed image size overflow"))?;
|
|
let encoded_len = image_end.saturating_sub(HEADER);
|
|
if encoded_len < min_encoded {
|
|
return Err(bad("PCX pixel data is too short for the declared image"));
|
|
}
|
|
let mut row = vec![0u8; row_len];
|
|
let mut pixels = Vec::new();
|
|
pixels.try_reserve_exact(total).map_err(|_| bad("PCX pixel buffer is too large"))?;
|
|
pixels.resize(total, 0);
|
|
let mut p = HEADER;
|
|
let width = w as usize;
|
|
for y in 0..h as usize {
|
|
read_row(&b[..image_end], &mut p, &mut row)?;
|
|
for x in 0..width {
|
|
let at = (y * width + x) * ch;
|
|
let rgb = if bits == 8 && planes >= 3 {
|
|
if planes == 4 {
|
|
pixels[at + 3] = row[3 * stride + x];
|
|
}
|
|
[row[x], row[stride + x], row[2 * stride + x]]
|
|
} else {
|
|
let index = if bits == 8 {
|
|
usize::from(row[x])
|
|
} else {
|
|
let bit = x * bits;
|
|
let shift = 8 - bits - (bit % 8);
|
|
let mask = (1usize << bits) - 1;
|
|
(0..planes).fold(0usize, |v, plane| {
|
|
let sample = (usize::from(row[plane * stride + bit / 8]) >> shift) & mask;
|
|
v | sample << (plane * bits)
|
|
})
|
|
};
|
|
if vga {
|
|
let o = image_end + 1 + index * 3;
|
|
[b[o], b[o + 1], b[o + 2]]
|
|
} else if bits == 8 {
|
|
let g = index as u8;
|
|
[g, g, g] // palette-info 2: gray scale
|
|
} else {
|
|
let o = 16 + index * 3;
|
|
[b[o], b[o + 1], b[o + 2]]
|
|
}
|
|
};
|
|
pixels[at..at + 3].copy_from_slice(&rgb);
|
|
}
|
|
}
|
|
let mut image = Image::from_u8(w, h, layout, pixels)?;
|
|
let (xdpi, ydpi) = (le16(b, 12), le16(b, 14));
|
|
if xdpi > 0 && ydpi > 0 {
|
|
image.dpi = Some((f32::from(xdpi), f32::from(ydpi)));
|
|
}
|
|
Ok(image)
|
|
}
|