Files
pdfcraft/crates/engine/src/upright_image.rs
T
MarekSpodBiedry 5c5f4d8b49 Phone photos follow EXIF orientation; image signatures stay upright on rotated pages (#434) (#485)
* Stamps and added images follow a JPEG's EXIF orientation (part of #434)

Phones store a portrait photo as landscape pixels plus an EXIF
Orientation tag. A JPEG went into the PDF as it was (DCTDecode), and PDF
image XObjects have no such tag, so a photo used as a custom stamp, an
added or replaced image, a watermark or background, or a button image
showed up turned 90 degrees.

Before these images are embedded, a JPEG whose Orientation is not 1 is
decoded, turned the way the tag says and re-encoded (quality 92). Its
JFIF resolution is kept, swapped for quarter turns, so the natural size
on the page stays right. Other images, JPEGs without the tag, JPEGs that
fail to decode and photos over 64 megapixels are embedded unchanged, as
before. Fill & Sign image signatures already applied the tag.

* Image signatures stay upright and anchored on rotated pages (part of #434)

On a page with /Rotate 90, 180 or 270 an image signature turned with the
page, because its picture was drawn along user-space axes while the click
or the "at" argument is a displayed point. It was also anchored at the
wrong point: its left edge and vertical centre were taken in user space,
so on a 90 degree page the image centre ended up elsewhere.

The signature's appearance now carries the counter-rotation as its form
/Matrix, which viewers fit to /Rect, so the picture reads upright as
displayed. The page's rotation is kept in /PCPictureRotate, so restyling
regenerates the same matrix and moving and resizing keep it. The box of the
signature runs along the displayed axes, so its left edge and vertical
centre are where they were asked for. The page view matrix in
pdfcraft-model is split out as view_matrix_for.

The Fill & Sign preview and the live move/resize preview draw the picture
the way the appearance does, and the resize handles keep the image's
proportions as displayed. comment_image_preview reports image_rotation,
how far the image is shown turned (0 for one placed upright).

Custom image stamps are left to #473.

* Use is_multiple_of for the quarter-turn checks (clippy) (#434)

* Probe the pointer preview away from egui_kittest's drawn cursor (#434)

The kittest render paints a mouse cursor (a 16 px triangle with a black outline) at the
hover point, so the 2 pt probe beside the pointer landed on its outline when zoom and view
rotation made 2 pt only a pixel or two. Put the stroke's block at the picture's right end and
probe there and at its far margin instead.
2026-10-09 09:14:55 -04:00

134 lines
6.0 KiB
Rust

//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
//! (stamps, added images, replacements) go through here first.
use std::borrow::Cow;
use std::io::Cursor;
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
use image::metadata::Orientation;
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
/// Quality of a re-encoded JPEG (the camera's own is rarely higher).
const QUALITY: u8 = 92;
/// Larger photos are embedded as they are: turning them would take hundreds of MB.
const MAX_PIXELS: u64 = 64 << 20;
/// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded);
/// everything else, and any JPEG this can't read, is returned unchanged.
pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
match turned(bytes) {
Some(out) => Cow::Owned(out),
None => Cow::Borrowed(bytes),
}
}
fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
if !bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?;
let orientation = decoder.orientation().ok()?;
let (w, h) = decoder.dimensions();
if orientation == Orientation::NoTransforms || u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
let mut image = DynamicImage::from_decoder(decoder).ok()?;
image.apply_orientation(orientation);
let mut out = Vec::new();
{
let mut encoder = JpegEncoder::new_with_quality(&mut out, QUALITY);
// Keep the resolution, which sets the image's natural size on the page.
if let Some(mut density) = jfif_density(bytes) {
if matches!(orientation, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) {
density.density = (density.density.1, density.density.0);
}
encoder.set_pixel_density(density);
}
match &image {
DynamicImage::ImageLuma8(gray) => encoder.encode_image(gray).ok()?,
_ => encoder.encode_image(&image.to_rgb8()).ok()?,
}
}
Some(out)
}
/// The resolution in a JFIF header, if the file has one in inches or centimetres.
fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
let mut i = 2usize;
while let Some(&[0xFF, marker, hi, lo]) = bytes.get(i..i.saturating_add(4)) {
let len = usize::from(u16::from_be_bytes([hi, lo]));
let seg = bytes.get(i + 4..i + 2 + len)?;
if marker == 0xE0 && seg.starts_with(b"JFIF\0") {
let &[unit, x0, x1, y0, y1] = seg.get(7..12)? else { return None };
let unit = match unit {
1 => PixelDensityUnit::Inches,
2 => PixelDensityUnit::Centimeters,
_ => return None,
};
return Some(PixelDensity { density: (u16::from_be_bytes([x0, x1]), u16::from_be_bytes([y0, y1])), unit });
}
if marker == 0xDA {
return None;
}
i += 2 + len;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edit, MarkFile};
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
/// degrees clockwise).
fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn orientation_is_applied_and_plain_images_are_left_alone() {
let photo = phone_jpeg(6, 144);
let turned = upright(&photo);
assert!(matches!(turned, Cow::Owned(_)));
let decoded = image::load_from_memory(&turned).unwrap();
assert_eq!((decoded.width(), decoded.height()), (20, 60));
assert_eq!(jfif_density(&turned), Some(PixelDensity { density: (72, 144), unit: PixelDensityUnit::Inches }));
let stored = phone_jpeg(1, 72);
assert!(matches!(upright(&stored), Cow::Borrowed(_)));
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
}
#[test]
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
// Named "Signature" so `signature_image` finds its picture.
let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
session.apply(id, stamp).unwrap();
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let obj = cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
let rect = pdfcraft_annot::summaries(cos)[0].rect;
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
}
}