Files
vectorcraft/crates/svg/tests/it/image_orientation.rs
T

379 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Raster images whose orientation (EXIF, or a TIFF's Orientation tag) turns their pixels import
//! as they are shown, linked or as data URLs: the pixels turned, the width and height swapped, and
//! the turned image fitted to the `<image>` box.
use std::path::PathBuf;
use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_svg::{ImportOptions, base64_encode, import_with, import_with_report};
fn svg(body: &str) -> String {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120">{body}</svg>"#)
}
fn images(d: &Document) -> Vec<(&Node, &ImageObject)> {
let mut v = Vec::new();
d.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
v.push((n, &**im));
}
});
v
}
/// The one image `body` imports to, with its warnings, its link read from `dir` when given.
fn import_one(body: &str, dir: Option<&PathBuf>) -> (Document, ImageObject) {
let read = |path: &str| std::fs::read(path).ok().map(|b| (b, LinkInfo::new(path)));
let folder = dir.map(|d| d.to_string_lossy().into_owned());
let (d, w) = match &folder {
Some(f) => import_with(&svg(body), &ImportOptions { folder: Some(f), read: Some(&read) }).unwrap(),
None => import_with_report(&svg(body)).unwrap(),
};
assert!(w.is_empty(), "{w:?}");
let im = match &images(&d)[..] {
[(_, im)] => (*im).clone(),
other => panic!("{} images", other.len()),
};
(d, im)
}
/// A fresh folder for one test's files.
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-svg-orientation-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A 16 × 8 JPEG, red on the left half, with an EXIF segment giving Orientation `orientation`
/// when there is one.
fn jpeg(orientation: Option<u16>) -> Vec<u8> {
let rgb = image::RgbImage::from_fn(16, 8, |x, _| if x < 8 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) });
let mut jpg = Vec::new();
image::DynamicImage::ImageRgb8(rgb).write_to(&mut std::io::Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap();
let Some(orientation) = orientation else { return jpg };
let o = orientation.to_le_bytes();
let exif: Vec<u8> =
[b"Exif\0\0".as_slice(), b"II", &[0x2a, 0, 8, 0, 0, 0], &[1, 0], &[0x12, 0x01, 3, 0, 1, 0, 0, 0, o[0], o[1], 0, 0], &[0, 0, 0, 0]].concat();
let mut tagged = jpg[..2].to_vec();
tagged.extend([0xff, 0xe1]);
tagged.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes());
tagged.extend(&exif);
tagged.extend(&jpg[2..]);
tagged
}
/// A little-endian, uncompressed TIFF of `w` × `h` pixels of `samples` 8-bit samples each (1 for
/// gray, 2 for gray with an unassociated alpha channel, 4 for CMYK inks), `data` row by row, with
/// 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> {
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 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,
2 => 8 | 8 << 16,
_ => bits_at,
};
let photometric = if samples == 4 { 5 } else { 1 };
let mut out = [b"II".as_slice(), &[0x2a, 0], &8u32.to_le_bytes(), &u16::try_from(entries).unwrap().to_le_bytes()].concat();
// ExtraSamples (338): unassociated alpha.
let extra = (samples == 2).then(|| entry(338, 3, 1, 2));
for e in [
entry(256, 4, 1, w),
entry(257, 4, 1, h),
entry(258, 3, u32::from(samples), bits),
entry(259, 3, 1, 1),
entry(262, 3, 1, photometric),
entry(273, 4, 1, data_at),
entry(274, 3, 1, u32::from(orientation)),
entry(277, 3, 1, u32::from(samples)),
entry(278, 4, 1, h),
entry(279, 4, 1, u32::try_from(data.len()).unwrap()),
]
.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);
}
out.extend(0u32.to_le_bytes());
if samples > 2 {
for _ in 0..samples {
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
}
/// Where [`tiff`] writes the RowsPerStrip value (the ninth entry) and the PlanarConfiguration value
/// (the eleventh).
const ROWS_AT: usize = 8 + 2 + 8 * 12 + 8;
const PLANAR_AT: usize = 8 + 2 + 10 * 12 + 8;
/// A 4 × 2 gray TIFF, black on the left half, turned a quarter clockwise by Orientation 6.
fn gray_tiff() -> Vec<u8> {
tiff(4, 2, 1, &[0, 0, 255, 255, 0, 0, 255, 255], 6)
}
/// Where the image's own box goes on the page.
fn placed(im: &ImageObject) -> Rect {
im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)))
}
fn near(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6)
}
/// The stored pixels of `im`, as RGB.
fn pixels(d: &Document, im: &ImageObject) -> image::RgbImage {
image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgb8()
}
fn red(p: &image::Rgb<u8>) -> bool {
p[0] > 150 && p[1] < 80
}
/// A JPEG whose EXIF Orientation 6 turns it a quarter clockwise shows upright, linked and as a
/// data URL alike: 8 × 16, the left half on top, fitted (centered) in a square box as the turned
/// image is, and stored without an orientation that would turn it again.
#[test]
fn a_turned_jpeg_imports_upright() {
let jpg = jpeg(Some(6));
let dir = folder("jpeg");
std::fs::write(dir.join("photo.jpg"), &jpg).unwrap();
let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg));
let untyped = format!("data:;base64,{}", base64_encode(&jpg));
for href in ["photo.jpg", data.as_str(), untyped.as_str()] {
let (d, im) = import_one(&format!(r#"<image href="{href}" x="10" y="20" width="80" height="80"/>"#), Some(&dir));
let what = &href[..href.len().min(20)];
assert_eq!((im.width, im.height), (8, 16), "{what}: turned a quarter");
assert!(near(placed(&im), Rect::new(30.0, 20.0, 70.0, 100.0)), "{what}: fitted as turned: {:?}", placed(&im));
let px = pixels(&d, &im);
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "{what}: the left half is on top");
assert_eq!(vectorcraft_doc::cmyk::exif_orientation(&d.images[&im.key].bytes), None, "{what}: not turned again");
assert_eq!(im.link.is_some(), href == "photo.jpg", "{what}");
}
}
/// With `preserveAspectRatio="none"` the turned image fills the box.
#[test]
fn a_turned_jpeg_stretches_to_the_box_as_turned() {
let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpeg(Some(6))));
let (_, im) = import_one(&format!(r#"<image href="{data}" x="10" y="20" width="80" height="40" preserveAspectRatio="none"/>"#), None);
assert_eq!((im.width, im.height), (8, 16));
assert!(near(placed(&im), Rect::new(10.0, 20.0, 90.0, 60.0)), "{:?}", placed(&im));
}
/// A JPEG without an orientation, or with Orientation 1, keeps its own bytes.
#[test]
fn an_upright_jpeg_keeps_its_bytes() {
for jpg in [jpeg(None), jpeg(Some(1))] {
let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg));
let (d, im) = import_one(&format!(r#"<image href="{data}" width="80" height="80"/>"#), None);
assert_eq!((im.width, im.height), (16, 8));
assert_eq!(*d.images[&im.key].bytes, jpg);
assert_eq!(d.images[&im.key].mime, "image/jpeg");
}
}
/// A TIFF whose Orientation tag is 6 shows upright, linked, as an `image/tiff` data URL and as a
/// data URL without a type.
#[test]
fn a_turned_tiff_imports_upright() {
let tif = gray_tiff();
let dir = folder("tiff");
std::fs::write(dir.join("scan.tif"), &tif).unwrap();
let data = format!("data:image/tiff;base64,{}", base64_encode(&tif));
let untyped = format!("data:;base64,{}", base64_encode(&tif));
for href in ["scan.tif", data.as_str(), untyped.as_str()] {
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), Some(&dir));
let what = &href[..href.len().min(20)];
assert_eq!((im.width, im.height), (2, 4), "{what}: turned a quarter");
assert!(near(placed(&im), Rect::new(0.0, 0.0, 20.0, 40.0)), "{what}: {:?}", placed(&im));
let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8();
assert_eq!((px.get_pixel(0, 0)[0], px.get_pixel(1, 1)[0], px.get_pixel(0, 3)[0]), (0, 0, 255), "{what}: the left half is on top");
}
}
/// A CMYK TIFF keeps its inks as a CMYK TIFF, turned when its orientation turns it.
#[test]
fn a_cmyk_tiff_keeps_its_inks_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, (w, h), cyan_at, none_at) in [(1, (4usize, 2usize), (1, 1), (2, 0)), (6, (2, 4), (1, 1), (0, 2))] {
let tif = tiff(4, 2, 4, &data, orientation);
let href = format!("data:image/tiff;base64,{}", base64_encode(&tif));
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), None);
let what = format!("orientation {orientation}");
assert_eq!((im.width as usize, im.height as usize), (w, h), "{what}");
let blob = &d.images[&im.key];
assert_eq!(blob.mime, "image/tiff", "{what}");
let inks = blob.cmyk().expect("CMYK inks");
assert_eq!((inks.width as usize, inks.height as usize), (w, h), "{what}");
let ink = |(x, y): (usize, usize)| &inks.data[(y * w + x) * 4..][..4];
assert_eq!((ink((0, 0)), ink(cyan_at), ink(none_at)), (&c[..], &c[..], &none[..]), "{what}: cyan on the left, or on top");
}
}
/// A gray TIFF with an alpha channel, which the image decoder can't read, imports as Open and
/// Place read it, with its transparency: upright, and turned by Orientation 6.
#[test]
fn a_gray_tiff_with_alpha_imports_turned_or_not() {
let (black, clear) = ([0u8, 255], [255u8, 0]);
let data: Vec<u8> = [black, black, clear, clear, black, black, clear, clear].concat();
for (orientation, size, black_at, clear_at) in [(1, (4, 2), (0, 1), (3, 0)), (6, (2, 4), (1, 1), (0, 3))] {
let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff(4, 2, 2, &data, orientation)));
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), None);
assert_eq!((im.width, im.height), size, "orientation {orientation}");
let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8();
assert_eq!(px.get_pixel(black_at.0, black_at.1).0, [0, 0, 0, 255], "orientation {orientation}: black");
assert_eq!(px.get_pixel(clear_at.0, clear_at.1)[3], 0, "orientation {orientation}: clear");
}
}
/// A turned TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a
/// strip each, times 5 planes) is refused without a panic: linked, it is a placeholder with a
/// warning; as a data URL, it is left out.
#[test]
fn a_tiff_with_more_strips_than_can_be_counted_is_refused() {
let mut tif = tiff(3, 1 << 31 | 1, 5, &[0; 30], 6);
tif[ROWS_AT..ROWS_AT + 4].copy_from_slice(&1u32.to_le_bytes());
tif[PLANAR_AT..PLANAR_AT + 2].copy_from_slice(&2u16.to_le_bytes());
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&tif), Some((3, 1 << 31 | 1)));
let dir = folder("oversized");
std::fs::write(dir.join("huge.tif"), &tif).unwrap();
let read = |path: &str| std::fs::read(path).ok().map(|b| (b, LinkInfo::new(path)));
let f = dir.to_string_lossy();
let (d, w) =
import_with(&svg(r#"<image href="huge.tif" width="20" height="40"/>"#), &ImportOptions { folder: Some(&f), read: Some(&read) }).unwrap();
assert!(w.iter().any(|w| w.contains("huge.tif") && w.contains("is not an image VectorCraft reads")), "{w:?}");
let [(_, im)] = images(&d)[..] else { panic!("{} images", images(&d).len()) };
assert!(d.images[&im.key].is_proxy(), "a placeholder");
let data = format!("data:image/tiff;base64,{}", base64_encode(&tif));
let (d, _) = import_with_report(&svg(&format!(r#"<image href="{data}" width="20" height="40"/>"#))).unwrap();
assert!(images(&d).is_empty());
}
/// A turned JPEG inside an SVG shown as an image is turned too.
#[test]
fn a_turned_jpeg_in_an_svg_image_imports_upright() {
let jpg = format!("data:image/jpeg;base64,{}", base64_encode(&jpeg(Some(6))));
let inner = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><image href="{jpg}" width="80" height="80"/></svg>"#);
let nested = format!("data:image/svg+xml;base64,{}", base64_encode(inner.as_bytes()));
let (d, im) = import_one(&format!(r#"<image href="{nested}" width="80" height="80"/>"#), None);
assert_eq!((im.width, im.height), (8, 16));
assert!(near(placed(&im), Rect::new(20.0, 0.0, 60.0, 80.0)), "{:?}", placed(&im));
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}");
}
}
/// A turned JPEG that a linked SVG file links to is turned too.
#[test]
fn a_turned_jpeg_linked_from_a_linked_svg_file_imports_upright() {
let dir = folder("nested-file");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub").join("photo.jpg"), jpeg(Some(6))).unwrap();
let inner = r#"<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><image href="photo.jpg" width="80" height="80"/></svg>"#;
std::fs::write(dir.join("sub").join("inner.svg"), inner).unwrap();
let (d, im) = import_one(r#"<image href="sub/inner.svg" width="80" height="80"/>"#, Some(&dir));
assert_eq!((im.width, im.height), (8, 16));
assert!(near(placed(&im), Rect::new(20.0, 0.0, 60.0, 80.0)), "{:?}", placed(&im));
let px = pixels(&d, &im);
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)));
}