typst_library/visualize/image/
mod.rs

1//! Image handling.
2
3mod pdf;
4mod raster;
5mod svg;
6
7pub use self::pdf::PdfImage;
8pub use self::raster::{
9    ExchangeFormat, PixelEncoding, PixelFormat, RasterFormat, RasterImage,
10};
11pub use self::svg::SvgImage;
12
13use std::ffi::OsStr;
14use std::fmt::{self, Debug, Formatter};
15use std::num::NonZeroUsize;
16use std::sync::Arc;
17
18use ecow::EcoString;
19use hayro_syntax::LoadPdfError;
20use typst_syntax::{Span, Spanned};
21use typst_utils::{LazyHash, NonZeroExt};
22
23use crate::diag::{At, LoadedWithin, SourceResult, StrResult, bail, warning};
24use crate::engine::Engine;
25use crate::foundations::{
26    Bytes, Cast, Content, Derived, NativeElement, Packed, Smart, StyleChain, Synthesize,
27    cast, elem, func, scope,
28};
29use crate::introspection::{Locatable, Tagged};
30use crate::layout::{Length, Rel, Sizing};
31use crate::loading::{DataSource, Load, LoadSource, Loaded, Readable};
32use crate::model::Figurable;
33use crate::text::{LocalName, Locale, families};
34use crate::visualize::image::pdf::PdfDocument;
35
36/// A raster or vector graphic.
37///
38/// You can wrap the image in a [`figure`] to give it a number and caption.
39///
40/// Like most elements, images are _block-level_ by default and thus do not
41/// integrate themselves into adjacent paragraphs. To force an image to become
42/// inline, put it into a [`box`].
43///
44/// # Example
45/// ```example
46/// #figure(
47///   image("molecular.jpg", width: 80%),
48///   caption: [
49///     A step in the molecular testing
50///     pipeline of our lab.
51///   ],
52/// )
53/// ```
54#[elem(scope, Locatable, Tagged, Synthesize, LocalName, Figurable)]
55pub struct ImageElem {
56    /// A [path]($syntax/#paths) to an image file or raw bytes making up an
57    /// image in one of the supported [formats]($image.format).
58    ///
59    /// Bytes can be used to specify raw pixel data in a row-major,
60    /// left-to-right, top-to-bottom format.
61    ///
62    /// ```example
63    /// #let original = read("diagram.svg")
64    /// #let changed = original.replace(
65    ///   "#2B80FF", // blue
66    ///   green.to-hex(),
67    /// )
68    ///
69    /// #image(bytes(original))
70    /// #image(bytes(changed))
71    /// ```
72    #[required]
73    #[parse(
74        let source = args.expect::<Spanned<DataSource>>("source")?;
75        let loaded = source.load(engine.world)?;
76        Derived::new(source.v, loaded)
77    )]
78    pub source: Derived<DataSource, Loaded>,
79
80    /// The image's format.
81    ///
82    /// By default, the format is detected automatically. Typically, you thus
83    /// only need to specify this when providing raw bytes as the
84    /// [`source`]($image.source) (even then, Typst will try to figure out the
85    /// format automatically, but that's not always possible).
86    ///
87    /// Supported formats are `{"png"}`, `{"jpg"}`, `{"gif"}`, `{"svg"}`,
88    /// `{"pdf"}`, `{"webp"}` as well as raw pixel data.
89    ///
90    /// Note that several restrictions apply when using PDF files as images:
91    ///
92    /// - When exporting to PDF, any PDF image file used must have a version
93    ///   equal to or lower than the [export target PDF
94    ///   version]($pdf/#pdf-versions).
95    /// - PDF files as images are currently not supported when exporting with a
96    ///   specific PDF standard, like PDF/A-3 or PDF/UA-1. In these cases, you
97    ///   can instead use SVGs to embed vector images.
98    /// - The image file must not be password-protected.
99    /// - Tags in your PDF image will not be preserved. Instead, you must
100    ///   provide an [alternative description]($image.alt) to make the image
101    ///   accessible.
102    ///
103    /// When providing raw pixel data as the `source`, you must specify a
104    /// dictionary with the following keys as the `format`:
105    /// - `encoding` ([str]): The encoding of the pixel data. One of:
106    ///   - `{"rgb8"}` (three 8-bit channels: red, green, blue)
107    ///   - `{"rgba8"}` (four 8-bit channels: red, green, blue, alpha)
108    ///   - `{"luma8"}` (one 8-bit channel)
109    ///   - `{"lumaa8"}` (two 8-bit channels: luma and alpha)
110    /// - `width` ([int]): The pixel width of the image.
111    /// - `height` ([int]): The pixel height of the image.
112    ///
113    /// The pixel width multiplied by the height multiplied by the channel count
114    /// for the specified encoding must then match the `source` data.
115    ///
116    /// ```example
117    /// #image(
118    ///   read(
119    ///     "tetrahedron.svg",
120    ///     encoding: none,
121    ///   ),
122    ///   format: "svg",
123    ///   width: 2cm,
124    /// )
125    ///
126    /// #image(
127    ///   bytes(range(16).map(x => x * 16)),
128    ///   format: (
129    ///     encoding: "luma8",
130    ///     width: 4,
131    ///     height: 4,
132    ///   ),
133    ///   width: 2cm,
134    /// )
135    /// ```
136    pub format: Smart<ImageFormat>,
137
138    /// The width of the image.
139    pub width: Smart<Rel<Length>>,
140
141    /// The height of the image.
142    pub height: Sizing,
143
144    /// An alternative description of the image.
145    ///
146    /// This text is used by Assistive Technology (AT) like screen readers to
147    /// describe the image to users with visual impairments.
148    ///
149    /// When the image is wrapped in a [`figure`]($figure), use this parameter
150    /// rather than the [figure's `alt` parameter]($figure.alt) to describe the
151    /// image. The only exception to this rule is when the image and the other
152    /// contents in the figure form a single semantic unit. In this case, use
153    /// the figure's `alt` parameter to describe the entire composition and do
154    /// not use this parameter.
155    ///
156    /// You can learn how to write good alternative descriptions in the
157    /// [Accessibility Guide]($guides/accessibility/#textual-representations).
158    pub alt: Option<EcoString>,
159
160    /// The page number that should be embedded as an image. This attribute only
161    /// has an effect for PDF files.
162    #[default(NonZeroUsize::ONE)]
163    pub page: NonZeroUsize,
164
165    /// How the image should adjust itself to a given area (the area is defined
166    /// by the `width` and `height` fields). Note that `fit` doesn't visually
167    /// change anything if the area's aspect ratio is the same as the image's
168    /// one.
169    ///
170    /// ```example
171    /// #set page(width: 300pt, height: 50pt, margin: 10pt)
172    /// #image("tiger.jpg", width: 100%, fit: "cover")
173    /// #image("tiger.jpg", width: 100%, fit: "contain")
174    /// #image("tiger.jpg", width: 100%, fit: "stretch")
175    /// ```
176    #[default(ImageFit::Cover)]
177    pub fit: ImageFit,
178
179    /// A hint to viewers how they should scale the image.
180    ///
181    /// When set to `{auto}`, the default is left up to the viewer. For PNG
182    /// export, Typst will default to smooth scaling, like most PDF and SVG
183    /// viewers.
184    ///
185    /// _Note:_ The exact look may differ across PDF viewers.
186    pub scaling: Smart<ImageScaling>,
187
188    /// An ICC profile for the image.
189    ///
190    /// ICC profiles define how to interpret the colors in an image. When set
191    /// to `{auto}`, Typst will try to extract an ICC profile from the image.
192    #[parse(match args.named::<Spanned<Smart<DataSource>>>("icc")? {
193        Some(Spanned { v: Smart::Custom(source), span }) => Some(Smart::Custom({
194            let loaded = Spanned::new(&source, span).load(engine.world)?;
195            Derived::new(source, loaded.data)
196        })),
197        Some(Spanned { v: Smart::Auto, .. }) => Some(Smart::Auto),
198        None => None,
199    })]
200    pub icc: Smart<Derived<DataSource, Bytes>>,
201
202    /// The locale of this element (used for the alternative description).
203    #[internal]
204    #[synthesized]
205    pub locale: Locale,
206}
207
208impl Synthesize for Packed<ImageElem> {
209    fn synthesize(&mut self, _: &mut Engine, styles: StyleChain) -> SourceResult<()> {
210        self.locale = Some(Locale::get_in(styles));
211        Ok(())
212    }
213}
214
215#[scope]
216#[allow(clippy::too_many_arguments)]
217impl ImageElem {
218    /// Decode a raster or vector graphic from bytes or a string.
219    #[func(title = "Decode Image")]
220    #[deprecated(
221        message = "`image.decode` is deprecated, directly pass bytes to `image` instead",
222        until = "0.15.0"
223    )]
224    pub fn decode(
225        span: Span,
226        /// The data to decode as an image. Can be a string for SVGs.
227        data: Spanned<Readable>,
228        /// The image's format. Detected automatically by default.
229        #[named]
230        format: Option<Smart<ImageFormat>>,
231        /// The width of the image.
232        #[named]
233        width: Option<Smart<Rel<Length>>>,
234        /// The height of the image.
235        #[named]
236        height: Option<Sizing>,
237        /// A text describing the image.
238        #[named]
239        alt: Option<Option<EcoString>>,
240        /// How the image should adjust itself to a given area.
241        #[named]
242        fit: Option<ImageFit>,
243        /// A hint to viewers how they should scale the image.
244        #[named]
245        scaling: Option<Smart<ImageScaling>>,
246    ) -> StrResult<Content> {
247        let bytes = data.v.into_bytes();
248        let loaded =
249            Loaded::new(Spanned::new(LoadSource::Bytes, data.span), bytes.clone());
250        let source = Derived::new(DataSource::Bytes(bytes), loaded);
251        let mut elem = ImageElem::new(source);
252        if let Some(format) = format {
253            elem.format.set(format);
254        }
255        if let Some(width) = width {
256            elem.width.set(width);
257        }
258        if let Some(height) = height {
259            elem.height.set(height);
260        }
261        if let Some(alt) = alt {
262            elem.alt.set(alt);
263        }
264        if let Some(fit) = fit {
265            elem.fit.set(fit);
266        }
267        if let Some(scaling) = scaling {
268            elem.scaling.set(scaling);
269        }
270        Ok(elem.pack().spanned(span))
271    }
272}
273
274impl Packed<ImageElem> {
275    /// Decodes the image.
276    pub fn decode(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Image> {
277        let span = self.span();
278        let loaded = &self.source.derived;
279        let format = self.determine_format(styles).at(span)?;
280
281        // Construct the image itself.
282        let kind = match format {
283            ImageFormat::Raster(format) => ImageKind::Raster(
284                RasterImage::new(
285                    loaded.data.clone(),
286                    format,
287                    self.icc.get_ref(styles).as_ref().map(|icc| icc.derived.clone()),
288                )
289                .at(span)?,
290            ),
291            ImageFormat::Vector(VectorFormat::Svg) => {
292                // Warn the user if the image contains a foreign object. Not
293                // perfect because the svg could also be encoded, but that's an
294                // edge case.
295                if memchr::memmem::find(&loaded.data, b"<foreignObject").is_some() {
296                    engine.sink.warn(warning!(
297                        span,
298                        "image contains foreign object";
299                        hint: "SVG images with foreign objects might render incorrectly in Typst";
300                        hint: "see https://github.com/typst/typst/issues/1421 for more information"
301                    ));
302                }
303
304                // Identify the SVG file in case contained hrefs need to be resolved.
305                let svg_file = match self.source.source {
306                    DataSource::Path(ref path) => span.resolve_path(path).ok(),
307                    DataSource::Bytes(_) => span.id(),
308                };
309                ImageKind::Svg(
310                    SvgImage::with_fonts_images(
311                        loaded.data.clone(),
312                        engine.world,
313                        &families(styles).map(|f| f.as_str()).collect::<Vec<_>>(),
314                        svg_file,
315                    )
316                    .within(loaded)?,
317                )
318            }
319            ImageFormat::Vector(VectorFormat::Pdf) => {
320                let document = match PdfDocument::new(loaded.data.clone()) {
321                    Ok(doc) => doc,
322                    Err(e) => match e {
323                        // TODO: the `DecyptionError` is currently not public
324                        LoadPdfError::Decryption(_) => {
325                            bail!(
326                                span,
327                                "the PDF is encrypted or password-protected";
328                                hint: "such PDFs are currently not supported";
329                                hint: "preprocess the PDF to remove the encryption"
330                            );
331                        }
332                        LoadPdfError::Invalid => {
333                            bail!(
334                                span,
335                                "the PDF could not be loaded";
336                                hint: "perhaps the PDF file is malformed"
337                            );
338                        }
339                    },
340                };
341
342                // See https://github.com/LaurenzV/hayro/issues/141.
343                if document.pdf().xref().has_optional_content_groups() {
344                    engine.sink.warn(warning!(
345                        span,
346                        "PDF contains optional content groups";
347                        hint: "the image might display incorrectly in PDF export";
348                        hint: "preprocess the PDF to flatten or remove optional content groups"
349                    ));
350                }
351
352                // The user provides the page number start from 1, but further
353                // down the pipeline, page numbers are 0-based.
354                let page_num = self.page.get(styles).get();
355                let page_idx = page_num - 1;
356                let num_pages = document.num_pages();
357
358                let Some(pdf_image) = PdfImage::new(document, page_idx) else {
359                    let s = if num_pages == 1 { "" } else { "s" };
360                    bail!(
361                        span,
362                        "page {page_num} does not exist";
363                        hint: "the document only has {num_pages} page{s}"
364                    );
365                };
366
367                ImageKind::Pdf(pdf_image)
368            }
369        };
370
371        Ok(Image::new(kind, self.alt.get_cloned(styles), self.scaling.get(styles)))
372    }
373
374    /// Tries to determine the image format based on the format that was
375    /// explicitly defined, or else the extension, or else the data.
376    fn determine_format(&self, styles: StyleChain) -> StrResult<ImageFormat> {
377        if let Smart::Custom(v) = self.format.get(styles) {
378            return Ok(v);
379        };
380
381        let Derived { source, derived: loaded } = &self.source;
382        if let DataSource::Path(path) = source
383            && let Some(format) = determine_format_from_path(path.as_str())
384        {
385            return Ok(format);
386        }
387
388        Ok(ImageFormat::detect(&loaded.data).ok_or("unknown image format")?)
389    }
390}
391
392/// Derive the image format from the file extension of a path.
393fn determine_format_from_path(path: &str) -> Option<ImageFormat> {
394    let ext = std::path::Path::new(path)
395        .extension()
396        .and_then(OsStr::to_str)
397        .unwrap_or_default()
398        .to_lowercase();
399
400    match ext.as_str() {
401        // Raster formats
402        "png" => Some(ExchangeFormat::Png.into()),
403        "jpg" | "jpeg" => Some(ExchangeFormat::Jpg.into()),
404        "gif" => Some(ExchangeFormat::Gif.into()),
405        "webp" => Some(ExchangeFormat::Webp.into()),
406        // Vector formats
407        "svg" | "svgz" => Some(VectorFormat::Svg.into()),
408        "pdf" => Some(VectorFormat::Pdf.into()),
409        _ => None,
410    }
411}
412
413impl LocalName for Packed<ImageElem> {
414    const KEY: &'static str = "figure";
415}
416
417impl Figurable for Packed<ImageElem> {}
418
419/// How an image should adjust itself to a given area,
420#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
421pub enum ImageFit {
422    /// The image should completely cover the area (preserves aspect ratio by
423    /// cropping the image only horizontally or vertically). This is the
424    /// default.
425    Cover,
426    /// The image should be fully contained in the area (preserves aspect
427    /// ratio; doesn't crop the image; one dimension can be narrower than
428    /// specified).
429    Contain,
430    /// The image should be stretched so that it exactly fills the area, even if
431    /// this means that the image will be distorted (doesn't preserve aspect
432    /// ratio and doesn't crop the image).
433    Stretch,
434}
435
436/// A loaded raster or vector image.
437///
438/// Values of this type are cheap to clone and hash.
439#[derive(Clone, Eq, PartialEq, Hash)]
440pub struct Image(Arc<LazyHash<Repr>>);
441
442/// The internal representation.
443#[derive(Hash)]
444struct Repr {
445    /// The raw, undecoded image data.
446    kind: ImageKind,
447    /// A text describing the image.
448    alt: Option<EcoString>,
449    /// The scaling algorithm to use.
450    scaling: Smart<ImageScaling>,
451}
452
453impl Image {
454    /// When scaling an image to it's natural size, we default to this DPI
455    /// if the image doesn't contain DPI metadata.
456    pub const DEFAULT_DPI: f64 = 72.0;
457
458    /// Should always be the same as the default DPI used by usvg.
459    pub const USVG_DEFAULT_DPI: f64 = 96.0;
460
461    /// Create an image from a `RasterImage` or `SvgImage`.
462    pub fn new(
463        kind: impl Into<ImageKind>,
464        alt: Option<EcoString>,
465        scaling: Smart<ImageScaling>,
466    ) -> Self {
467        Self::new_impl(kind.into(), alt, scaling)
468    }
469
470    /// Create an image with optional properties set to the default.
471    pub fn plain(kind: impl Into<ImageKind>) -> Self {
472        Self::new(kind, None, Smart::Auto)
473    }
474
475    /// The internal, non-generic implementation. This is memoized to reuse
476    /// the `Arc` and `LazyHash`.
477    #[comemo::memoize]
478    fn new_impl(
479        kind: ImageKind,
480        alt: Option<EcoString>,
481        scaling: Smart<ImageScaling>,
482    ) -> Image {
483        Self(Arc::new(LazyHash::new(Repr { kind, alt, scaling })))
484    }
485
486    /// The format of the image.
487    pub fn format(&self) -> ImageFormat {
488        match &self.0.kind {
489            ImageKind::Raster(raster) => raster.format().into(),
490            ImageKind::Svg(_) => VectorFormat::Svg.into(),
491            ImageKind::Pdf(_) => VectorFormat::Pdf.into(),
492        }
493    }
494
495    /// The width of the image in pixels.
496    pub fn width(&self) -> f64 {
497        match &self.0.kind {
498            ImageKind::Raster(raster) => raster.width() as f64,
499            ImageKind::Svg(svg) => svg.width(),
500            ImageKind::Pdf(pdf) => pdf.width() as f64,
501        }
502    }
503
504    /// The height of the image in pixels.
505    pub fn height(&self) -> f64 {
506        match &self.0.kind {
507            ImageKind::Raster(raster) => raster.height() as f64,
508            ImageKind::Svg(svg) => svg.height(),
509            ImageKind::Pdf(pdf) => pdf.height() as f64,
510        }
511    }
512
513    /// The image's pixel density in pixels per inch, if known.
514    pub fn dpi(&self) -> Option<f64> {
515        match &self.0.kind {
516            ImageKind::Raster(raster) => raster.dpi(),
517            ImageKind::Svg(_) => Some(Image::USVG_DEFAULT_DPI),
518            ImageKind::Pdf(_) => Some(Image::DEFAULT_DPI),
519        }
520    }
521
522    /// A text describing the image.
523    pub fn alt(&self) -> Option<&str> {
524        self.0.alt.as_deref()
525    }
526
527    /// The image scaling algorithm to use for this image.
528    pub fn scaling(&self) -> Smart<ImageScaling> {
529        self.0.scaling
530    }
531
532    /// The decoded image.
533    pub fn kind(&self) -> &ImageKind {
534        &self.0.kind
535    }
536}
537
538impl Debug for Image {
539    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
540        f.debug_struct("Image")
541            .field("format", &self.format())
542            .field("width", &self.width())
543            .field("height", &self.height())
544            .field("alt", &self.alt())
545            .field("scaling", &self.scaling())
546            .finish()
547    }
548}
549
550/// A kind of image.
551#[derive(Clone, Hash)]
552pub enum ImageKind {
553    /// A raster image.
554    Raster(RasterImage),
555    /// An SVG image.
556    Svg(SvgImage),
557    /// A PDF image.
558    Pdf(PdfImage),
559}
560
561impl From<RasterImage> for ImageKind {
562    fn from(image: RasterImage) -> Self {
563        Self::Raster(image)
564    }
565}
566
567impl From<SvgImage> for ImageKind {
568    fn from(image: SvgImage) -> Self {
569        Self::Svg(image)
570    }
571}
572
573/// A raster or vector image format.
574#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
575pub enum ImageFormat {
576    /// A raster graphics format.
577    Raster(RasterFormat),
578    /// A vector graphics format.
579    Vector(VectorFormat),
580}
581
582impl ImageFormat {
583    /// Try to detect the format of an image from data.
584    pub fn detect(data: &[u8]) -> Option<Self> {
585        if let Some(format) = ExchangeFormat::detect(data) {
586            return Some(Self::Raster(RasterFormat::Exchange(format)));
587        }
588
589        if is_svg(data) {
590            return Some(Self::Vector(VectorFormat::Svg));
591        }
592
593        if is_pdf(data) {
594            return Some(Self::Vector(VectorFormat::Pdf));
595        }
596
597        None
598    }
599}
600
601/// Checks whether the data looks like a PDF file.
602fn is_pdf(data: &[u8]) -> bool {
603    let head = &data[..data.len().min(2048)];
604    memchr::memmem::find(head, b"%PDF-").is_some()
605}
606
607/// Checks whether the data looks like an SVG or a compressed SVG.
608fn is_svg(data: &[u8]) -> bool {
609    // Check for the gzip magic bytes. This check is perhaps a bit too
610    // permissive as other formats than SVGZ could use gzip.
611    if data.starts_with(&[0x1f, 0x8b]) {
612        return true;
613    }
614
615    // If the first 2048 bytes contain the SVG namespace declaration, we assume
616    // that it's an SVG. Note that, if the SVG does not contain a namespace
617    // declaration, usvg will reject it.
618    let head = &data[..data.len().min(2048)];
619    memchr::memmem::find(head, b"http://www.w3.org/2000/svg").is_some()
620}
621
622/// A vector graphics format.
623#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
624pub enum VectorFormat {
625    /// The vector graphics format of the web.
626    Svg,
627    /// High-fidelity document and graphics format, with focus on exact
628    /// reproduction in print.
629    Pdf,
630}
631
632impl<R> From<R> for ImageFormat
633where
634    R: Into<RasterFormat>,
635{
636    fn from(format: R) -> Self {
637        Self::Raster(format.into())
638    }
639}
640
641impl From<VectorFormat> for ImageFormat {
642    fn from(format: VectorFormat) -> Self {
643        Self::Vector(format)
644    }
645}
646
647cast! {
648    ImageFormat,
649    self => match self {
650        Self::Raster(v) => v.into_value(),
651        Self::Vector(v) => v.into_value(),
652    },
653    v: RasterFormat => Self::Raster(v),
654    v: VectorFormat => Self::Vector(v),
655}
656
657/// The image scaling algorithm a viewer should use.
658#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
659pub enum ImageScaling {
660    /// Scale with a smoothing algorithm such as bilinear interpolation.
661    Smooth,
662    /// Scale with nearest neighbor or a similar algorithm to preserve the
663    /// pixelated look of the image.
664    Pixelated,
665}