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) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 06:11:01 -04:00
parent cf7599ca2c
commit 9e71e041b7
2 changed files with 84 additions and 3 deletions

View File

@ -121,14 +121,54 @@ fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
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.

View File

@ -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<u8> {
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)));
}