mirror of
https://github.com/typst/typst
synced 2025-08-14 07:07:54 +08:00
423 lines
12 KiB
Rust
423 lines
12 KiB
Rust
//! Image handling.
|
|
|
|
mod pixmap;
|
|
mod raster;
|
|
mod svg;
|
|
|
|
pub use self::raster::{RasterFormat, RasterImage};
|
|
pub use self::svg::SvgImage;
|
|
|
|
use std::fmt::{self, Debug, Formatter};
|
|
use std::hash::Hash;
|
|
use std::sync::Arc;
|
|
|
|
use comemo::Tracked;
|
|
use ecow::EcoString;
|
|
use pixmap::{Pixmap, PixmapFormat, PixmapSource};
|
|
use typst_syntax::{Span, Spanned};
|
|
use typst_utils::LazyHash;
|
|
|
|
use crate::diag::{bail, At, SourceResult, StrResult};
|
|
use crate::engine::Engine;
|
|
use crate::foundations::{
|
|
cast, elem, func, scope, Bytes, Cast, Content, Dict, NativeElement, Packed, Show,
|
|
Smart, StyleChain,
|
|
};
|
|
use crate::layout::{BlockElem, Length, Rel, Sizing};
|
|
use crate::loading::Readable;
|
|
use crate::model::Figurable;
|
|
use crate::text::LocalName;
|
|
use crate::World;
|
|
|
|
/// A raster or vector graphic.
|
|
///
|
|
/// You can wrap the image in a [`figure`] to give it a number and caption.
|
|
///
|
|
/// Like most elements, images are _block-level_ by default and thus do not
|
|
/// integrate themselves into adjacent paragraphs. To force an image to become
|
|
/// inline, put it into a [`box`].
|
|
///
|
|
/// # Example
|
|
/// ```example
|
|
/// #figure(
|
|
/// image("molecular.jpg", width: 80%),
|
|
/// caption: [
|
|
/// A step in the molecular testing
|
|
/// pipeline of our lab.
|
|
/// ],
|
|
/// )
|
|
/// ```
|
|
#[elem(scope, Show, LocalName, Figurable)]
|
|
pub struct ImageElem {
|
|
/// Path to an image file.
|
|
///
|
|
/// For more details, see the [Paths section]($syntax/#paths).
|
|
#[required]
|
|
#[parse(
|
|
let Spanned { v: path, span } =
|
|
args.expect::<Spanned<EcoString>>("path to image file")?;
|
|
let id = span.resolve_path(&path).at(span)?;
|
|
let data = engine.world.file(id).at(span)?;
|
|
path
|
|
)]
|
|
#[borrowed]
|
|
pub path: EcoString,
|
|
|
|
/// The data required to decode the image.
|
|
#[internal]
|
|
#[required]
|
|
#[parse(data.into())]
|
|
pub source: ImageSource,
|
|
|
|
/// The image's format. Detected automatically by default.
|
|
///
|
|
/// Supported formats are PNG, JPEG, GIF, and SVG. Using a PDF as an image
|
|
/// is [not currently supported](https://github.com/typst/typst/issues/145).
|
|
pub format: Smart<ImageFormat>,
|
|
|
|
/// The width of the image.
|
|
pub width: Smart<Rel<Length>>,
|
|
|
|
/// The height of the image.
|
|
pub height: Sizing,
|
|
|
|
/// A text describing the image.
|
|
pub alt: Option<EcoString>,
|
|
|
|
/// How the image should adjust itself to a given area (the area is defined
|
|
/// by the `width` and `height` fields). Note that `fit` doesn't visually
|
|
/// change anything if the area's aspect ratio is the same as the image's
|
|
/// one.
|
|
///
|
|
/// ```example
|
|
/// #set page(width: 300pt, height: 50pt, margin: 10pt)
|
|
/// #image("tiger.jpg", width: 100%, fit: "cover")
|
|
/// #image("tiger.jpg", width: 100%, fit: "contain")
|
|
/// #image("tiger.jpg", width: 100%, fit: "stretch")
|
|
/// ```
|
|
#[default(ImageFit::Cover)]
|
|
pub fit: ImageFit,
|
|
|
|
/// Whether text in SVG images should be converted into curves before
|
|
/// embedding. This will result in the text becoming unselectable in the
|
|
/// output.
|
|
#[default(false)]
|
|
pub flatten_text: bool,
|
|
}
|
|
|
|
#[scope]
|
|
#[allow(clippy::too_many_arguments)]
|
|
impl ImageElem {
|
|
/// Decode a raster or vector graphic from bytes or a string.
|
|
///
|
|
/// ```example
|
|
/// #let original = read("diagram.svg")
|
|
/// #let changed = original.replace(
|
|
/// "#2B80FF", // blue
|
|
/// green.to-hex(),
|
|
/// )
|
|
///
|
|
/// #image.decode(original)
|
|
/// #image.decode(changed)
|
|
/// ```
|
|
#[func(title = "Decode Image")]
|
|
pub fn decode(
|
|
/// The call span of this function.
|
|
span: Span,
|
|
/// The data to decode as an image. Can be a string for SVGs.
|
|
source: ImageSource,
|
|
/// The image's format. Detected automatically by default.
|
|
#[named]
|
|
format: Option<Smart<ImageFormat>>,
|
|
/// The width of the image.
|
|
#[named]
|
|
width: Option<Smart<Rel<Length>>>,
|
|
/// The height of the image.
|
|
#[named]
|
|
height: Option<Sizing>,
|
|
/// A text describing the image.
|
|
#[named]
|
|
alt: Option<Option<EcoString>>,
|
|
/// How the image should adjust itself to a given area.
|
|
#[named]
|
|
fit: Option<ImageFit>,
|
|
) -> StrResult<Content> {
|
|
let mut elem = ImageElem::new(EcoString::new(), source);
|
|
if let Some(format) = format {
|
|
elem.push_format(format);
|
|
}
|
|
if let Some(width) = width {
|
|
elem.push_width(width);
|
|
}
|
|
if let Some(height) = height {
|
|
elem.push_height(height);
|
|
}
|
|
if let Some(alt) = alt {
|
|
elem.push_alt(alt);
|
|
}
|
|
if let Some(fit) = fit {
|
|
elem.push_fit(fit);
|
|
}
|
|
Ok(elem.pack().spanned(span))
|
|
}
|
|
}
|
|
|
|
impl Show for Packed<ImageElem> {
|
|
fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
|
|
Ok(BlockElem::single_layouter(self.clone(), engine.routines.layout_image)
|
|
.with_width(self.width(styles))
|
|
.with_height(self.height(styles))
|
|
.pack()
|
|
.spanned(self.span()))
|
|
}
|
|
}
|
|
|
|
impl LocalName for Packed<ImageElem> {
|
|
const KEY: &'static str = "figure";
|
|
}
|
|
|
|
impl Figurable for Packed<ImageElem> {}
|
|
|
|
/// How an image should adjust itself to a given area,
|
|
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
|
|
pub enum ImageFit {
|
|
/// The image should completely cover the area (preserves aspect ratio by
|
|
/// cropping the image only horizontally or vertically). This is the
|
|
/// default.
|
|
Cover,
|
|
/// The image should be fully contained in the area (preserves aspect
|
|
/// ratio; doesn't crop the image; one dimension can be narrower than
|
|
/// specified).
|
|
Contain,
|
|
/// The image should be stretched so that it exactly fills the area, even if
|
|
/// this means that the image will be distorted (doesn't preserve aspect
|
|
/// ratio and doesn't crop the image).
|
|
Stretch,
|
|
}
|
|
|
|
/// A loaded raster or vector image.
|
|
///
|
|
/// Values of this type are cheap to clone and hash.
|
|
#[derive(Clone, Hash, Eq, PartialEq)]
|
|
pub struct Image(Arc<LazyHash<Repr>>);
|
|
|
|
/// The internal representation.
|
|
#[derive(Hash)]
|
|
struct Repr {
|
|
/// The raw, undecoded image data.
|
|
kind: ImageKind,
|
|
/// A text describing the image.
|
|
alt: Option<EcoString>,
|
|
}
|
|
|
|
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;
|
|
|
|
/// Should always be the same as the default DPI used by usvg.
|
|
pub const USVG_DEFAULT_DPI: f64 = 96.0;
|
|
|
|
/// Create an image from a source and a format.
|
|
#[comemo::memoize]
|
|
#[typst_macros::time(name = "load image")]
|
|
pub fn new(
|
|
source: ImageSource,
|
|
format: ImageFormat,
|
|
options: &ImageOptions,
|
|
) -> StrResult<Image> {
|
|
let kind = match format {
|
|
ImageFormat::Raster(format) => {
|
|
let ImageSource::Readable(readable) = source else {
|
|
bail!("expected readable source for the given format (str or bytes)");
|
|
};
|
|
ImageKind::Raster(RasterImage::new(readable.into(), format)?)
|
|
}
|
|
ImageFormat::Vector(VectorFormat::Svg) => {
|
|
let ImageSource::Readable(readable) = source else {
|
|
bail!("expected readable source for the given format (str or bytes)");
|
|
};
|
|
ImageKind::Svg(SvgImage::new(readable.into(), options)?)
|
|
}
|
|
ImageFormat::Pixmap(format) => {
|
|
let ImageSource::Pixmap(source) = source else {
|
|
bail!("source must be a pixmap");
|
|
};
|
|
ImageKind::Pixmap(Pixmap::new(source, format)?)
|
|
}
|
|
};
|
|
|
|
Ok(Self(Arc::new(LazyHash::new(Repr { kind, alt: options.alt.clone() }))))
|
|
}
|
|
|
|
/// The format of the image.
|
|
pub fn format(&self) -> ImageFormat {
|
|
match &self.0.kind {
|
|
ImageKind::Raster(raster) => raster.format().into(),
|
|
ImageKind::Svg(_) => VectorFormat::Svg.into(),
|
|
ImageKind::Pixmap(pixmap) => pixmap.format().into(),
|
|
}
|
|
}
|
|
|
|
/// The width of the image in pixels.
|
|
pub fn width(&self) -> f64 {
|
|
match &self.0.kind {
|
|
ImageKind::Raster(raster) => raster.width() as f64,
|
|
ImageKind::Svg(svg) => svg.width(),
|
|
ImageKind::Pixmap(pixmap) => pixmap.width() as f64,
|
|
}
|
|
}
|
|
|
|
/// The height of the image in pixels.
|
|
pub fn height(&self) -> f64 {
|
|
match &self.0.kind {
|
|
ImageKind::Raster(raster) => raster.height() as f64,
|
|
ImageKind::Svg(svg) => svg.height(),
|
|
ImageKind::Pixmap(pixmap) => pixmap.height() as f64,
|
|
}
|
|
}
|
|
|
|
/// 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(_) => Some(Image::USVG_DEFAULT_DPI),
|
|
ImageKind::Pixmap(_) => None,
|
|
}
|
|
}
|
|
|
|
/// A text describing the image.
|
|
pub fn alt(&self) -> Option<&str> {
|
|
self.0.alt.as_deref()
|
|
}
|
|
|
|
/// The decoded image.
|
|
pub fn kind(&self) -> &ImageKind {
|
|
&self.0.kind
|
|
}
|
|
}
|
|
|
|
impl Debug for Image {
|
|
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
|
f.debug_struct("Image")
|
|
.field("format", &self.format())
|
|
.field("width", &self.width())
|
|
.field("height", &self.height())
|
|
.field("alt", &self.alt())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Information required to decode an image.
|
|
#[derive(Debug, Clone, PartialEq, Hash)]
|
|
pub enum ImageSource {
|
|
Readable(Readable),
|
|
Pixmap(Arc<PixmapSource>),
|
|
}
|
|
|
|
impl From<Bytes> for ImageSource {
|
|
fn from(bytes: Bytes) -> Self {
|
|
ImageSource::Readable(Readable::Bytes(bytes))
|
|
}
|
|
}
|
|
|
|
cast! {
|
|
ImageSource,
|
|
data: Readable => ImageSource::Readable(data),
|
|
mut dict: Dict => {
|
|
let source = ImageSource::Pixmap(Arc::new(PixmapSource {
|
|
data: dict.take("data")?.cast()?,
|
|
pixel_width: dict.take("pixel-width")?.cast()?,
|
|
pixel_height: dict.take("pixel-height")?.cast()?,
|
|
icc_profile: dict.take("icc-profile").ok().map(|value| value.cast()).transpose()?,
|
|
}));
|
|
dict.finish(&["data", "pixel-width", "pixel-height", "icc-profile"])?;
|
|
source
|
|
},
|
|
}
|
|
|
|
/// A raster or vector image format.
|
|
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
|
|
pub enum ImageFormat {
|
|
/// A raster graphics format.
|
|
Raster(RasterFormat),
|
|
/// A vector graphics format.
|
|
Vector(VectorFormat),
|
|
/// A format made up of flat pixels without metadata or compression.
|
|
Pixmap(PixmapFormat),
|
|
}
|
|
|
|
/// A vector graphics format.
|
|
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
|
|
pub enum VectorFormat {
|
|
/// The vector graphics format of the web.
|
|
Svg,
|
|
}
|
|
|
|
impl From<RasterFormat> for ImageFormat {
|
|
fn from(format: RasterFormat) -> Self {
|
|
Self::Raster(format)
|
|
}
|
|
}
|
|
|
|
impl From<VectorFormat> for ImageFormat {
|
|
fn from(format: VectorFormat) -> Self {
|
|
Self::Vector(format)
|
|
}
|
|
}
|
|
|
|
impl From<PixmapFormat> for ImageFormat {
|
|
fn from(format: PixmapFormat) -> Self {
|
|
Self::Pixmap(format)
|
|
}
|
|
}
|
|
|
|
cast! {
|
|
ImageFormat,
|
|
self => match self {
|
|
Self::Raster(v) => v.into_value(),
|
|
Self::Vector(v) => v.into_value(),
|
|
Self::Pixmap(v) => v.into_value(),
|
|
},
|
|
v: RasterFormat => Self::Raster(v),
|
|
v: VectorFormat => Self::Vector(v),
|
|
v: PixmapFormat => Self::Pixmap(v),
|
|
}
|
|
|
|
/// A kind of image.
|
|
#[derive(Hash)]
|
|
pub enum ImageKind {
|
|
/// A raster image.
|
|
Raster(RasterImage),
|
|
/// An SVG image.
|
|
Svg(SvgImage),
|
|
/// An image constructed from a pixmap.
|
|
Pixmap(Pixmap),
|
|
}
|
|
|
|
pub struct ImageOptions<'a> {
|
|
pub alt: Option<EcoString>,
|
|
pub world: Option<Tracked<'a, dyn World + 'a>>,
|
|
pub families: &'a [&'a str],
|
|
pub flatten_text: bool,
|
|
}
|
|
|
|
impl Default for ImageOptions<'_> {
|
|
fn default() -> Self {
|
|
ImageOptions {
|
|
alt: None,
|
|
world: None,
|
|
families: &[],
|
|
flatten_text: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Hash for ImageOptions<'_> {
|
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
|
self.alt.hash(state);
|
|
self.families.hash(state);
|
|
self.flatten_text.hash(state);
|
|
}
|
|
}
|