From 9e71e041b70556af4a7bd604488aeb9641184803 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:11:01 -0400 Subject: [PATCH] Render: tiled pictures take their size from the resolution they record A tiled picture fill (including a blipFill with neither stretch nor tile, which PowerPoint tiles) assumed 96 dpi. PowerPoint sizes a picture by the resolution its file records: a 32 px tile saved at 90 dpi is 25.6 pt, not 24. The PNG pHYs chunk and JPEG JFIF density are now read (rounded to whole dpi) and scale the tile. Corpus lo/tdf128596 (tiled checkmark texture): 8.79 -> 0.52 mean diff (it had drifted from 7.36 when tiles started snapping to whole pixels). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/images.rs | 46 ++++++++++++++++++++++++++++++--- crates/render/src/tests_wild.rs | 41 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index b41c2de..4d4f96d 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -121,14 +121,54 @@ fn decode_at(bytes: &[u8], side: u32) -> Option { Some(img.to_rgba8()) } -/// A raster picture's own size in pixels, read from its header (`None` for metafiles and -/// anything unreadable). +/// A raster picture's own size in 96-dpi pixels (so 0.75 pt each), read from its header: its +/// pixel size, scaled by the resolution it records (PNG `pHYs`, JPEG JFIF density) when that +/// isn't 96 dpi, as PowerPoint sizes pictures. `None` for metafiles and anything unreadable. pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { if crate::metafile::is_metafile(bytes) { return None; } let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; - (w > 0 && h > 0).then_some((w as f64, h as f64)) + if w == 0 || h == 0 { + return None; + } + let (dx, dy) = dpi(bytes).unwrap_or((96.0, 96.0)); + Some((w as f64 * 96.0 / dx, h as f64 * 96.0 / dy)) +} + +/// The resolution (whole dots per inch across and down) a PNG or JPEG records, when it records +/// one in a plausible range. +pub fn dpi(bytes: &[u8]) -> Option<(f64, f64)> { + // Whole dots per inch: PNGs store dots per metre, so 96 dpi reads as 95.9866. + let ok = |d: f64| (d.is_finite() && (10.0..=10000.0).contains(&d)).then_some(d.round()); + let be32 = |b: &[u8], i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])); + let be16 = |b: &[u8], i: usize| b.get(i..i + 2).map(|s| u16::from_be_bytes([s[0], s[1]])); + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + let mut i = 8usize; + for _ in 0..64 { + let len = be32(bytes, i)? as usize; + let kind = bytes.get(i + 4..i + 8)?; + if kind == b"pHYs" { + let (x, y, unit) = (be32(bytes, i + 8)?, be32(bytes, i + 12)?, *bytes.get(i + 16)?); + return if unit == 1 { Some((ok(x as f64 * 0.0254)?, ok(y as f64 * 0.0254)?)) } else { None }; + } + if kind == b"IDAT" || kind == b"IEND" { + return None; + } + i = i.checked_add(12)?.checked_add(len)?; + } + return None; + } + if bytes.starts_with(&[0xFF, 0xD8]) && bytes.get(6..11) == Some(b"JFIF\0".as_slice()) { + let unit = *bytes.get(13)?; + let (x, y) = (be16(bytes, 14)? as f64, be16(bytes, 16)? as f64); + return match unit { + 1 => Some((ok(x)?, ok(y)?)), + 2 => Some((ok(x * 2.54)?, ok(y * 2.54)?)), + _ => None, + }; + } + None } /// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px. diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index a9b64d7..63d381c 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -437,3 +437,44 @@ fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() { assert_eq!(paint::tile_origin(b, "br", (20.0, 10.0), (5.0, -5.0)), Point::new(85.0, 35.0)); assert_eq!(paint::tile_origin(b, "nonsense", (20.0, 10.0), (f64::NAN, 1.0)), Point::new(0.0, 1.0)); } + +/// `png` with a pHYs chunk saying `dpi` inserted after its header. +fn with_dpi(png: &[u8], dpi: f64) -> Vec { + fn crc32(data: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for b in data { + c ^= *b as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + let ppm = (dpi / 0.0254).round() as u32; + let mut chunk = b"pHYs".to_vec(); + chunk.extend_from_slice(&ppm.to_be_bytes()); + chunk.extend_from_slice(&ppm.to_be_bytes()); + chunk.push(1); + let mut out = png[..33].to_vec(); // signature (8) + IHDR chunk (25) + out.extend_from_slice(&9u32.to_be_bytes()); + out.extend_from_slice(&chunk); + out.extend_from_slice(&crc32(&chunk).to_be_bytes()); + out.extend_from_slice(&png[33..]); + out +} + +#[test] +fn picture_resolution_sizes_tiles() { + let png = half_red_half_blue(); + assert_eq!(images::dpi(&png), None); + let p48 = with_dpi(&png, 48.0); + let d = images::dpi(&p48).unwrap_or_default(); + assert!((d.0 - 48.0).abs() < 0.01 && (d.1 - 48.0).abs() < 0.01, "{d:?}"); + // 8 × 4 px at 48 dpi is as big as 16 × 8 at 96. + assert_eq!(images::natural_size(&p48), Some((16.0, 8.0))); + assert_eq!(images::natural_size(&png), Some((8.0, 4.0))); + // JFIF density in dots per inch. + let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 16]; + jpeg.extend_from_slice(b"JFIF\0\x01\x01\x01\x00\x48\x00\x48"); + assert_eq!(images::dpi(&jpeg), Some((72.0, 72.0))); +}