summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2024-03-07 16:05:52 +0100
committerGitHub <noreply@github.com>2024-03-07 15:05:52 +0000
commit1d32145319211b6faed1f2ce4892347fde7a3f50 (patch)
tree177ac95f7e804d55ba9c407fb5dcacadea98c382 /crates
parenta483321aa08e435c159d19c0d559e2413c1272f9 (diff)
DPI-based natural sizing for images (#3571)
Diffstat (limited to 'crates')
-rw-r--r--crates/typst/Cargo.toml4
-rw-r--r--crates/typst/src/visualize/image/mod.rs36
-rw-r--r--crates/typst/src/visualize/image/raster.rs133
-rw-r--r--crates/typst/src/visualize/image/svg.rs29
4 files changed, 177 insertions, 25 deletions
diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml
index 77809ed1..889d41e7 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 e7c91cd6..199564c6 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<ImageElem> {
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<ImageElem> {
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<f64> {
+ 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 3c9afe43..995e3483 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<Vec<u8>>,
+ dpi: Option<f64>,
}
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<f64> {
+ self.0.dpi
+ }
+
/// Access the underlying dynamic image.
pub fn dynamic(&self) -> &image::DynamicImage {
&self.0.dynamic
@@ -133,13 +148,11 @@ impl TryFrom<image::ImageFormat> for RasterFormat {
}
}
-/// Get rotation from EXIF metadata.
-fn exif_rotation(data: &[u8]) -> Option<u32> {
- 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<u32> {
+ 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<f64> {
+ // 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<f64> {
+ 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<f64> {
+ let validate_at = |index: usize, expect: &[u8]| -> Option<()> {
+ data.get(index..)?.starts_with(expect).then_some(())
+ };
+ let u16_at = |index: usize| -> Option<u16> {
+ 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<f64> {
+ 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 fe496807..9685e454 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<SvgImage> {
- 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<dyn World + '_>,
families: &[String],
) -> StrResult<SvgImage> {
- // 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<dyn World + '_>,