Add PNG DPI support via pHYs chunk and fix font picker

PNG files now embed pHYs chunk for DPI when output_dpi is set, matching
the existing JPEG DPI support. Also fixed FontDialogButton signal handler
to properly unwrap the Option<FontDescription>.
This commit is contained in:
2026-03-06 17:16:43 +02:00
parent d9ce1f8731
commit fbb9cddbb8
2 changed files with 88 additions and 3 deletions

View File

@@ -129,6 +129,11 @@ impl OutputEncoder {
reason: e.to_string(), reason: e.to_string(),
})?; })?;
// Insert pHYs chunk for DPI if requested
if self.options.output_dpi > 0 {
buf = insert_png_phys_chunk(&buf, self.options.output_dpi);
}
let optimized = oxipng::optimize_from_memory(&buf, &oxipng::Options::default()) let optimized = oxipng::optimize_from_memory(&buf, &oxipng::Options::default())
.map_err(|e| PixstripError::Processing { .map_err(|e| PixstripError::Processing {
operation: "png_optimize".into(), operation: "png_optimize".into(),
@@ -186,6 +191,85 @@ impl OutputEncoder {
} }
} }
/// Insert a pHYs chunk into PNG data to set DPI.
/// The pHYs chunk must appear before the first IDAT chunk.
/// DPI is converted to pixels per meter (1 inch = 0.0254 meters).
fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec<u8> {
// PNG pixels per meter = DPI / 0.0254
let ppm = (dpi as f64 / 0.0254).round() as u32;
// Build the pHYs chunk:
// 4 bytes: data length (9)
// 4 bytes: chunk type "pHYs"
// 4 bytes: pixels per unit X
// 4 bytes: pixels per unit Y
// 1 byte: unit (1 = meter)
// 4 bytes: CRC32 of type + data
let mut chunk_data = Vec::with_capacity(9);
chunk_data.extend_from_slice(&ppm.to_be_bytes()); // X pixels per unit
chunk_data.extend_from_slice(&ppm.to_be_bytes()); // Y pixels per unit
chunk_data.push(1); // unit = meter
let mut crc_input = Vec::with_capacity(13);
crc_input.extend_from_slice(b"pHYs");
crc_input.extend_from_slice(&chunk_data);
let crc = crc32_png(&crc_input);
let mut phys_chunk = Vec::with_capacity(21);
phys_chunk.extend_from_slice(&9u32.to_be_bytes()); // length
phys_chunk.extend_from_slice(b"pHYs"); // type
phys_chunk.extend_from_slice(&chunk_data); // data
phys_chunk.extend_from_slice(&crc.to_be_bytes()); // CRC
// Find the first IDAT chunk and insert pHYs before it.
// PNG structure: 8-byte signature, then chunks (each: 4 len + 4 type + data + 4 crc)
let mut result = Vec::with_capacity(png_data.len() + phys_chunk.len());
let mut pos = 8; // skip PNG signature
result.extend_from_slice(&png_data[..8]);
while pos + 8 <= png_data.len() {
let chunk_len = u32::from_be_bytes([
png_data[pos], png_data[pos + 1], png_data[pos + 2], png_data[pos + 3],
]) as usize;
let chunk_type = &png_data[pos + 4..pos + 8];
let total_chunk_size = 4 + 4 + chunk_len + 4; // len + type + data + crc
if chunk_type == b"IDAT" || chunk_type == b"pHYs" {
if chunk_type == b"IDAT" {
// Insert pHYs before first IDAT
result.extend_from_slice(&phys_chunk);
}
// If existing pHYs, skip it (we're replacing it)
if chunk_type == b"pHYs" {
pos += total_chunk_size;
continue;
}
}
result.extend_from_slice(&png_data[pos..pos + total_chunk_size]);
pos += total_chunk_size;
}
result
}
/// Simple CRC32 for PNG chunks (uses the standard PNG CRC polynomial)
fn crc32_png(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
}
}
crc ^ 0xFFFF_FFFF
}
impl Default for OutputEncoder { impl Default for OutputEncoder {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()

View File

@@ -443,9 +443,10 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
font_button.connect_font_desc_notify(move |btn| { font_button.connect_font_desc_notify(move |btn| {
let desc = btn.font_desc(); if let Some(desc) = btn.font_desc() {
if let Some(family) = desc.family() { if let Some(family) = desc.family() {
jc.borrow_mut().watermark_font_family = family.to_string(); jc.borrow_mut().watermark_font_family = family.to_string();
}
} }
}); });
} }