Merge pull request #1442 from HEOJUNFO/fix/grey-tiff-device-gray

Export placed greyscale TIFFs as DeviceGray
This commit is contained in:
Brandon Thomas
2026-10-23 17:30:03 -04:00
committed by GitHub
2 changed files with 104 additions and 3 deletions
+84 -3
View File
@@ -1629,6 +1629,21 @@ fn is_rgb_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"RGB ".as_slice())
}
/// Whether an ICC profile describes grey.
fn is_grey_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"GRAY".as_slice())
}
/// Whether `data` decodes to grey (with or without alpha), read from its header.
fn is_grey(data: &[u8]) -> bool {
use image::ImageDecoder;
image::ImageReader::new(std::io::Cursor::new(data))
.with_guessed_format()
.ok()
.and_then(|r| r.into_decoder().ok())
.is_some_and(|dec| !dec.color_type().has_color())
}
/// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets
/// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG
/// without a profile stays DeviceGray or DeviceCMYK.
@@ -1668,6 +1683,10 @@ fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, mode: RasterMo
_ => None,
};
direct.or_else(|| {
// A grey TIFF, BMP… stays DeviceGray, as a grey PNG or JPEG does (#1426).
if let Some(grey) = GreyImage::new(data) {
return Image::from_custom(grey, i).ok();
}
// TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB.
if mode.tag_rgb {
return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok());
@@ -1722,6 +1741,61 @@ impl krilla::image::CustomImage for SrgbImage {
}
}
/// A grey raster as a DeviceGray image (ICCBased when the file has a grey profile of its own), so
/// a page that only holds black prints as black, not as RGB.
#[derive(Clone, Hash)]
struct GreyImage {
grey: Arc<Vec<u8>>,
/// Only when some pixel is not opaque.
alpha: Option<Arc<Vec<u8>>>,
size: (u32, u32),
icc: Option<Arc<Vec<u8>>>,
}
impl GreyImage {
/// `None` when `data` isn't grey or can't be decoded.
fn new(data: &[u8]) -> Option<Self> {
if !is_grey(data) {
return None;
}
let img = image::load_from_memory(data).ok()?.to_luma_alpha8();
let size = img.dimensions();
let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?;
if pixels == 0 {
return None;
}
let (mut grey, mut alpha) = (Vec::with_capacity(pixels), Vec::with_capacity(pixels));
for p in img.pixels() {
grey.push(p.0[0]);
alpha.push(p.0[1]);
}
let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha));
let icc = embedded_icc(data).filter(|p| is_grey_icc(p)).map(Arc::new);
Some(Self { grey: Arc::new(grey), alpha, size, icc })
}
}
impl krilla::image::CustomImage for GreyImage {
fn color_channel(&self) -> &[u8] {
&self.grey
}
fn alpha_channel(&self) -> Option<&[u8]> {
self.alpha.as_deref().map(Vec::as_slice)
}
fn bits_per_component(&self) -> krilla::image::BitsPerComponent {
krilla::image::BitsPerComponent::Eight
}
fn size(&self) -> (u32, u32) {
self.size
}
fn icc_profile(&self) -> Option<&[u8]> {
self.icc.as_deref().map(Vec::as_slice)
}
fn color_space(&self) -> krilla::image::ImageColorspace {
krilla::image::ImageColorspace::Luma
}
}
/// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a
/// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for
/// the output intent's press, and tagging them with another CMYK profile would let a RIP
@@ -1768,11 +1842,18 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
}
let rgb = img.to_rgb8();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode)
// A grey image stays a grey JPEG (#1426).
let grey = !img.color().has_color();
if grey {
let l = img.to_luma8();
image::ImageEncoder::write_image(enc, l.as_raw(), l.width(), l.height(), image::ExtendedColorType::L8).ok()?;
} else {
let rgb = img.to_rgb8();
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
}
jpeg(buf.into(), || embedded_icc(data).filter(|p| if grey { is_grey_icc(p) } else { is_rgb_icc(p) }), mode)
}
#[cfg(test)]
+20
View File
@@ -340,6 +340,26 @@ fn cmyk_tiff_keeps_its_inks() {
}
}
/// A placed greyscale TIFF went into PDFs as DeviceRGB, unlike a greyscale PNG or JPEG (#1426):
/// a page holding only a black-and-white scan declared colour.
#[test]
fn grey_tiff_exports_as_device_gray() {
let px: Vec<u8> = vec![0, 64, 128, 255, 255, 128, 64, 0];
let mut tif = Vec::new();
tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut tif)).unwrap().write_image::<tiff::encoder::colortype::Gray8>(4, 2, &px).unwrap();
let mut d = doc_with_text("x");
place(&mut d, tif, (4, 2));
for standard in [Standard::None, Standard::PdfX4] {
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings);
assert_eq!(image_xobjects(&r.bytes), vec![("DeviceGray".to_string(), px.clone())], "{standard:?}");
}
// Recompressed, it stays a grey JPEG.
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images: true, compress: false, ..Default::default() }).unwrap();
let s = String::from_utf8_lossy(&r.bytes);
assert!(s.contains("/DCTDecode") && s.contains("/DeviceGray") && !s.contains("/DeviceRGB"), "a grey JPEG");
}
/// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner.
fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec<u8> {
let img =