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