diff --git a/Cargo.lock b/Cargo.lock index 215960088..d05f4f847 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2539,6 +2539,7 @@ dependencies = [ "once_cell", "palette", "phf", + "png", "portable-atomic", "qcms", "rayon", @@ -2558,6 +2559,7 @@ dependencies = [ "two-face", "typed-arena", "typst-assets", + "typst-dev-assets", "typst-macros", "typst-syntax", "typst-timing", diff --git a/Cargo.toml b/Cargo.toml index 118c3ea06..fcba1f257 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ pathdiff = "0.2" pdf-writer = "0.9.2" phf = { version = "0.11", features = ["macros"] } pixglyph = "0.3" +png = "0.17" portable-atomic = "1.6" proc-macro2 = "1" pulldown-cmark = "0.9" diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml index 77809ed11..889d41e77 100644 --- a/crates/typst/Cargo.toml +++ b/crates/typst/Cargo.toml @@ -46,6 +46,7 @@ once_cell = { workspace = true } palette = { workspace = true } qcms = { workspace = true } phf = { workspace = true } +png = { workspace = true } portable-atomic = { workspace = true } rayon = { workspace = true } regex = { workspace = true } @@ -72,5 +73,8 @@ wasmi = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] stacker = { workspace = true } +[dev-dependencies] +typst-dev-assets = { workspace = true } + [lints] workspace = true diff --git a/crates/typst/src/visualize/image/mod.rs b/crates/typst/src/visualize/image/mod.rs index e7c91cd6f..199564c60 100644 --- a/crates/typst/src/visualize/image/mod.rs +++ b/crates/typst/src/visualize/image/mod.rs @@ -27,7 +27,7 @@ use crate::loading::Readable; use crate::model::Figurable; use crate::syntax::{Span, Spanned}; use crate::text::{families, Lang, LocalName, Region}; -use crate::util::{option_eq, LazyHash, Numeric}; +use crate::util::{option_eq, LazyHash}; use crate::visualize::Path; use crate::World; @@ -198,20 +198,30 @@ impl LayoutSingle for Packed { let region_ratio = region.x / region.y; // Find out whether the image is wider or taller than the target size. - let pxw = image.width() as f64; - let pxh = image.height() as f64; + let pxw = image.width(); + let pxh = image.height(); let px_ratio = pxw / pxh; let wide = px_ratio > region_ratio; // The space into which the image will be placed according to its fit. let target = if expand.x && expand.y { + // If both width and height are forced, take them. region - } else if expand.x || (!expand.y && wide && region.x.is_finite()) { + } else if expand.x { + // If just width is forced, take it. Size::new(region.x, region.y.min(region.x.safe_div(px_ratio))) - } else if region.y.is_finite() { + } else if expand.y { + // If just height is forced, take it. Size::new(region.x.min(region.y * px_ratio), region.y) } else { - Size::new(Abs::pt(pxw), Abs::pt(pxh)) + // If neither is forced, take the natural image size at the image's + // DPI bounded by the available space. + let dpi = image.dpi().unwrap_or(Image::DEFAULT_DPI); + let natural = Axes::new(pxw, pxh).map(|v| Abs::inches(v / dpi)); + Size::new( + natural.x.min(region.x).min(region.y * px_ratio), + natural.y.min(region.y).min(region.x.safe_div(px_ratio)), + ) }; // Compute the actual size of the fitted image. @@ -219,7 +229,7 @@ impl LayoutSingle for Packed { let fitted = match fit { ImageFit::Cover | ImageFit::Contain => { if wide == (fit == ImageFit::Contain) { - Size::new(target.x, target.x / px_ratio) + Size::new(target.x, target.x.safe_div(px_ratio)) } else { Size::new(target.y * px_ratio, target.y) } @@ -320,6 +330,10 @@ pub enum ImageKind { } impl Image { + /// When scaling an image to it's natural size, we default to this DPI + /// if the image doesn't contain DPI metadata. + pub const DEFAULT_DPI: f64 = 72.0; + /// Create an image from a buffer and a format. #[comemo::memoize] #[typst_macros::time(name = "load image")] @@ -394,6 +408,14 @@ impl Image { } } + /// The image's pixel density in pixels per inch, if known. + pub fn dpi(&self) -> Option { + match &self.0.kind { + ImageKind::Raster(raster) => raster.dpi(), + ImageKind::Svg(_) => None, + } + } + /// A text describing the image. pub fn alt(&self) -> Option<&str> { self.0.alt.as_deref() diff --git a/crates/typst/src/visualize/image/raster.rs b/crates/typst/src/visualize/image/raster.rs index 3c9afe43f..995e34837 100644 --- a/crates/typst/src/visualize/image/raster.rs +++ b/crates/typst/src/visualize/image/raster.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use std::hash::{Hash, Hasher}; use std::io; use std::sync::Arc; @@ -22,6 +23,7 @@ struct Repr { format: RasterFormat, dynamic: image::DynamicImage, icc: Option>, + dpi: Option, } impl RasterImage { @@ -46,11 +48,19 @@ impl RasterImage { } .map_err(format_image_error)?; - if let Some(rotation) = exif_rotation(&data) { + let exif = exif::Reader::new() + .read_from_container(&mut std::io::Cursor::new(&data)) + .ok(); + + // Apply rotation from EXIF metadata. + if let Some(rotation) = exif.as_ref().and_then(exif_rotation) { apply_rotation(&mut dynamic, rotation); } - Ok(Self(Arc::new(Repr { data, format, dynamic, icc }))) + // Extract pixel density. + let dpi = determine_dpi(&data, exif.as_ref()); + + Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi }))) } /// The raw image data. @@ -73,6 +83,11 @@ impl RasterImage { self.dynamic().height() } + /// The image's pixel density in pixels per inch, if known. + pub fn dpi(&self) -> Option { + self.0.dpi + } + /// Access the underlying dynamic image. pub fn dynamic(&self) -> &image::DynamicImage { &self.0.dynamic @@ -133,13 +148,11 @@ impl TryFrom for RasterFormat { } } -/// Get rotation from EXIF metadata. -fn exif_rotation(data: &[u8]) -> Option { - let reader = exif::Reader::new(); - let mut cursor = std::io::Cursor::new(data); - let exif = reader.read_from_container(&mut cursor).ok()?; - let orient = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?; - orient.value.get_uint(0) +/// Try to get the rotation from the EXIF metadata. +fn exif_rotation(exif: &exif::Exif) -> Option { + exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)? + .value + .get_uint(0) } /// Apply an EXIF rotation to a dynamic image. @@ -163,6 +176,87 @@ fn apply_rotation(image: &mut DynamicImage, rotation: u32) { } } +/// Try to determine the DPI (dots per inch) of the image. +fn determine_dpi(data: &[u8], exif: Option<&exif::Exif>) -> Option { + // Try to extract the DPI from the EXIF metadata. If that doesn't yield + // anything, fall back to specialized procedures for extracting JPEG or PNG + // DPI metadata. GIF does not have any. + exif.and_then(exif_dpi) + .or_else(|| jpeg_dpi(data)) + .or_else(|| png_dpi(data)) +} + +/// Try to get the DPI from the EXIF metadata. +fn exif_dpi(exif: &exif::Exif) -> Option { + let axis = |tag| { + let dpi = exif.get_field(tag, exif::In::PRIMARY)?; + let exif::Value::Rational(rational) = &dpi.value else { return None }; + Some(rational.first()?.to_f64()) + }; + + [axis(exif::Tag::XResolution), axis(exif::Tag::YResolution)] + .into_iter() + .flatten() + .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)) +} + +/// Tries to extract the DPI from raw JPEG data (by inspecting the JFIF APP0 +/// section). +fn jpeg_dpi(data: &[u8]) -> Option { + let validate_at = |index: usize, expect: &[u8]| -> Option<()> { + data.get(index..)?.starts_with(expect).then_some(()) + }; + let u16_at = |index: usize| -> Option { + data.get(index..index + 2)?.try_into().ok().map(u16::from_be_bytes) + }; + + validate_at(0, b"\xFF\xD8\xFF\xE0\0")?; + validate_at(6, b"JFIF\0")?; + validate_at(11, b"\x01")?; + + let len = u16_at(4)?; + if len < 16 { + return None; + } + + let units = *data.get(13)?; + let x = u16_at(14)?; + let y = u16_at(16)?; + let dpu = x.max(y) as f64; + + Some(match units { + 1 => dpu, // already inches + 2 => dpu * 2.54, // cm -> inches + _ => return None, + }) +} + +/// Tries to extract the DPI from raw PNG data. +fn png_dpi(mut data: &[u8]) -> Option { + let mut decoder = png::StreamingDecoder::new(); + let dims = loop { + let (consumed, event) = decoder.update(data, &mut Vec::new()).ok()?; + match event { + png::Decoded::PixelDimensions(dims) => break dims, + // Bail as soon as there is anything data-like. + png::Decoded::ChunkBegin(_, png::chunk::IDAT) + | png::Decoded::ImageData + | png::Decoded::ImageEnd => return None, + _ => {} + } + data = data.get(consumed..)?; + if consumed == 0 { + return None; + } + }; + + let dpu = dims.xppu.max(dims.yppu) as f64; + match dims.unit { + png::Unit::Meter => Some(dpu * 0.0254), // meter -> inches + png::Unit::Unspecified => None, + } +} + /// Format the user-facing raster graphic decoding error message. fn format_image_error(error: image::ImageError) -> EcoString { match error { @@ -170,3 +264,24 @@ fn format_image_error(error: image::ImageError) -> EcoString { err => eco_format!("failed to decode image ({err})"), } } + +#[cfg(test)] +mod tests { + use super::{RasterFormat, RasterImage}; + use crate::foundations::Bytes; + + #[test] + fn test_image_dpi() { + #[track_caller] + fn test(path: &str, format: RasterFormat, dpi: f64) { + let data = typst_dev_assets::get(path).unwrap(); + let bytes = Bytes::from_static(data); + let image = RasterImage::new(bytes, format).unwrap(); + assert_eq!(image.dpi().map(f64::round), Some(dpi)); + } + + test("images/f2t.jpg", RasterFormat::Jpg, 220.0); + test("images/tiger.jpg", RasterFormat::Jpg, 72.0); + test("images/graph.png", RasterFormat::Png, 144.0); + } +} diff --git a/crates/typst/src/visualize/image/svg.rs b/crates/typst/src/visualize/image/svg.rs index fe4968077..9685e4547 100644 --- a/crates/typst/src/visualize/image/svg.rs +++ b/crates/typst/src/visualize/image/svg.rs @@ -11,6 +11,7 @@ use crate::diag::{format_xml_like_error, StrResult}; use crate::foundations::Bytes; use crate::layout::Axes; use crate::text::{FontVariant, FontWeight}; +use crate::visualize::Image; use crate::World; /// A decoded SVG. @@ -29,8 +30,7 @@ impl SvgImage { /// Decode an SVG image without fonts. #[comemo::memoize] pub fn new(data: Bytes) -> StrResult { - let opts = usvg::Options::default(); - let tree = usvg::Tree::from_data(&data, &opts).map_err(format_usvg_error)?; + let tree = usvg::Tree::from_data(&data, &options()).map_err(format_usvg_error)?; Ok(Self(Arc::new(Repr { data, size: tree_size(&tree), @@ -47,13 +47,8 @@ impl SvgImage { world: Tracked, families: &[String], ) -> StrResult { - // Disable usvg's default to "Times New Roman". Instead, we default to - // the empty family and later, when we traverse the SVG, we check for - // empty and non-existing family names and replace them with the true - // fallback family. This way, we can memoize SVG decoding with and without - // fonts if the SVG does not contain text. - let opts = usvg::Options { font_family: String::new(), ..Default::default() }; - let mut tree = usvg::Tree::from_data(&data, &opts).map_err(format_usvg_error)?; + let mut tree = + usvg::Tree::from_data(&data, &options()).map_err(format_usvg_error)?; let mut font_hash = 0; if tree.has_text_nodes() { let (fontdb, hash) = load_svg_fonts(world, &mut tree, families); @@ -126,6 +121,22 @@ impl Hash for Repr { } } +/// The conversion options. +fn options() -> usvg::Options { + // Disable usvg's default to "Times New Roman". Instead, we default to + // the empty family and later, when we traverse the SVG, we check for + // empty and non-existing family names and replace them with the true + // fallback family. This way, we can memoize SVG decoding with and without + // fonts if the SVG does not contain text. + usvg::Options { + font_family: String::new(), + // We override the DPI here so that we get the correct the size when + // scaling the image to its natural size. + dpi: Image::DEFAULT_DPI as f32, + ..Default::default() + } +} + /// Discover and load the fonts referenced by an SVG. fn load_svg_fonts( world: Tracked, diff --git a/tests/ref/meta/figure.png b/tests/ref/meta/figure.png index d37a01c30..bcdd0d2fa 100644 Binary files a/tests/ref/meta/figure.png and b/tests/ref/meta/figure.png differ diff --git a/tests/ref/visualize/image-scale.png b/tests/ref/visualize/image-scale.png new file mode 100644 index 000000000..95e9157ec Binary files /dev/null and b/tests/ref/visualize/image-scale.png differ diff --git a/tests/typ/visualize/image-scale.typ b/tests/typ/visualize/image-scale.typ new file mode 100644 index 000000000..7ddf435eb --- /dev/null +++ b/tests/typ/visualize/image-scale.typ @@ -0,0 +1,6 @@ +// Test that images aren't upscaled. + +--- +// Image is just 48x80 at 220dpi. It should not be scaled to fit the page +// width, but rather max out at its natural size. +#image("/assets/images/f2t.jpg")