diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index a42ec84fa..5717977b1 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -5,6 +5,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; mod extract; +mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file}; use pdfcraft_fonts::{literal, win_ansi, wrap}; @@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { /// Detect the image format from its bytes; a TIFF may hold several pages. fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { if bytes.starts_with(&[0xFF, 0xD8]) { - Ok(vec![jpeg(name, bytes)?]) + Ok(vec![jpeg(name, &upright_image::upright(bytes))?]) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Ok(vec![png_image(name, bytes)?]) } else if is_tiff(bytes) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 993c38d9f..a996b703f 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -949,3 +949,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() { } } } + +/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees +/// clockwise). +pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + 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 a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { + let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap()); + // Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt. + let m0 = media(&pages(&doc)[0]); + assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}"); + let img = image_of(&doc, 0); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60))); + // "As stored" stays as it is. + let m1 = media(&pages(&doc)[1]); + assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}"); + let img = image_of(&doc, 1); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); +} diff --git a/crates/engine/src/upright_image.rs b/crates/create/src/upright_image.rs similarity index 62% rename from crates/engine/src/upright_image.rs rename to crates/create/src/upright_image.rs index dc2b9cdbf..9816dfc4b 100644 --- a/crates/engine/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -1,6 +1,6 @@ //! 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. +//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this +//! crate (pages made from images, stamps, added images, replacements) goes through here first. use std::borrow::Cow; use std::io::Cursor; @@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option { #[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 { - 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 - } + use crate::tests::phone_jpeg; #[test] fn orientation_is_applied_and_plain_images_are_left_alone() { @@ -111,23 +94,4 @@ mod tests { 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:?}"); - } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 69f7f01d0..5fefe9b4b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,7 +36,6 @@ pub mod signature_image; pub mod signature_scan; pub mod text_fonts; mod text_preview; -mod upright_image; pub mod word_count; pub mod xfa; @@ -4933,7 +4932,7 @@ fn embed_image( name: &str, bytes: &[u8], ) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes)) + pdfcraft_create::image_xobject(doc, name, bytes) } /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 22016c8c9..ea410cafb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5854,3 +5854,39 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() { // No area, or a box off the image, is refused. assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); } + +/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise). +fn phone_jpeg() -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (144, 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(&[6, 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 a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { + let mut session = 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: Arc::new(phone_jpeg()), 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:?}"); +}