SVG import: raster images follow their EXIF or TIFF orientation

An <image> whose EXIF orientation or TIFF Orientation tag turns or flips
its pixels, linked or as a data URL, is turned before usvg reads it: the
pixels are stored as they are shown, the width and height swap, and the
box fits the turned image under preserveAspectRatio, as CSS
image-orientation: from-image describes. A turned CMYK JPEG or TIFF keeps
its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned.

- Data URLs of type image/tiff, and untyped TIFF bytes, are read.
- TIFFs with extra channels, CCITT Group 3 TIFFs and planar CMYK TIFFs
  are read as Open and Place read them.
- Converted images decode within the decode allowance, and a TIFF that
  claims more strips or tiles than the decoder can count is refused
  before any decoder opens it.
- exif_orientation moves to vectorcraft-doc so Open/Place and SVG import
  share it.

Tests: crates/svg/tests/it/image_orientation.rs.
This commit is contained in:
dotCooCoo
2026-11-13 06:53:42 -08:00
parent e8c7421a3d
commit 1ce6431a99
7 changed files with 431 additions and 35 deletions
+15
View File
@@ -140,6 +140,21 @@ pub fn tiff_orientation(bytes: &[u8]) -> Option<u16> {
d.find_tag_unsigned::<u16>(tiff::tags::Tag::Orientation).ok().flatten()
}
/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when
/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with
/// extra samples) has its Orientation tag read from its first image ([`tiff_orientation`]). Reads
/// only the image's header and metadata; `None` for an [`oversized_tiff`], which no decoder opens.
pub fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientation> {
use image::ImageDecoder as _;
use image::metadata::Orientation;
if oversized_tiff(bytes).is_some() {
return None;
}
let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok();
let tagged = || tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?));
decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms)
}
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
+2 -14
View File
@@ -868,7 +868,7 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
/// A TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are
/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in.
/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is
/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`vectorcraft_doc::cmyk::exif_orientation`]) turns or flips it is
/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG
/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]).
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
@@ -909,7 +909,7 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
// A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is
// seen, so it places the way it was taken (#4284); the others keep their own bytes.
let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten();
let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| vectorcraft_doc::cmyk::exif_orientation(bytes)).flatten();
let turned = orientation.is_some();
// The resolution describes the stored pixels; a quarter turn swaps its axes.
let quarter = orientation.is_some_and(|o| o.to_exif() >= 5);
@@ -957,18 +957,6 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
as_png(img, None, shown_ppi)
}
/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when
/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with
/// extra samples) has its Orientation tag read from its first image
/// ([`vectorcraft_doc::cmyk::tiff_orientation`]).
pub(crate) fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientation> {
use image::ImageDecoder as _;
use image::metadata::Orientation;
let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok();
let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?));
decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms)
}
/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its
/// colors are in.
pub(super) fn as_png(img: image::RgbaImage, icc: Option<Vec<u8>>, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
+128 -21
View File
@@ -3,11 +3,15 @@
//! stays a linked image ([`LinkInfo`]); an SVG file becomes vector art. One that can't be read
//! (missing, remote, not an image) becomes a placeholder that keeps the link and its place, so it
//! can be relinked, and a warning.
//!
//! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag)
//! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as
//! it is shown.
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use usvg::{ImageHrefResolver, ImageKind, roxmltree};
use vectorcraft_doc::LinkInfo;
@@ -44,6 +48,20 @@ pub(in crate::import) struct Files {
svg_images: AtomicUsize,
/// SVG images were left out for exceeding [`MAX_NESTED_SVG_IMAGES`].
too_many_svg_images: AtomicBool,
/// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got
/// (kept here too, so the address stays its own).
cmyk: Mutex<HashMap<usize, (Bytes, Bytes)>>,
}
/// Encoded image bytes, shared.
type Bytes = Arc<Vec<u8>>;
/// Raster bytes as usvg gets them.
struct Shown {
kind: RasterKind,
bytes: Arc<Vec<u8>>,
/// The CMYK TIFF stored in their place: a turned CMYK image's inks, turned.
cmyk: Option<Vec<u8>>,
}
/// How usvg holds the bytes of a raster of one format (`ImageKind::PNG`…).
@@ -174,27 +192,37 @@ impl Files {
files
}
/// What usvg gets for a file read (`bytes`, linked by `link`): a raster usvg reads (others are
/// stored as PNG) or an SVG; `None` for anything else.
/// What usvg gets for a file read (`bytes`, linked by `link`): a raster as [`shown`] gives it
/// (formats usvg doesn't read are stored as PNG) or an SVG; `None` for anything else.
fn source(&mut self, bytes: Vec<u8>, link: LinkInfo) -> Option<Source> {
let Ok(format) = image::guess_format(&bytes) else {
// Unpacked once, here, within what the SVGZ images may unpack to.
let text = crate::text_within(&bytes, &self.unpacked).ok()?;
return text.contains("<svg").then(|| Source::Svg(Arc::from(text.as_ref())));
};
let Some(kind) = raster(format) else {
let img = image::load_from_memory_with_format(&bytes, format).ok()?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
let png = Arc::new(png);
self.links.insert(key(&png), Linked { link, missing: false });
return Some(Source::Raster(ImageKind::PNG, png));
};
let bytes = Arc::new(bytes);
let (kind, bytes) = self.keep(shown(Arc::new(bytes), format)?);
self.links.insert(key(&bytes), Linked { link, missing: false });
Some(Source::Raster(kind, bytes))
}
/// What usvg gets for `shown`, with its CMYK TIFF, when it has one, noted to store in its
/// place ([`Files::stored`]).
fn keep(&self, shown: Shown) -> (RasterKind, Arc<Vec<u8>>) {
if let Some(cmyk) = shown.cmyk
&& let Ok(mut stored) = self.cmyk.lock()
{
stored.insert(key(&shown.bytes), (shown.bytes.clone(), Arc::new(cmyk)));
}
(shown.kind, shown.bytes)
}
/// The CMYK TIFF stored in place of the image whose data usvg got as `kind`: a CMYK JPEG or
/// TIFF its orientation turns, which usvg got as PNG.
pub(in crate::import) fn stored(&self, kind: &ImageKind) -> Option<Arc<Vec<u8>>> {
let ImageKind::PNG(b) = kind else { return None };
self.cmyk.lock().ok()?.get(&key(b)).map(|(_, tiff)| tiff.clone())
}
/// Does href `h` show a placeholder?
fn placeholder(&self, h: &str) -> bool {
matches!(self.sources.get(h), Some(Source::Raster(_, b)) if self.links.get(&key(b)).is_some_and(|l| l.missing))
@@ -241,21 +269,24 @@ impl Files {
pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> {
self.depth.store(height, Ordering::Relaxed);
ImageHrefResolver {
// By type as usvg reads them; one without a type (`text/plain`) by its bytes: a
// raster usvg reads, else SVG.
// By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its
// bytes: one of those rasters, else SVG. A raster as [`shown`] gives it.
resolve_data: Box::new(move |mime, bytes, opts| {
let kind = match mime {
"image/jpg" | "image/jpeg" => ImageKind::JPEG,
"image/png" => ImageKind::PNG,
"image/gif" => ImageKind::GIF,
"image/webp" => ImageKind::WEBP,
use image::ImageFormat as F;
let format = match mime {
"image/jpg" | "image/jpeg" => F::Jpeg,
"image/png" => F::Png,
"image/gif" => F::Gif,
"image/webp" => F::WebP,
"image/tiff" => F::Tiff,
"image/svg+xml" => return self.svg_data(&bytes, opts),
"text/plain" => match image::guess_format(&bytes).ok().and_then(raster) {
Some(kind) => kind,
"text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) {
Some(format) => format,
None => return self.svg_data(&bytes, opts),
},
_ => return None,
};
let (kind, bytes) = self.keep(shown(bytes, format)?);
Some(kind(bytes))
}),
resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? {
@@ -312,6 +343,82 @@ fn raster(format: image::ImageFormat) -> Option<RasterKind> {
}
}
/// Encoded raster `bytes`, `format` by their type (or by their bytes when they have none), as usvg
/// gets them, as Open and Place store them: unchanged when usvg reads the format and the image's
/// 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
/// ([`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;
if vectorcraft_doc::cmyk::oversized_tiff(&bytes).is_some() {
return None;
}
let orientation =
matches!(format, F::Png | F::Jpeg | F::Gif | F::WebP | F::Tiff).then(|| vectorcraft_doc::cmyk::exif_orientation(&bytes)).flatten();
if let Some(kind) = raster(format)
&& orientation.is_none()
{
return Some(Shown { kind, bytes, cmyk: None });
}
let tiff = image::guess_format(&bytes).ok() == Some(F::Tiff);
let mut img = if tiff { tiff_pixels(&bytes)? } else { decoded(&bytes, format)? };
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),
_ => None,
};
if let Some(orientation) = orientation {
img.apply_orientation(orientation);
}
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), F::Png).ok()?;
Some(Shown { kind: ImageKind::PNG, bytes: Arc::new(png), cmyk })
}
/// The pixels of encoded image `bytes` (`format` when their bytes don't say), as stored, decoded
/// within [`vectorcraft_doc::MAX_RASTER_ALLOC`], counted as decoded and as RGBA, as Open and Place
/// decode them; `None` when they can't be.
fn decoded(bytes: &[u8], format: image::ImageFormat) -> Option<image::DynamicImage> {
use image::ImageDecoder as _;
let max = vectorcraft_doc::MAX_RASTER_ALLOC;
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
if reader.format().is_none() {
reader.set_format(format);
}
let mut limits = image::Limits::default();
limits.max_alloc = Some(max);
reader.limits(limits);
let decoder = reader.into_decoder().ok()?;
let (w, h) = decoder.dimensions();
if decoder.total_bytes() > max || u64::from(w) * u64::from(h) * 4 > max {
return None;
}
image::DynamicImage::from_decoder(decoder).ok()
}
/// The pixels of TIFF `bytes`, as stored, read as Open and Place read them: a TIFF with extra
/// samples (CMYK or gray with alpha) as RGBA, CMYK converted with the active color settings
/// ([`vectorcraft_doc::cmyk::extra_samples_tiff_rgba`]); a 1-bit TIFF compressed with CCITT Group
/// 3 as RGBA ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]); others by [`decoded`], a CMYK TIFF
/// that stores one plane per ink with its inks written together first
/// ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]). `None` when they can't be read.
fn tiff_pixels(bytes: &[u8]) -> Option<image::DynamicImage> {
use image::DynamicImage::ImageRgba8;
let cms = vectorcraft_color::cms::active();
if let Some(img) = vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) {
return Some(ImageRgba8(img));
}
if let Some(read) = vectorcraft_doc::cmyk::group3_tiff_rgba(bytes) {
return read.ok().map(ImageRgba8);
}
decoded(bytes, image::ImageFormat::Tiff).or_else(|| decoded(&vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, None)?, image::ImageFormat::Tiff))
}
/// No image: left out, noted in `why` when `noted`.
fn leave_out(why: &AtomicBool, noted: bool) -> Option<ImageKind> {
if noted {
+6
View File
@@ -771,6 +771,12 @@ impl Importer {
.ok()
.and_then(|r| r.into_dimensions().ok())
.unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32));
// A turned CMYK image is stored as the CMYK TIFF of its inks, the size of the PNG usvg got.
let cmyk = self.files.stored(i.kind());
let (bytes, mime) = match &cmyk {
Some(tiff) => (tiff, "image/tiff"),
None => (bytes, mime),
};
// A linked file keeps a preview; a placeholder is all a missing one has (its preview).
let linked = self.files.linked(i.kind());
let missing = linked.is_some_and(|l| l.missing);
+278
View File
@@ -0,0 +1,278 @@
//! 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> {
let entries: u32 = if samples == 2 { 12 } else { 11 };
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 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()
};
// 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()),
entry(284, 3, 1, 1),
]
.into_iter()
.chain(extra)
{
out.extend(e);
}
out.extend(0u32.to_le_bytes());
if samples > 2 {
for _ in 0..samples {
out.extend(8u16.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)));
}
+1
View File
@@ -20,6 +20,7 @@ mod font_weights;
mod foreign_attributes;
mod gradients;
mod ids;
mod image_orientation;
mod import_details;
mod import_live;
mod import_text;
+1
View File
@@ -1640,6 +1640,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once.
- The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`).
- 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, 8 bits each, as a CMYK TIFF.
- 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`).
- 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.
- On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before.