diff --git a/Cargo.lock b/Cargo.lock index f513a7aa87..53e0f097d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4136,6 +4136,7 @@ dependencies = [ "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", + "tiff", "unicode-segmentation", "url", ] diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 2560adac9f..5888e51114 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -622,7 +622,8 @@ fn render(args: &[String]) -> Result<(), CliError> { return Err(e.into()); } let bytes = match format { - Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?, + // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. + Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, None => { // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/render.rs index b0ab245601..695431b65b 100644 --- a/apps/pdfcraft-cli/tests/render.rs +++ b/apps/pdfcraft-cli/tests/render.rs @@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() { assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]); } +#[test] +fn the_file_records_the_dpi_it_was_rendered_at() { + // The engine's tests check each format; this checks that `--dpi` reaches the file. + let pdf = fixture("dpi"); + let jpg = tmp("dpi", "p1.jpg"); + assert!(render(&pdf, &jpg).status.success()); + let bytes = std::fs::read(&jpg).unwrap(); + assert_eq!(&bytes[6..11], b"JFIF\0"); + assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch"); +} + #[test] fn a_pam_name_still_gets_the_netpbm_stream() { let pdf = fixture("pam"); diff --git a/contributors/people.toml b/contributors/people.toml index da3cfc6a77..cd8ef99f72 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -145,3 +145,6 @@ sync_github_name = false [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.MortenHusted] +sync_github_name = true diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 74567eb488..ff5e7611d8 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -37,6 +37,9 @@ pdfcraft-model = { workspace = true } pdfcraft-geom = { workspace = true } thiserror = { workspace = true } png = "0.18" +# Already in the graph (through image and pdfcraft-create): image's TiffEncoder can't set the +# resolution or the compression of an exported page. +tiff = "0.11" # Already in the graph (url and idna through webbrowser, icu_properties through boa): they show # the host of a document's link the way the browser reads it (links::display_host). url = "2.5" diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6b7aad4fad..0bf4e2f79d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -93,14 +93,14 @@ impl Exporter { encode_png(r.width, r.height, &r.rgba) } - /// Page `page` as an image file of `format`. + /// Page `page` as an image file of `format`, which says the resolution it was drawn at. pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result, String> { self.check(page)?; let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); if let Some(e) = r.error { return Err(format!("page {}: {e}", page + 1)); } - encode_image(r.width, r.height, &r.rgba, format) + encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi)) } /// The reading-order text of a page. @@ -127,12 +127,14 @@ impl Exporter { } } -/// Premultiplied RGBA → an image file of `format`. -pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result, String> { +/// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch, +/// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts +/// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink. +pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result, String> { match format { ImageFormat::Png => encode_png(width, height, premultiplied), - ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality), - ImageFormat::Tiff => encode_tiff(width, height, premultiplied), + ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi), + ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi), } } @@ -199,23 +201,37 @@ fn over_white(premultiplied: &[u8]) -> Vec { .collect() } -/// Premultiplied RGBA → baseline JPEG (pages are opaque: transparency becomes white paper). -pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result, String> { +/// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper). +pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result, String> { use image::ImageEncoder; + use image::codecs::jpeg::{JpegEncoder, PixelDensity}; let rgb = over_white(premultiplied); let mut out = Vec::new(); - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)) - .write_image(&rgb, width, height, image::ExtendedColorType::Rgb8) - .map_err(|e| e.to_string())?; + let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); + // The JFIF density is whole dots per inch. + enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16)); + enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?; Ok(out) } -/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default). -pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { - use image::ImageEncoder; +/// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly: +/// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed. +/// No predictor: LZW alone is the compression TIFF readers support most widely after none. +pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result, String> { + use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8}; + use tiff::tags::{ResolutionUnit, Tag}; + let err = |e: tiff::TiffError| e.to_string(); let rgb = over_white(premultiplied); let mut out = std::io::Cursor::new(Vec::new()); - image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?; + let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw); + let mut image = tiff.new_image::(width, height).map_err(err)?; + // In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number. + let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 }; + let tags = image.encoder(); + tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?; + tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?; + tags.write_tag(Tag::YResolution, resolution).map_err(err)?; + image.write_data(&rgb).map_err(err)?; Ok(out.into_inner()) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0f012d9e09..f125c41dac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2574,6 +2574,48 @@ fn pages_export_as_png_jpeg_and_tiff() { let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap(); let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap(); assert_eq!((decoded.width(), decoded.height()), (400, 600)); + let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap(); + let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap(); + let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap(); + assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor"); +} + +/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution. +fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) { + use tiff::{decoder::ifd::Value, tags::Tag}; + let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap(); + let mut res = |tag| match d.get_tag(tag).unwrap() { + Value::Rational(n, d) => f64::from(n) / f64::from(d), + v => panic!("{tag:?} is {v:?}"), + }; + let (x, y) = (res(Tag::XResolution), res(Tag::YResolution)); + (d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y) +} + +/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it +/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing. +#[test] +fn exported_pages_record_the_resolution_they_were_drawn_at() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(1); + let mut ex = Exporter::new(s.get(id).unwrap()); + assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0)); + let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap(); + assert_eq!(&jpg[6..11], b"JFIF\0"); + assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch"); + // pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG + // (doc_create from images) would come out 0.01% small. + let png = ex.image(0, 72.0, ImageFormat::Png).unwrap(); + assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution"); + + // A page too large for the renderer at the asked resolution says the one it was drawn at. + let mut s = Session::new().with_clock(|| 1_700_000_000); + let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap(); + let id = s.open("wide.pdf", None, bytes, None).unwrap(); + let mut ex = Exporter::new(s.get(id).unwrap()); + let used = ex.dpi_used(0, 600.0); + let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap()); + assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}"); } #[test]