SVG import: a raster stored again as PNG keeps its resolution

A raster that SVG import decodes and stores again (a TIFF, a BMP or any
other format that is not PNG, JPEG, GIF or WebP, or an image whose EXIF
or TIFF orientation turns or flips it) was written as a PNG with no pHYs
chunk, and a turned or planar CMYK TIFF was written without resolution
tags, so the image lost the resolution its file declares.

The converted PNG now carries the source's resolution in a pHYs chunk,
and the CMYK TIFF in its resolution tags, as Open and Place store them.
A quarter turn (orientations 5 to 8) swaps the horizontal and vertical
values. An image that declares no resolution is stored without one, and
an image stored unchanged keeps its own bytes.

The PNG, JPEG, TIFF, WebP and BMP resolution readers and the pHYs writer
move from the engine to vectorcraft-doc, so the SVG importer can use
them; the engine and renderer entry points forward to them unchanged.
This commit is contained in:
dotCooCoo
2026-11-14 02:40:45 -08:00
parent edb228ac33
commit e7feeb311d
7 changed files with 358 additions and 167 deletions
+1
View File
@@ -55,6 +55,7 @@ mod pixels;
pub use pixels::{GraySource, MAX_RASTER_ALLOC, PixelPick};
pub mod placed_document;
pub mod plugin_data;
pub mod ppi;
pub mod profiles;
pub mod puppet;
pub mod range;
+243
View File
@@ -0,0 +1,243 @@
//! Image resolution metadata (pixels per inch) in the formats the image decoder reads: PNG `pHYs`,
//! JPEG JFIF density or EXIF, TIFF and WebP EXIF resolution tags, and BMP pixels per metre. An image
//! converted to PNG keeps it as a `pHYs` chunk ([`with_png_resolution`]).
const INCH_M: f64 = 0.0254;
const INCH_CM: f64 = 2.54;
/// Is `bytes` a PNG, JPEG, TIFF, WebP or BMP file, the formats [`resolution`] reads?
pub fn reads(bytes: &[u8]) -> bool {
bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|| bytes.starts_with(&[0xff, 0xd8])
|| bytes.starts_with(b"II*\0")
|| bytes.starts_with(b"MM\0*")
|| (bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP")
|| bytes.starts_with(b"BM")
}
/// The resolution a PNG, JPEG, TIFF, WebP or BMP file declares as (horizontal, vertical) ppi, each
/// from 1 to 100,000; `None` when it declares none (or only an aspect ratio), and for other files.
pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
let r = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
png(bytes)
} else if bytes.starts_with(&[0xff, 0xd8]) {
jpeg(bytes)
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
tiff(bytes)
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
webp(bytes)
} else if bytes.starts_with(b"BM") {
bmp(bytes)
} else {
None
};
r.filter(|(x, y)| [*x, *y].iter().all(|v| v.is_finite() && (1.0..=100_000.0).contains(v)))
}
/// `n` bytes at `at` (`None` past the end; offsets come from the file, so no overflow either).
fn slice(b: &[u8], at: usize, n: usize) -> Option<&[u8]> {
b.get(at..at.checked_add(n)?)
}
fn bytes<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
slice(b, at, N)?.try_into().ok()
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
bytes(b, at).map(u16::from_be_bytes)
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
bytes(b, at).map(u32::from_be_bytes)
}
fn le32(b: &[u8], at: usize) -> Option<u32> {
bytes(b, at).map(u32::from_le_bytes)
}
/// Pixels per inch from the whole pixels per metre PNG and BMP store: a whole number of ppi when
/// the metre value is that rounded (300 ppi is stored as 11811 per metre, 299.9994 ppi).
fn per_metre(ppm: u32) -> f64 {
let v = ppm as f64 * INCH_M;
let whole = v.round();
if (v - whole).abs() <= INCH_M / 2.0 + 1e-9 { whole } else { v }
}
/// PNG chunks after the signature: (type, data).
pub fn png_chunks(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
let mut at = 8;
std::iter::from_fn(move || {
let len = be32(b, at)? as usize;
let ty = slice(b, at + 4, 4)?;
let data = slice(b, at + 8, len)?;
at = at.checked_add(12 + len)?;
Some((ty, data))
})
}
fn png(b: &[u8]) -> Option<(f64, f64)> {
let (_, d) = png_chunks(b).take_while(|(ty, _)| *ty != b"IDAT").find(|(ty, _)| *ty == b"pHYs")?;
// Unit 1 = metre; 0 only gives the pixel aspect ratio.
(d.get(8) == Some(&1)).then(|| (per_metre(be32(d, 0).unwrap_or(0)), per_metre(be32(d, 4).unwrap_or(0))))
}
fn jpeg(b: &[u8]) -> Option<(f64, f64)> {
let (mut jfif, mut exif) = (None, None);
let mut at = 2;
while at + 4 <= b.len() && b[at] == 0xff {
let marker = b[at + 1];
// 0xff fill bytes pad between segments.
if marker == 0xff {
at += 1;
continue;
}
if marker == 0xd8 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
at += 2;
continue;
}
// Start of scan or end of image: no more metadata.
if marker == 0xda || marker == 0xd9 {
break;
}
let len = be16(b, at + 2)? as usize;
let seg = slice(b, at + 4, len.checked_sub(2)?)?;
match marker {
0xe0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => {
let (x, y) = (be16(seg, 8)? as f64, be16(seg, 10)? as f64);
jfif = match seg[7] {
1 => Some((x, y)),
2 => Some((x * INCH_CM, y * INCH_CM)),
_ => None,
};
}
0xe1 if seg.starts_with(b"Exif\0\0") => exif = tiff(&seg[6..]),
_ => {}
}
at += 2 + len;
}
jfif.or(exif)
}
/// TIFF (or EXIF) IFD0 XResolution / YResolution / ResolutionUnit, as (horizontal, vertical) ppi
/// in the stored pixels' axes, unchecked; `None` without an absolute unit.
pub fn tiff(b: &[u8]) -> Option<(f64, f64)> {
let le = b.starts_with(b"II");
let u16_at = |at: usize| bytes(b, at).map(if le { u16::from_le_bytes } else { u16::from_be_bytes });
let u32_at = |at: usize| bytes(b, at).map(if le { u32::from_le_bytes } else { u32::from_be_bytes });
let ifd = u32_at(4)? as usize;
let (mut x, mut y, mut unit) = (None, None, 2);
for i in 0..u16_at(ifd)? as usize {
let e = ifd.checked_add(2 + i * 12)?;
let rational = || -> Option<f64> {
let at = u32_at(e + 8)? as usize;
let (n, d) = (u32_at(at)?, u32_at(at.checked_add(4)?)?);
(d != 0).then(|| n as f64 / d as f64)
};
match u16_at(e)? {
282 => x = rational(),
283 => y = rational(),
296 => unit = u16_at(e + 8)?,
_ => {}
}
}
let k = match unit {
2 => 1.0,
3 => INCH_CM,
_ => return None,
};
let x = x?;
Some((x * k, y.unwrap_or(x) * k))
}
fn webp(b: &[u8]) -> Option<(f64, f64)> {
let mut at = 12;
while at + 8 <= b.len() {
let len = le32(b, at + 4)? as usize;
let data = b.get(at + 8..at + 8 + len)?;
if &b[at..at + 4] == b"EXIF" {
return tiff(data.strip_prefix(b"Exif\0\0").unwrap_or(data));
}
at += 8 + len + (len & 1);
}
None
}
fn bmp(b: &[u8]) -> Option<(f64, f64)> {
// BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28.
if le32(b, 14)? < 40 {
return None;
}
let ppm = |at| le32(b, at).filter(|v| (1..=i32::MAX as u32).contains(v)).map(per_metre);
Some((ppm(38)?, ppm(42)?))
}
/// Pixels per metre for a resolution in pixels per inch (the unit `pHYs` stores).
pub fn pixels_per_metre(ppi: f64) -> u32 {
(ppi / INCH_M).round().clamp(1.0, u32::MAX as f64) as u32
}
/// PNG CRC-32 (the polynomial ZIP uses) of `parts` read one after another.
fn crc32(parts: &[&[u8]]) -> u32 {
let mut crc = !0u32;
for &b in parts.iter().flat_map(|p| p.iter()) {
crc ^= u32::from(b);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xEDB8_8320 & u32::from(crc & 1 != 0).wrapping_neg());
}
}
!crc
}
/// A whole `pHYs` chunk (length, type, data, CRC) declaring `x` × `y` pixels per inch.
pub fn phys_chunk(x: f64, y: f64) -> Vec<u8> {
let [x, y] = [x, y].map(|v| pixels_per_metre(v).to_be_bytes());
// x, y, unit 1 = metre.
let data = [x.as_slice(), y.as_slice(), &[1]].concat();
let mut out = Vec::with_capacity(21);
out.extend_from_slice(&9u32.to_be_bytes());
out.extend_from_slice(b"pHYs");
out.extend_from_slice(&data);
out.extend_from_slice(&crc32(&[b"pHYs", &data]).to_be_bytes());
out
}
/// `png` with a `pHYs` chunk declaring `ppi` right after the header (any existing one is replaced).
pub fn with_png_resolution(png: &[u8], (x, y): (f64, f64)) -> Vec<u8> {
let Some(head) = be32(png, 8).and_then(|len| slice(png, 0, 20usize.checked_add(len as usize)?)) else { return png.to_vec() };
let chunk = phys_chunk(x, y);
let mut out = Vec::with_capacity(png.len() + chunk.len());
out.extend_from_slice(head);
out.extend_from_slice(&chunk);
let mut at = head.len();
while let Some(chunk) = be32(png, at).and_then(|len| slice(png, at, 12usize.checked_add(len as usize)?)) {
if chunk.get(4..8) != Some(b"pHYs") {
out.extend_from_slice(chunk);
}
at += chunk.len();
}
out.extend_from_slice(png.get(at..).unwrap_or_default());
out
}
#[cfg(test)]
mod tests {
use super::*;
fn png_of(w: u32, h: u32) -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::new(w, h).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// The `pHYs` chunk carries the CRC a PNG reader checks, and replaces the one already there.
#[test]
fn a_png_takes_its_resolution_and_still_decodes() {
let png = png_of(4, 3);
assert_eq!(resolution(&png), None);
let tagged = with_png_resolution(&png, (300.0, 150.0));
assert_eq!(resolution(&tagged), Some((300.0, 150.0)));
let again = with_png_resolution(&tagged, (72.0, 72.0));
assert_eq!(resolution(&again), Some((72.0, 72.0)), "replaced, not duplicated");
assert_eq!(again.len(), tagged.len());
let img = image::load_from_memory(&again).unwrap();
assert_eq!((img.width(), img.height()), (4, 3));
// Every PNG ends with this IEND chunk CRC.
assert_eq!(crc32(&[b"IEND"]), 0xAE42_6082);
}
}
+9 -152
View File
@@ -3,26 +3,22 @@
//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF
//! resolution tags. Place
//! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it
//! as a `pHYs` chunk.
//! as a `pHYs` chunk. The formats the image decoder reads are read by [`vectorcraft_doc::ppi`].
pub(super) use vectorcraft_doc::ppi::png_chunks;
use vectorcraft_doc::ppi::tiff;
pub use vectorcraft_doc::ppi::with_png_resolution;
const INCH_M: f64 = 0.0254;
const INCH_CM: f64 = 2.54;
/// The resolution a file declares as (horizontal, vertical) ppi; `None` when it declares none (or
/// only an aspect ratio).
pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
let r = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
png(bytes)
} else if bytes.starts_with(&[0xff, 0xd8]) {
jpeg(bytes)
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
tiff(bytes)
} else if super::jxlread::is_jxl(bytes) {
if vectorcraft_doc::ppi::reads(bytes) {
return vectorcraft_doc::ppi::resolution(bytes);
}
let r = if super::jxlread::is_jxl(bytes) {
jxl(bytes)
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
webp(bytes)
} else if bytes.starts_with(b"BM") {
bmp(bytes)
} else if super::psdread::is_psd(bytes) {
super::psdread::resolution(bytes)
} else if super::pcxread::is_pcx(bytes) {
@@ -82,121 +78,9 @@ fn slice(b: &[u8], at: usize, n: usize) -> Option<&[u8]> {
fn bytes<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
slice(b, at, N)?.try_into().ok()
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
bytes(b, at).map(u16::from_be_bytes)
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
bytes(b, at).map(u32::from_be_bytes)
}
fn le32(b: &[u8], at: usize) -> Option<u32> {
bytes(b, at).map(u32::from_le_bytes)
}
/// Pixels per inch from the whole pixels per metre PNG and BMP store: a whole number of ppi when
/// the metre value is that rounded (300 ppi is stored as 11811 per metre, 299.9994 ppi).
fn per_metre(ppm: u32) -> f64 {
let v = ppm as f64 * INCH_M;
let whole = v.round();
if (v - whole).abs() <= INCH_M / 2.0 + 1e-9 { whole } else { v }
}
/// PNG chunks after the signature: (type, data).
pub(super) fn png_chunks(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
let mut at = 8;
std::iter::from_fn(move || {
let len = be32(b, at)? as usize;
let ty = slice(b, at + 4, 4)?;
let data = slice(b, at + 8, len)?;
at = at.checked_add(12 + len)?;
Some((ty, data))
})
}
fn png(b: &[u8]) -> Option<(f64, f64)> {
let (_, d) = png_chunks(b).take_while(|(ty, _)| *ty != b"IDAT").find(|(ty, _)| *ty == b"pHYs")?;
// Unit 1 = metre; 0 only gives the pixel aspect ratio.
(d.get(8) == Some(&1)).then(|| (per_metre(be32(d, 0).unwrap_or(0)), per_metre(be32(d, 4).unwrap_or(0))))
}
fn jpeg(b: &[u8]) -> Option<(f64, f64)> {
let (mut jfif, mut exif) = (None, None);
let mut at = 2;
while at + 4 <= b.len() && b[at] == 0xff {
let marker = b[at + 1];
// 0xff fill bytes pad between segments.
if marker == 0xff {
at += 1;
continue;
}
if marker == 0xd8 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
at += 2;
continue;
}
// Start of scan or end of image: no more metadata.
if marker == 0xda || marker == 0xd9 {
break;
}
let len = be16(b, at + 2)? as usize;
let seg = slice(b, at + 4, len.checked_sub(2)?)?;
match marker {
0xe0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => {
let (x, y) = (be16(seg, 8)? as f64, be16(seg, 10)? as f64);
jfif = match seg[7] {
1 => Some((x, y)),
2 => Some((x * INCH_CM, y * INCH_CM)),
_ => None,
};
}
0xe1 if seg.starts_with(b"Exif\0\0") => exif = tiff(&seg[6..]),
_ => {}
}
at += 2 + len;
}
jfif.or(exif)
}
/// TIFF (or EXIF) IFD0 XResolution / YResolution / ResolutionUnit.
fn tiff(b: &[u8]) -> Option<(f64, f64)> {
let le = b.starts_with(b"II");
let u16_at = |at: usize| bytes(b, at).map(if le { u16::from_le_bytes } else { u16::from_be_bytes });
let u32_at = |at: usize| bytes(b, at).map(if le { u32::from_le_bytes } else { u32::from_be_bytes });
let ifd = u32_at(4)? as usize;
let (mut x, mut y, mut unit) = (None, None, 2);
for i in 0..u16_at(ifd)? as usize {
let e = ifd.checked_add(2 + i * 12)?;
let rational = || -> Option<f64> {
let at = u32_at(e + 8)? as usize;
let (n, d) = (u32_at(at)?, u32_at(at.checked_add(4)?)?);
(d != 0).then(|| n as f64 / d as f64)
};
match u16_at(e)? {
282 => x = rational(),
283 => y = rational(),
296 => unit = u16_at(e + 8)?,
_ => {}
}
}
let k = match unit {
2 => 1.0,
3 => INCH_CM,
_ => return None,
};
let x = x?;
Some((x * k, y.unwrap_or(x) * k))
}
fn webp(b: &[u8]) -> Option<(f64, f64)> {
let mut at = 12;
while at + 8 <= b.len() {
let len = le32(b, at + 4)? as usize;
let data = b.get(at + 8..at + 8 + len)?;
if &b[at..at + 4] == b"EXIF" {
return tiff(data.strip_prefix(b"Exif\0\0").unwrap_or(data));
}
at += 8 + len + (len & 1);
}
None
}
fn jxl(b: &[u8]) -> Option<(f64, f64)> {
if !b.starts_with(&[0, 0, 0, 12, b'J', b'X', b'L', b' ', 13, 10, 135, 10]) {
@@ -224,33 +108,6 @@ fn jxl(b: &[u8]) -> Option<(f64, f64)> {
None
}
fn bmp(b: &[u8]) -> Option<(f64, f64)> {
// BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28.
if le32(b, 14)? < 40 {
return None;
}
let ppm = |at| le32(b, at).filter(|v| (1..=i32::MAX as u32).contains(v)).map(per_metre);
Some((ppm(38)?, ppm(42)?))
}
/// `png` with a `pHYs` chunk declaring `ppi` right after the header (any existing one is replaced).
pub fn with_png_resolution(png: &[u8], (x, y): (f64, f64)) -> Vec<u8> {
let Some(head) = be32(png, 8).and_then(|len| slice(png, 0, 20usize.checked_add(len as usize)?)) else { return png.to_vec() };
let chunk = vectorcraft_render::encode::png::phys_chunk(x, y);
let mut out = Vec::with_capacity(png.len() + chunk.len());
out.extend_from_slice(head);
out.extend_from_slice(&chunk);
let mut at = head.len();
while let Some(chunk) = be32(png, at).and_then(|len| slice(png, at, 12usize.checked_add(len as usize)?)) {
if chunk.get(4..8) != Some(b"pHYs") {
out.extend_from_slice(chunk);
}
at += chunk.len();
}
out.extend_from_slice(png.get(at..).unwrap_or_default());
out
}
#[cfg(test)]
mod tests {
use super::*;
+2 -6
View File
@@ -28,16 +28,12 @@ const ADAM7: [(u32, u32, u32, u32); 7] = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4,
/// Pixels per metre for a resolution in pixels per inch (the unit `pHYs` stores).
pub fn pixels_per_metre(ppi: f64) -> u32 {
(ppi / 0.0254).round().clamp(1.0, u32::MAX as f64) as u32
vectorcraft_doc::ppi::pixels_per_metre(ppi)
}
/// A whole `pHYs` chunk (length, type, data, CRC) declaring `x` × `y` pixels per inch.
pub fn phys_chunk(x: f64, y: f64) -> Vec<u8> {
let [x, y] = [x, y].map(|v| pixels_per_metre(v).to_be_bytes());
let mut out = Vec::with_capacity(21);
// x, y, unit 1 = metre.
chunk(&mut out, b"pHYs", &[x.as_slice(), y.as_slice(), &[1]].concat());
out
vectorcraft_doc::ppi::phys_chunk(x, y)
}
/// Encode straight-alpha RGBA8 pixels (`width`×`height`, row-major) as a PNG file.
+14 -6
View File
@@ -390,10 +390,11 @@ fn raster(format: image::ImageFormat) -> Option<RasterKind> {
/// orientation ([`vectorcraft_doc::cmyk::exif_orientation`], read for PNG, JPEG, GIF, WebP and
/// TIFF) leaves the pixels as stored. Otherwise the pixels are read ([`decoded`],
/// [`tiff_pixels`]), turned or flipped as the orientation says, and written as PNG, so usvg sizes
/// and fits the image as it is shown. A CMYK TIFF also keeps its inks as a CMYK TIFF
/// ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]), and a CMYK JPEG or TIFF that is turned keeps them
/// turned ([`vectorcraft_doc::cmyk::turned_cmyk`]). `None` when the pixels can't be read, and for
/// a TIFF with more strips or tiles than the decoder can count
/// and fits the image as it is shown, with the resolution the file declares
/// ([`vectorcraft_doc::ppi::resolution`]), its axes swapped by a quarter turn. A CMYK TIFF also
/// keeps its inks as a CMYK TIFF ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]), and a CMYK JPEG
/// or TIFF that is turned keeps them turned ([`vectorcraft_doc::cmyk::turned_cmyk`]). `None` when
/// the pixels can't be read, and for a TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]), before any decoder opens it.
fn shown(bytes: Arc<Vec<u8>>, format: image::ImageFormat) -> Option<Shown> {
use image::ImageFormat as F;
@@ -409,9 +410,13 @@ fn shown(bytes: Arc<Vec<u8>>, format: image::ImageFormat) -> Option<Shown> {
}
let tiff = image::guess_format(&bytes).ok() == Some(F::Tiff);
let mut img = if tiff { tiff_pixels(&bytes)? } else { decoded(&bytes, format)? };
let ppi = vectorcraft_doc::ppi::resolution(&bytes);
// The resolution describes the stored pixels; a quarter turn swaps its axes.
let quarter = orientation.is_some_and(|o| o.to_exif() >= 5);
let shown_ppi = ppi.map(|(x, y)| if quarter { (y, x) } else { (x, y) });
let cmyk = match orientation {
Some(o) if matches!(format, F::Jpeg | F::Tiff) => vectorcraft_doc::cmyk::turned_cmyk(&bytes, o, None).map(|(tiff, _)| tiff),
None if tiff => vectorcraft_doc::cmyk::stored_cmyk_tiff(&bytes, None),
Some(o) if matches!(format, F::Jpeg | F::Tiff) => vectorcraft_doc::cmyk::turned_cmyk(&bytes, o, shown_ppi).map(|(tiff, _)| tiff),
None if tiff => vectorcraft_doc::cmyk::stored_cmyk_tiff(&bytes, ppi),
_ => None,
};
if let Some(orientation) = orientation {
@@ -419,6 +424,9 @@ fn shown(bytes: Arc<Vec<u8>>, format: image::ImageFormat) -> Option<Shown> {
}
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), F::Png).ok()?;
if let Some(ppi) = shown_ppi {
png = vectorcraft_doc::ppi::with_png_resolution(&png, ppi);
}
Some(Shown { kind: ImageKind::PNG, bytes: Arc::new(png), cmyk })
}
+88 -3
View File
@@ -69,13 +69,21 @@ fn jpeg(orientation: Option<u16>) -> Vec<u8> {
/// an Orientation tag (274) of `orientation`. Its RowsPerStrip (278) value is at byte [`ROWS_AT`]
/// and its PlanarConfiguration (284, 1) value at [`PLANAR_AT`].
fn tiff(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16) -> Vec<u8> {
let entries: u32 = if samples == 2 { 12 } else { 11 };
tiff_at(w, h, samples, data, orientation, None)
}
/// [`tiff`], declaring `ppi` (horizontal, vertical pixels per inch) when given, with XResolution
/// (282), YResolution (283) and ResolutionUnit (296, inches).
fn tiff_at(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16, ppi: Option<(u32, u32)>) -> Vec<u8> {
let entries = 11 + u32::from(samples == 2) + 3 * u32::from(ppi.is_some());
let ifd_end = 8 + 2 + entries * 12 + 4;
let bits_at = ifd_end;
let data_at = bits_at + if samples > 2 { 2 * u32::from(samples) } else { 0 };
let ppi_at = bits_at + if samples > 2 { 2 * u32::from(samples) } else { 0 };
let data_at = ppi_at + if ppi.is_some() { 16 } else { 0 };
let entry = |tag: u16, kind: u16, count: u32, value: u32| {
[&tag.to_le_bytes()[..], &kind.to_le_bytes(), &count.to_le_bytes(), &value.to_le_bytes()].concat()
};
let resolution = |tags: [(u16, u16, u32); 2]| ppi.map(|_| tags.map(|(tag, kind, value)| entry(tag, kind, 1, value))).into_iter().flatten();
// Two SHORTs fit in the entry; more are written after the directory.
let bits = match samples {
1 => 8,
@@ -97,9 +105,11 @@ fn tiff(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16) -> Vec<u8>
entry(277, 3, 1, u32::from(samples)),
entry(278, 4, 1, h),
entry(279, 4, 1, u32::try_from(data.len()).unwrap()),
entry(284, 3, 1, 1),
]
.into_iter()
.chain(resolution([(282, 5, ppi_at), (283, 5, ppi_at + 8)]))
.chain([entry(284, 3, 1, 1)])
.chain(ppi.map(|_| entry(296, 3, 1, 2)))
.chain(extra)
{
out.extend(e);
@@ -110,6 +120,11 @@ fn tiff(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16) -> Vec<u8>
out.extend(8u16.to_le_bytes());
}
}
if let Some((x, y)) = ppi {
for v in [x, 1, y, 1] {
out.extend(v.to_le_bytes());
}
}
assert_eq!(out.len(), data_at as usize);
out.extend(data);
out
@@ -276,3 +291,73 @@ fn a_turned_jpeg_in_an_svg_image_imports_upright() {
let px = pixels(&d, &im);
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)));
}
/// [`jpeg`] with its JFIF segment declaring `x` × `y` pixels per inch.
fn jpeg_at(orientation: Option<u16>, (x, y): (u16, u16)) -> Vec<u8> {
let mut jpg = jpeg(orientation);
let jfif = jpg.windows(5).position(|w| w == b"JFIF\0").unwrap();
jpg[jfif + 7] = 1;
jpg[jfif + 8..jfif + 10].copy_from_slice(&x.to_be_bytes());
jpg[jfif + 10..jfif + 12].copy_from_slice(&y.to_be_bytes());
jpg
}
/// The resolution the stored image of `im` declares.
fn stored_ppi(d: &Document, im: &ImageObject) -> Option<(f64, f64)> {
vectorcraft_doc::ppi::resolution(&d.images[&im.key].bytes)
}
/// A raster that import writes again as PNG keeps the resolution its file declares, linked, as a
/// data URL and inside an SVG shown as an image: its axes swapped when the image turns a quarter,
/// in their order when it turns a half or is flipped.
#[test]
fn a_turned_raster_keeps_its_resolution() {
let dir = folder("ppi");
for (orientation, ppi) in [(6, (150.0, 300.0)), (8, (150.0, 300.0)), (3, (300.0, 150.0)), (2, (300.0, 150.0))] {
let jpg = jpeg_at(Some(orientation), (300, 150));
std::fs::write(dir.join("photo.jpg"), &jpg).unwrap();
let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg));
let inner = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><image href="{data}" width="80" height="80"/></svg>"#);
let nested = format!("data:image/svg+xml;base64,{}", base64_encode(inner.as_bytes()));
for href in ["photo.jpg", data.as_str(), nested.as_str()] {
let (d, im) = import_one(&format!(r#"<image href="{href}" width="80" height="80"/>"#), Some(&dir));
let what = format!("orientation {orientation}, {}", &href[..href.len().min(20)]);
assert_eq!(d.images[&im.key].mime, "image/png", "{what}");
assert_eq!(stored_ppi(&d, &im), Some(ppi), "{what}");
}
}
let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff_at(4, 2, 1, &[0, 0, 255, 255, 0, 0, 255, 255], 6, Some((300, 150)))));
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), None);
assert_eq!((im.width, im.height), (2, 4));
assert_eq!(stored_ppi(&d, &im), Some((150.0, 300.0)), "a turned TIFF");
}
/// A TIFF that is not turned is written as PNG with the resolution it declares; a raster that
/// declares none is stored without one.
#[test]
fn an_upright_tiff_keeps_its_resolution() {
let gray = [0, 0, 255, 255, 0, 0, 255, 255];
let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff_at(4, 2, 1, &gray, 1, Some((300, 150)))));
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), None);
assert_eq!(d.images[&im.key].mime, "image/png");
assert_eq!(stored_ppi(&d, &im), Some((300.0, 150.0)));
for bytes in [tiff(4, 2, 1, &gray, 1), jpeg(Some(6))] {
let href = format!("data:;base64,{}", base64_encode(&bytes));
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), None);
assert_eq!(stored_ppi(&d, &im), None, "no resolution is made up");
}
}
/// A CMYK TIFF stored with its inks keeps its resolution, swapped when it turns a quarter.
#[test]
fn a_cmyk_tiff_keeps_its_resolution_turned_or_not() {
let (c, none) = ([255u8, 0, 0, 0], [0u8; 4]);
let data: Vec<u8> = [c, c, none, none, c, c, none, none].concat();
for (orientation, ppi) in [(1, (300.0, 150.0)), (6, (150.0, 300.0))] {
let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff_at(4, 2, 4, &data, orientation, Some((300, 150)))));
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), None);
assert_eq!(d.images[&im.key].mime, "image/tiff", "orientation {orientation}");
assert!(d.images[&im.key].cmyk().is_some(), "orientation {orientation}: CMYK inks");
assert_eq!(stored_ppi(&d, &im), Some(ppi), "orientation {orientation}");
}
}
+1
View File
@@ -1784,6 +1784,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF.
- An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`).
- SVG import shows a raster `<image>` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `<image>` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (<https://www.w3.org/TR/css-images-3/#the-image-orientation>). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`).
- An image that SVG import stores again, as PNG or as a CMYK TIFF, keeps the resolution its PNG, JPEG, TIFF, WebP or BMP file declares, as Open and Place keep it: in a PNG `pHYs` chunk (<https://www.w3.org/TR/png-3/#11pHYs>) or the TIFF's resolution tags, with the horizontal and vertical values swapped when the image turns a quarter. An image that declares no resolution is stored without one (`a_turned_raster_keeps_its_resolution`, `an_upright_tiff_keeps_its_resolution` and `a_cmyk_tiff_keeps_its_resolution_turned_or_not` in `crates/svg/tests/it/image_orientation.rs`).
- SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (<https://www.w3.org/TR/SVG2/conform.html>). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`).
- File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place <name>: the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`).
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.