DPI-based natural sizing for images (#3571)
This commit is contained in:
parent
a483321aa0
commit
1d32145319
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 + '_>,
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
BIN
tests/ref/visualize/image-scale.png
Normal file
BIN
tests/ref/visualize/image-scale.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 314 B |
6
tests/typ/visualize/image-scale.typ
Normal file
6
tests/typ/visualize/image-scale.typ
Normal file
@ -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")
|
Loading…
x
Reference in New Issue
Block a user