feat: allow user to choose how images should be scaled

Closes #1400.
This commit is contained in:
frozolotl 2024-12-13 18:35:07 +01:00
parent 4993bae782
commit 9789fdb860
8 changed files with 140 additions and 13 deletions

View File

@ -65,6 +65,7 @@ pub fn layout_image(
world: Some(engine.world), world: Some(engine.world),
families: &families(styles).map(|f| f.as_str()).collect::<Vec<_>>(), families: &families(styles).map(|f| f.as_str()).collect::<Vec<_>>(),
flatten_text: elem.flatten_text(styles), flatten_text: elem.flatten_text(styles),
scaling: elem.scaling(styles),
}, },
) )
.at(span)?; .at(span)?;

View File

@ -20,8 +20,8 @@ use typst_utils::LazyHash;
use crate::diag::{bail, At, SourceResult, StrResult}; use crate::diag::{bail, At, SourceResult, StrResult};
use crate::engine::Engine; use crate::engine::Engine;
use crate::foundations::{ use crate::foundations::{
cast, elem, func, scope, Bytes, Cast, Content, Dict, NativeElement, Packed, Show, cast, elem, func, scope, AutoValue, Bytes, Cast, Content, Dict, NativeElement,
Smart, StyleChain, Packed, Show, Smart, StyleChain, Value,
}; };
use crate::layout::{BlockElem, Length, Rel, Sizing}; use crate::layout::{BlockElem, Length, Rel, Sizing};
use crate::loading::Readable; use crate::loading::Readable;
@ -103,6 +103,12 @@ pub struct ImageElem {
/// output. /// output.
#[default(false)] #[default(false)]
pub flatten_text: bool, pub flatten_text: bool,
/// A hint to the viewer how it should scale the image.
///
/// **Note:** This option may be ignored and results look different
/// depending on the format and viewer.
pub scaling: ImageScaling,
} }
#[scope] #[scope]
@ -141,6 +147,12 @@ impl ImageElem {
/// How the image should adjust itself to a given area. /// How the image should adjust itself to a given area.
#[named] #[named]
fit: Option<ImageFit>, fit: Option<ImageFit>,
/// Whether text in SVG images should be converted into paths.
#[named]
flatten_text: Option<bool>,
/// How the image should be scaled by the viewer.
#[named]
scaling: Option<ImageScaling>,
) -> StrResult<Content> { ) -> StrResult<Content> {
let mut elem = ImageElem::new(EcoString::new(), source); let mut elem = ImageElem::new(EcoString::new(), source);
if let Some(format) = format { if let Some(format) = format {
@ -158,6 +170,15 @@ impl ImageElem {
if let Some(fit) = fit { if let Some(fit) = fit {
elem.push_fit(fit); elem.push_fit(fit);
} }
if let Some(fit) = fit {
elem.push_fit(fit);
}
if let Some(flatten_text) = flatten_text {
elem.push_flatten_text(flatten_text);
}
if let Some(scaling) = scaling {
elem.push_scaling(scaling);
}
Ok(elem.pack().spanned(span)) Ok(elem.pack().spanned(span))
} }
} }
@ -208,6 +229,8 @@ struct Repr {
kind: ImageKind, kind: ImageKind,
/// A text describing the image. /// A text describing the image.
alt: Option<EcoString>, alt: Option<EcoString>,
/// The scaling algorithm to use.
scaling: ImageScaling,
} }
impl Image { impl Image {
@ -247,7 +270,11 @@ impl Image {
} }
}; };
Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt: options.alt.clone() })))) Ok(Self(Arc::new(LazyHash::new(Repr {
kind,
alt: options.alt.clone(),
scaling: options.scaling,
}))))
} }
/// The format of the image. /// The format of the image.
@ -291,6 +318,11 @@ impl Image {
self.0.alt.as_deref() self.0.alt.as_deref()
} }
/// The image scaling algorithm to use for this image.
pub fn scaling(&self) -> ImageScaling {
self.0.scaling
}
/// The decoded image. /// The decoded image.
pub fn kind(&self) -> &ImageKind { pub fn kind(&self) -> &ImageKind {
&self.0.kind &self.0.kind
@ -304,6 +336,7 @@ impl Debug for Image {
.field("width", &self.width()) .field("width", &self.width())
.field("height", &self.height()) .field("height", &self.height())
.field("alt", &self.alt()) .field("alt", &self.alt())
.field("scaling", &self.scaling())
.finish() .finish()
} }
} }
@ -384,6 +417,31 @@ cast! {
v: PixmapFormat => Self::Pixmap(v), v: PixmapFormat => Self::Pixmap(v),
} }
/// The image scaling algorithm a viewer should use.
#[derive(Default, Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum ImageScaling {
/// Use the default scaling algorithm.
#[default]
Auto,
/// Scale photos with a smoothing algorithm such as bilinear interpolation.
Smooth,
/// Scale with nearest neighbor or similar to preserve the pixelated look
/// of the image.
Pixelated,
}
cast! {
ImageScaling,
self => match self {
ImageScaling::Auto => Value::Auto,
ImageScaling::Pixelated => "pixelated".into_value(),
ImageScaling::Smooth => "smooth".into_value(),
},
_: AutoValue => ImageScaling::Auto,
"pixelated" => ImageScaling::Pixelated,
"smooth" => ImageScaling::Smooth,
}
/// A kind of image. /// A kind of image.
#[derive(Hash)] #[derive(Hash)]
pub enum ImageKind { pub enum ImageKind {
@ -397,6 +455,7 @@ pub enum ImageKind {
pub struct ImageOptions<'a> { pub struct ImageOptions<'a> {
pub alt: Option<EcoString>, pub alt: Option<EcoString>,
pub scaling: ImageScaling,
pub world: Option<Tracked<'a, dyn World + 'a>>, pub world: Option<Tracked<'a, dyn World + 'a>>,
pub families: &'a [&'a str], pub families: &'a [&'a str],
pub flatten_text: bool, pub flatten_text: bool,
@ -406,6 +465,7 @@ impl Default for ImageOptions<'_> {
fn default() -> Self { fn default() -> Self {
ImageOptions { ImageOptions {
alt: None, alt: None,
scaling: ImageScaling::Auto,
world: None, world: None,
families: &[], families: &[],
flatten_text: false, flatten_text: false,
@ -416,6 +476,7 @@ impl Default for ImageOptions<'_> {
impl Hash for ImageOptions<'_> { impl Hash for ImageOptions<'_> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.alt.hash(state); self.alt.hash(state);
self.scaling.hash(state);
self.families.hash(state); self.families.hash(state);
self.flatten_text.hash(state); self.flatten_text.hash(state);
} }

View File

@ -5,7 +5,9 @@ use ecow::eco_format;
use image::{DynamicImage, GenericImageView, Rgba}; use image::{DynamicImage, GenericImageView, Rgba};
use pdf_writer::{Chunk, Filter, Finish, Ref}; use pdf_writer::{Chunk, Filter, Finish, Ref};
use typst_library::diag::{At, SourceResult, StrResult}; use typst_library::diag::{At, SourceResult, StrResult};
use typst_library::visualize::{ColorSpace, Image, ImageKind, RasterFormat, SvgImage}; use typst_library::visualize::{
ColorSpace, Image, ImageKind, ImageScaling, RasterFormat, SvgImage,
};
use typst_utils::Deferred; use typst_utils::Deferred;
use crate::{color, deflate, PdfChunk, WithGlobalRefs}; use crate::{color, deflate, PdfChunk, WithGlobalRefs};
@ -36,6 +38,7 @@ pub fn write_images(
height, height,
icc_profile, icc_profile,
alpha, alpha,
interpolate,
} => { } => {
let image_ref = chunk.alloc(); let image_ref = chunk.alloc();
out.insert(image.clone(), image_ref); out.insert(image.clone(), image_ref);
@ -45,6 +48,7 @@ pub fn write_images(
image.width(*width as i32); image.width(*width as i32);
image.height(*height as i32); image.height(*height as i32);
image.bits_per_component(i32::from(*bits_per_component)); image.bits_per_component(i32::from(*bits_per_component));
image.interpolate(*interpolate);
let mut icc_ref = None; let mut icc_ref = None;
let space = image.color_space(); let space = image.color_space();
@ -73,6 +77,7 @@ pub fn write_images(
mask.height(*height as i32); mask.height(*height as i32);
mask.color_space().device_gray(); mask.color_space().device_gray();
mask.bits_per_component(i32::from(*bits_per_component)); mask.bits_per_component(i32::from(*bits_per_component));
mask.interpolate(*interpolate);
} else { } else {
image.finish(); image.finish();
} }
@ -127,6 +132,10 @@ pub fn deferred_image(
_ => None, _ => None,
}; };
// PDF/A does not appear to allow interpolation[^1].
// [^1]: https://github.com/typst/typst/issues/2942
let interpolate = image.scaling() == ImageScaling::Smooth && !pdfa;
let deferred = Deferred::new(move || match image.kind() { let deferred = Deferred::new(move || match image.kind() {
ImageKind::Raster(raster) => { ImageKind::Raster(raster) => {
let format = if raster.format() == RasterFormat::Jpg { let format = if raster.format() == RasterFormat::Jpg {
@ -134,7 +143,12 @@ pub fn deferred_image(
} else { } else {
EncodeFormat::Flate EncodeFormat::Flate
}; };
Ok(encode_raster_image(&raster.dynamic(), raster.icc_profile(), format)) Ok(encode_raster_image(
raster.dynamic(),
raster.icc_profile(),
format,
interpolate,
))
} }
ImageKind::Svg(svg) => { ImageKind::Svg(svg) => {
let (chunk, id) = encode_svg(svg, pdfa) let (chunk, id) = encode_svg(svg, pdfa)
@ -145,6 +159,7 @@ pub fn deferred_image(
&pixmap.to_image(), &pixmap.to_image(),
pixmap.icc_profile(), pixmap.icc_profile(),
EncodeFormat::Flate, EncodeFormat::Flate,
interpolate,
)), )),
}); });
@ -157,6 +172,7 @@ fn encode_raster_image(
image: &DynamicImage, image: &DynamicImage,
icc_profile: Option<&[u8]>, icc_profile: Option<&[u8]>,
format: EncodeFormat, format: EncodeFormat,
interpolate: bool,
) -> EncodedImage { ) -> EncodedImage {
let color_space = to_color_space(image.color()); let color_space = to_color_space(image.color());
@ -192,6 +208,7 @@ fn encode_raster_image(
height: image.height(), height: image.height(),
icc_profile: compressed_icc, icc_profile: compressed_icc,
alpha, alpha,
interpolate,
} }
} }
@ -248,6 +265,8 @@ pub enum EncodedImage {
icc_profile: Option<Vec<u8>>, icc_profile: Option<Vec<u8>>,
/// The alpha channel of the image, pre-deflated, if any. /// The alpha channel of the image, pre-deflated, if any.
alpha: Option<(Vec<u8>, Filter)>, alpha: Option<(Vec<u8>, Filter)>,
/// Whether image interpolation should be enabled.
interpolate: bool,
}, },
/// A vector graphic. /// A vector graphic.
/// ///

View File

@ -4,7 +4,7 @@ use image::imageops::FilterType;
use image::{GenericImageView, Rgba}; use image::{GenericImageView, Rgba};
use tiny_skia as sk; use tiny_skia as sk;
use typst_library::layout::Size; use typst_library::layout::Size;
use typst_library::visualize::{Image, ImageKind}; use typst_library::visualize::{Image, ImageKind, ImageScaling};
use crate::{AbsExt, State}; use crate::{AbsExt, State};
@ -59,8 +59,10 @@ pub fn render_image(
#[comemo::memoize] #[comemo::memoize]
fn build_texture(image: &Image, w: u32, h: u32) -> Option<Arc<sk::Pixmap>> { fn build_texture(image: &Image, w: u32, h: u32) -> Option<Arc<sk::Pixmap>> {
match image.kind() { match image.kind() {
ImageKind::Raster(raster) => scale_image(raster.dynamic(), w, h), ImageKind::Raster(raster) => scale_image(raster.dynamic(), image.scaling(), w, h),
ImageKind::Pixmap(raster) => scale_image(&raster.to_image(), w, h), ImageKind::Pixmap(raster) => {
scale_image(&raster.to_image(), image.scaling(), w, h)
}
// Safety: We do not keep any references to tree nodes beyond the scope // Safety: We do not keep any references to tree nodes beyond the scope
// of `with`. // of `with`.
ImageKind::Svg(svg) => { ImageKind::Svg(svg) => {
@ -78,10 +80,20 @@ fn build_texture(image: &Image, w: u32, h: u32) -> Option<Arc<sk::Pixmap>> {
/// Scale a rastered image to a given size and return texture. /// Scale a rastered image to a given size and return texture.
// TODO(frozolotl): optimize pixmap allocation // TODO(frozolotl): optimize pixmap allocation
fn scale_image(image: &image::DynamicImage, w: u32, h: u32) -> Option<Arc<sk::Pixmap>> { fn scale_image(
image: &image::DynamicImage,
scaling: ImageScaling,
w: u32,
h: u32,
) -> Option<Arc<sk::Pixmap>> {
let mut pixmap = sk::Pixmap::new(w, h)?; let mut pixmap = sk::Pixmap::new(w, h)?;
let downscale = w < image.width(); let upscale = w > image.width();
let filter = if downscale { FilterType::Lanczos3 } else { FilterType::CatmullRom }; let filter = match scaling {
ImageScaling::Auto if upscale => FilterType::CatmullRom,
ImageScaling::Smooth if upscale => FilterType::CatmullRom,
ImageScaling::Pixelated => FilterType::Nearest,
_ => FilterType::Lanczos3, // downscale
};
let buf = image.resize(w, h, filter); let buf = image.resize(w, h, filter);
for ((_, _, src), dest) in buf.pixels().zip(pixmap.pixels_mut()) { for ((_, _, src), dest) in buf.pixels().zip(pixmap.pixels_mut()) {
let Rgba([r, g, b, a]) = src; let Rgba([r, g, b, a]) = src;

View File

@ -6,7 +6,7 @@ use image::error::UnsupportedError;
use image::{codecs::png::PngEncoder, ImageEncoder}; use image::{codecs::png::PngEncoder, ImageEncoder};
use typst_library::layout::{Abs, Axes}; use typst_library::layout::{Abs, Axes};
use typst_library::visualize::{ use typst_library::visualize::{
Image, ImageFormat, ImageKind, RasterFormat, VectorFormat, Image, ImageFormat, ImageKind, ImageScaling, RasterFormat, VectorFormat,
}; };
use crate::SVGRenderer; use crate::SVGRenderer;
@ -20,6 +20,17 @@ impl SVGRenderer {
self.xml.write_attribute("width", &size.x.to_pt()); self.xml.write_attribute("width", &size.x.to_pt());
self.xml.write_attribute("height", &size.y.to_pt()); self.xml.write_attribute("height", &size.y.to_pt());
self.xml.write_attribute("preserveAspectRatio", "none"); self.xml.write_attribute("preserveAspectRatio", "none");
match image.scaling() {
ImageScaling::Auto => {}
ImageScaling::Smooth => {
// This is still experimental and not implemented in all major browsers[^1].
// [^1]: https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering#browser_compatibility
self.xml.write_attribute("style", "image-rendering: smooth")
}
ImageScaling::Pixelated => {
self.xml.write_attribute("style", "image-rendering: pixelated")
}
}
self.xml.end_element(); self.xml.end_element();
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -71,7 +71,7 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B
data: bytes(( data: bytes((
0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF,
0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80,
0x80, 0x80, 0x00, 0x80, 0x80, 0x00, 0x80, 0x00, 0x80, 0x80, 0x80, 0x00, 0x00, 0x80, 0x80, 0x80, 0x00, 0x80,
)), )),
pixel-width: 3, pixel-width: 3,
pixel-height: 3, pixel-height: 3,
@ -117,6 +117,29 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B
width: 1cm, width: 1cm,
) )
--- image-scaling-methods ---
#let img(scaling) = image.decode(
(
data: bytes((
0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF,
0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80,
0x80, 0x80, 0x00, 0x00, 0x80, 0x80, 0x80, 0x00, 0x80,
)),
pixel-width: 3,
pixel-height: 3,
),
format: "rgb8",
width: 1cm,
scaling: scaling,
)
#stack(
dir: ltr,
spacing: 4pt,
img(auto),
img("smooth"),
img("pixelated"),
)
--- image-natural-dpi-sizing --- --- image-natural-dpi-sizing ---
// Test that images aren't upscaled. // Test that images aren't upscaled.
// Image is just 48x80 at 220dpi. It should not be scaled to fit the page // Image is just 48x80 at 220dpi. It should not be scaled to fit the page