PNG export: Indexed Color keeps the document resolution (#3047) (#3271)

The palette PNG encoder never wrote a pHYs chunk, so an Indexed Color
document saved as PNG reopened at 72 ppi while the RGB path kept its
resolution. encode_png_indexed now takes an optional dpi and the flat
exporter passes the document resolution under the same embed_metadata
rule as the ordinary PNG encoder. Save for Web PNG-8 is unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 20:12:44 -04:00
1 parent 872d3df2c5
commit c450e755ae
5 files changed
+40 -9

No files matched your search

+22 -5
View File
@@ -521,8 +521,16 @@ fn adler32_combine(a1: u32, a2: u32, len2: usize) -> u32 {
}
/// Writes an 8-bit palette PNG (PNG-8): `indices` row-major, `palette` RGB entries (≤ 256),
/// optional fully transparent entry (`tRNS`). Used for Indexed Color documents.
pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]], transparent: Option<u8>) -> Result<Vec<u8>, CodecError> {
/// optional fully transparent entry (`tRNS`) and optional resolution in dpi (`pHYs`, written when
/// both axes are positive and finite). Used for Indexed Color documents.
pub fn encode_indexed(
width: u32,
height: u32,
indices: &[u8],
palette: &[[u8; 3]],
transparent: Option<u8>,
dpi: Option<(f32, f32)>,
) -> Result<Vec<u8>, CodecError> {
if width == 0 || height == 0 || indices.len() != width as usize * height as usize {
return Err(CodecError::InvalidImage("index data does not match the size".into()));
}
@@ -538,6 +546,15 @@ pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3
trns[usize::from(t)] = 0;
info.trns = Some(trns.into());
}
if let Some((x, y)) = dpi
&& x.is_finite()
&& y.is_finite()
&& x > 0.0
&& y > 0.0
{
info.pixel_dims =
Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter });
}
let mut out = Vec::new();
{
let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
@@ -556,14 +573,14 @@ mod indexed_tests {
fn png8_roundtrips_through_the_decoder() {
let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]];
let idx = [0u8, 1, 2, 1, 0, 2];
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2)).unwrap();
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
let img = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(img.dimensions(), (3, 2));
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
assert_eq!(px.data()[11], 0, "transparent entry");
assert!(encode_indexed(3, 2, &[9; 6], &pal, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None).is_err());
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
}
}
+1 -1
View File
@@ -490,7 +490,7 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O
let bytes = if st.format == WebFormat::Gif {
photocraft_codecs::web::encode_gif_indexed(ww, hh, &idx, &pal, tt, st.interlaced).map_err(e)?
} else {
photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt).map_err(e)?
photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None).map_err(e)?
};
(bytes, Some(pal.len()))
}
+3 -1
View File
@@ -664,7 +664,9 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) ->
}));
Ok(())
});
let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent)?;
// Same rule as the ordinary PNG encoder: the resolution goes into `pHYs` with the metadata.
let dpi = opts.encode.embed_metadata.then_some((doc.resolution_dpi, doc.resolution_dpi));
let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi)?;
let mut warnings = vec![format!("written as an 8-bit palette PNG ({} colours)", table.colors.len())];
if opts.encode.embed_metadata && opts.xmp == XmpEmbed::All && !doc.metadata.text.is_empty() {
warnings.push("text metadata is not supported by the palette PNG exporter; it will be dropped".into());
+2 -2
View File
@@ -313,8 +313,8 @@ fn transparent_flat_images_open_as_layer_0() {
("ga.png", enc(ChannelLayout::GrayA, CS::U8, Format::Png), true),
("a.tif", enc(ChannelLayout::Rgba, CS::U8, Format::Tiff), true),
("ga.tif", enc(ChannelLayout::GrayA, CS::U16, Format::Tiff), true),
("pal.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], Some(0)).unwrap(), true),
("pal_opaque.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], None).unwrap(), false),
("pal.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], Some(0), None).unwrap(), true),
("pal_opaque.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], None, None).unwrap(), false),
("rgb.png", enc(ChannelLayout::Rgb, CS::U8, Format::Png), false),
("g.png", enc(ChannelLayout::Gray, CS::U16, Format::Png), false),
("rgb.tif", enc(ChannelLayout::Rgb, CS::U8, Format::Tiff), false),
+12
View File
@@ -37,3 +37,15 @@ fn duotone_exports_the_inks_as_rgb() {
let px = back.layers[0].surface().unwrap().rgba(0, 0);
assert!(px[0] > px[2] + 0.05, "warm: {px:?}");
}
#[test]
fn indexed_png_keeps_the_document_resolution() {
// #3047: the palette PNG lost its pHYs chunk and reopened at 72 ppi.
let mut d = doc(ColorMode::Indexed);
d.resolution_dpi = 300.0;
d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0]], transparent: None });
let r = photocraft_io::export(&d, "x.png", &Default::default()).unwrap();
assert_eq!(r.bytes[25], 3, "still a palette PNG");
let back = photocraft_io::import("x.png", &r.bytes).unwrap().document;
assert!((back.resolution_dpi - 300.0).abs() < 0.01, "{}", back.resolution_dpi);
}