Add PCX image support to Create PDF conversion (#6396)

* Add PCX image support to Create PDF conversion

PCX is listed as an accepted Create source elsewhere but PdfCraft rejects it, so users cannot convert PCX images to PDF.

Detect PCX by its header in is_image and decode it in embed, adding pcx to CONVERTIBLE so existing from_images and source_kind paths handle it.

Test: pcx_images_become_pages failed before the change and passes after.

* Create: PCX scanlines hold every plane's row, 8-bit indexed images, older versions, standard test files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
eliets2
2026-11-06 13:16:36 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 eliets2 FredApps
parent a0811650de
commit 1d71d6039f
2 changed files with 209 additions and 1 deletions
+133 -1
View File
@@ -516,6 +516,129 @@ fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result<Embed
Ok(rgba_image(rgba.as_raw(), rgba.dimensions(), (72.0, 72.0)))
}
fn le16(b: &[u8], at: usize) -> Option<u16> {
b.get(at..at + 2).map(|x| u16::from_le_bytes([x[0], x[1]]))
}
/// How a PCX image holds its pixels: bits per plane and planes.
#[derive(Clone, Copy, PartialEq)]
enum PcxLayout {
/// One byte per pixel per colour: each scanline has a red, a green and a blue row.
Rgb,
/// One byte per pixel, an index into the palette after the image (grey without one).
Indexed,
/// One bit per pixel in each of 1 to 4 planes, an index into the header's palette.
Bits(usize),
}
impl PcxLayout {
fn of(bits: u8, planes: usize) -> Option<PcxLayout> {
match (bits, planes) {
(8, 3) => Some(PcxLayout::Rgb),
(8, 1) => Some(PcxLayout::Indexed),
(1, 1..=4) => Some(PcxLayout::Bits(planes)),
_ => None,
}
}
}
/// The palette of a PCX image, and where its run-length coded scanlines end. An 8-bit image ends
/// with a 0x0C byte and 256 colours; a one-plane bit image is black and white whatever its header
/// holds; other bit images use the 16 colours at byte 16.
fn pcx_palette(bytes: &[u8], layout: PcxLayout) -> (Vec<u8>, usize) {
match layout {
PcxLayout::Rgb => (Vec::new(), bytes.len()),
PcxLayout::Indexed => match bytes.len().checked_sub(769).filter(|at| *at >= 128 && bytes.get(*at) == Some(&0x0C)) {
Some(at) => (bytes.get(at + 1..).unwrap_or_default().to_vec(), at),
None => ((0..=255u8).flat_map(|v| [v, v, v]).collect(), bytes.len()),
},
PcxLayout::Bits(1) => (vec![0, 0, 0, 255, 255, 255], bytes.len()),
PcxLayout::Bits(_) => (bytes.get(16..64).unwrap_or_default().to_vec(), bytes.len()),
}
}
/// The `rows` scanlines of `line` bytes each, decoded. A run may cross from one plane's row into the
/// next, but not out of its scanline.
fn pcx_scanlines(mut body: &[u8], rows: usize, line: usize) -> Option<Vec<u8>> {
let mut out = Vec::new();
out.try_reserve_exact(rows.checked_mul(line)?).ok()?;
for row in 1..=rows {
let end = row * line;
while out.len() < end {
let (&c, rest) = body.split_first()?;
body = rest;
if c < 0xC0 {
out.push(c);
continue;
}
let (&value, rest) = body.split_first()?;
body = rest;
let count = usize::from(c & 0x3F);
if count > end - out.len() {
return None;
}
out.resize(out.len() + count, value);
}
}
Some(out)
}
/// The RGB samples of the `w` pixels of scanline `row` (planes `stride` bytes apart).
fn pcx_row(out: &mut Vec<u8>, row: &[u8], w: usize, stride: usize, layout: PcxLayout, palette: &[u8]) {
let at = |i: usize| row.get(i).copied().unwrap_or(0);
for x in 0..w {
let index = match layout {
PcxLayout::Rgb => {
out.extend([at(x), at(stride + x), at(2 * stride + x)]);
continue;
}
PcxLayout::Indexed => usize::from(at(x)),
PcxLayout::Bits(planes) => (0..planes).fold(0, |index, k| index | (usize::from(at(k * stride + x / 8) >> (7 - x % 8) & 1) << k)),
};
out.extend((0..3).map(|c| palette.get(index * 3 + c).copied().unwrap_or(0)));
}
}
/// A PCX image: a 128-byte header (size at 4-11, resolution at 12 and 14, plane count at 65, bytes
/// per plane row at 66), then run-length coded scanlines, each holding one row of every plane.
fn pcx(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
let bad = |m: &str| CreateError::Image(name.into(), m.into());
let header = bytes.get(..128).ok_or_else(|| bad("a damaged PCX header"))?;
let word = |at: usize| le16(header, at).unwrap_or(0);
let byte = |at: usize| header.get(at).copied().unwrap_or(0);
let (bits, planes, stride) = (byte(3), usize::from(byte(65)), usize::from(word(66)));
let (Some(w), Some(h)) = (word(8).checked_sub(word(4)), word(10).checked_sub(word(6))) else {
return Err(bad("a damaged PCX header"));
};
let (w, h) = (usize::from(w) + 1, usize::from(h) + 1);
if planes == 0 || stride == 0 {
return Err(bad("a damaged PCX header"));
}
let layout = PcxLayout::of(bits, planes).ok_or_else(|| bad("this PCX plane layout is not supported"))?;
if stride < (w * usize::from(bits)).div_ceil(8) {
return Err(bad("a damaged PCX header"));
}
let line = planes * stride;
if line.saturating_mul(h) > MAX_DECODED_BYTES || w.saturating_mul(h).saturating_mul(3) > MAX_DECODED_BYTES {
return Err(bad("unusual PCX image size"));
}
let (palette, end) = pcx_palette(bytes, layout);
let body = bytes.get(128..end).unwrap_or_default();
let scanlines = pcx_scanlines(body, h, line).ok_or_else(|| bad("a damaged PCX body"))?;
let mut data = Vec::new();
data.try_reserve_exact(w * h * 3).map_err(|_| bad("not enough memory for this image"))?;
for row in scanlines.chunks_exact(line) {
pcx_row(&mut data, row, w, stride, layout, &palette);
}
let dpi = (f64::from(word(12)), f64::from(word(14)));
let dpi = if dpi.0 > 0.0 && dpi.1 > 0.0 { dpi } else { (72.0, 72.0) };
let mut d = Dict::new();
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let px = (u32::try_from(w).unwrap_or(0), u32::try_from(h).unwrap_or(0));
Ok(Embedded { dict: d, data, filtered: false, smask: None, icc: None, px, dpi })
}
/// Largest GIF logical screen, in pixels (4096×4096).
const MAX_GIF_PIXELS: u64 = 1 << 24;
@@ -623,7 +746,13 @@ pub enum SourceKind {
}
/// File extensions Create converts to PDF (images and plain text).
pub const CONVERTIBLE: [&str; 12] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "txt", "text"];
pub const CONVERTIBLE: [&str; 13] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx", "txt", "text"];
/// A PCX file: its manufacturer byte, a version (1 was never used), run-length coding and 1 or 8
/// bits per plane. `pcx` says when the rest of the header isn't one it can read.
fn is_pcx(bytes: &[u8]) -> bool {
bytes.first() == Some(&0x0A) && matches!(bytes.get(1), Some(0 | 2..=5)) && bytes.get(2) == Some(&1) && matches!(bytes.get(3), Some(1 | 8))
}
/// A classic TIFF or a BigTIFF (version 43), in either byte order.
fn is_tiff(bytes: &[u8]) -> bool {
@@ -640,6 +769,7 @@ fn is_image(bytes: &[u8]) -> bool {
|| bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|| is_tiff(bytes)
|| is_gif(bytes)
|| is_pcx(bytes)
// JPEG 2000: a JP2 file or a raw codestream.
|| bytes.starts_with(JP2_SIGNATURE)
|| bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51])
@@ -675,6 +805,8 @@ fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, Creat
Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), image::ImageFormat::Bmp)?])
} else if is_gif(bytes) {
Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?])
} else if is_pcx(bytes) {
Ok(vec![pcx(name, bytes)?])
} else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) {
Ok(vec![jpx(name, bytes)?])
} else {
+76
View File
@@ -485,6 +485,82 @@ fn bigtiff_images_become_pages() {
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 2.0, 1.0], "4×2 px at 144 dpi");
}
/// A PCX header for a `w` by `h` image at 72 dpi.
fn pcx_header(version: u8, bits: u8, planes: u8, stride: u16, w: u16, h: u16) -> Vec<u8> {
let mut pcx = vec![0u8; 128];
pcx[..4].copy_from_slice(&[0x0A, version, 1, bits]);
pcx[8..10].copy_from_slice(&(w - 1).to_le_bytes());
pcx[10..12].copy_from_slice(&(h - 1).to_le_bytes());
pcx[12..14].copy_from_slice(&72u16.to_le_bytes());
pcx[14..16].copy_from_slice(&72u16.to_le_bytes());
pcx[65] = planes;
pcx[66..68].copy_from_slice(&stride.to_le_bytes());
pcx
}
/// A 24-bit PCX: each scanline holds a red, a green and a blue row.
#[test]
fn pcx_images_become_pages() {
let mut pcx = pcx_header(5, 8, 3, 2, 2, 2);
pcx.extend_from_slice(&[10, 11, 20, 21, 30, 31]);
pcx.extend_from_slice(&[40, 41, 50, 51, 60, 61]);
let embedded = super::pcx("a.pcx", &pcx).unwrap();
assert_eq!(embedded.data, [10, 20, 30, 11, 21, 31, 40, 50, 60, 41, 51, 61]);
let doc = reopen(&from_images(&[("a.pcx".into(), pcx)]).unwrap());
assert_eq!(pages(&doc).len(), 1);
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 2.0, 2.0]);
assert_eq!(image_of(&doc, 0).name(b"ColorSpace"), Some(&b"DeviceRGB"[..]));
}
#[test]
fn a_pcx_run_may_cross_planes_but_not_scanlines() {
// 0xC4 repeats 7 four times: the red row and half of the green one
let mut pcx = pcx_header(5, 8, 3, 2, 2, 1);
pcx.extend_from_slice(&[0xC4, 7, 8, 9]);
assert_eq!(super::pcx("a.pcx", &pcx).unwrap().data[..], [7, 7, 8, 7, 7, 9]);
let mut pcx = pcx_header(5, 8, 3, 2, 2, 2);
pcx.extend_from_slice(&[0xC8, 7, 1, 2, 3, 4]);
assert!(super::pcx("a.pcx", &pcx).is_err(), "eight bytes in a six-byte scanline");
}
#[test]
fn an_8_bit_pcx_uses_its_palette_and_is_grey_without_one() {
let mut pcx = pcx_header(5, 8, 1, 2, 2, 1);
pcx.extend_from_slice(&[1, 2]);
assert_eq!(super::pcx("a.pcx", &pcx).unwrap().data[..], [1, 1, 1, 2, 2, 2], "no palette: grey");
pcx.push(0x0C);
let mut palette = vec![0u8; 768];
palette[3..9].copy_from_slice(&[9, 8, 7, 6, 5, 4]);
pcx.extend_from_slice(&palette);
assert_eq!(super::pcx("a.pcx", &pcx).unwrap().data[..], [9, 8, 7, 6, 5, 4]);
}
#[test]
fn bit_plane_pcx_images_read_their_palette() {
// version 2, one plane: black and white
let mut pcx = pcx_header(2, 1, 1, 2, 2, 1);
pcx.extend_from_slice(&[0b1000_0000, 0]);
assert!(from_images(&[("a.pcx".into(), pcx.clone())]).is_ok(), "an old version is still a PCX");
assert_eq!(super::pcx("a.pcx", &pcx).unwrap().data[..], [255, 255, 255, 0, 0, 0]);
// two planes: colour 3 is at byte 16 + 9
let mut pcx = pcx_header(5, 1, 2, 1, 1, 1);
pcx[25..28].copy_from_slice(&[1, 2, 3]);
pcx.extend_from_slice(&[0x80, 0x80]);
assert_eq!(super::pcx("a.pcx", &pcx).unwrap().data[..], [1, 2, 3]);
}
#[test]
fn damaged_pcx_headers_and_bodies_are_refused() {
let no_planes = pcx_header(5, 8, 0, 2, 2, 1);
let no_stride = pcx_header(5, 8, 3, 0, 2, 1);
let short_stride = pcx_header(5, 8, 3, 1, 2, 1);
let short_body = pcx_header(5, 8, 3, 2, 2, 1);
for bytes in [no_planes, no_stride, short_stride, short_body] {
assert!(super::pcx("a.pcx", &bytes).is_err());
}
assert!(super::pcx("a.pcx", &[0x0A, 5, 1, 8]).is_err(), "no header");
}
/// A synthetic ICC profile: a 128-byte header (size, device class, colour space, `acsp`) and
/// some payload. Only the header is read.
fn icc_profile(space: &[u8; 4]) -> Vec<u8> {