diff --git a/crates/typst-html/src/css.rs b/crates/typst-html/src/css.rs
index 2b659188a..6c84cba0f 100644
--- a/crates/typst-html/src/css.rs
+++ b/crates/typst-html/src/css.rs
@@ -1,11 +1,72 @@
//! Conversion from Typst data types into CSS data types.
-use std::fmt::{self, Display};
+use std::fmt::{self, Display, Write};
-use typst_library::layout::Length;
+use ecow::EcoString;
+use typst_library::html::{attr, HtmlElem};
+use typst_library::layout::{Length, Rel};
use typst_library::visualize::{Color, Hsl, LinearRgb, Oklab, Oklch, Rgb};
use typst_utils::Numeric;
+/// Additional methods for [`HtmlElem`].
+pub trait HtmlElemExt {
+ /// Adds the styles to an element if the property list is non-empty.
+ fn with_styles(self, properties: Properties) -> Self;
+}
+
+impl HtmlElemExt for HtmlElem {
+ /// Adds CSS styles to an element.
+ fn with_styles(self, properties: Properties) -> Self {
+ if let Some(value) = properties.into_inline_styles() {
+ self.with_attr(attr::style, value)
+ } else {
+ self
+ }
+ }
+}
+
+/// A list of CSS properties with values.
+#[derive(Debug, Default)]
+pub struct Properties(EcoString);
+
+impl Properties {
+ /// Creates an empty list.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Adds a new property to the list.
+ pub fn push(&mut self, property: &str, value: impl Display) {
+ if !self.0.is_empty() {
+ self.0.push_str("; ");
+ }
+ write!(&mut self.0, "{property}: {value}").unwrap();
+ }
+
+ /// Adds a new property in builder-style.
+ #[expect(unused)]
+ pub fn with(mut self, property: &str, value: impl Display) -> Self {
+ self.push(property, value);
+ self
+ }
+
+ /// Turns this into a string suitable for use as an inline `style`
+ /// attribute.
+ pub fn into_inline_styles(self) -> Option {
+ (!self.0.is_empty()).then_some(self.0)
+ }
+}
+
+pub fn rel(rel: Rel) -> impl Display {
+ typst_utils::display(move |f| match (rel.abs.is_zero(), rel.rel.is_zero()) {
+ (false, false) => {
+ write!(f, "calc({}% + {})", rel.rel.get(), length(rel.abs))
+ }
+ (true, false) => write!(f, "{}%", rel.rel.get()),
+ (_, true) => write!(f, "{}", length(rel.abs)),
+ })
+}
+
pub fn length(length: Length) -> impl Display {
typst_utils::display(move |f| match (length.abs.is_zero(), length.em.is_zero()) {
(false, false) => {
diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs
index f361bfbb3..5bf25e79b 100644
--- a/crates/typst-html/src/rules.rs
+++ b/crates/typst-html/src/rules.rs
@@ -3,12 +3,12 @@ use std::num::NonZeroUsize;
use ecow::{eco_format, EcoVec};
use typst_library::diag::warning;
use typst_library::foundations::{
- Content, NativeElement, NativeRuleMap, ShowFn, StyleChain, Target,
+ Content, NativeElement, NativeRuleMap, ShowFn, Smart, StyleChain, Target,
};
use typst_library::html::{attr, tag, HtmlAttrs, HtmlElem, HtmlTag};
use typst_library::introspection::{Counter, Locator};
use typst_library::layout::resolve::{table_to_cellgrid, Cell, CellGrid, Entry};
-use typst_library::layout::OuterVAlignment;
+use typst_library::layout::{OuterVAlignment, Sizing};
use typst_library::model::{
Attribution, CiteElem, CiteGroup, Destination, EmphElem, EnumElem, FigureCaption,
FigureElem, HeadingElem, LinkElem, LinkTarget, ListElem, ParbreakElem, QuoteElem,
@@ -18,6 +18,9 @@ use typst_library::text::{
HighlightElem, LinebreakElem, OverlineElem, RawElem, RawLine, SpaceElem, StrikeElem,
SubElem, SuperElem, UnderlineElem,
};
+use typst_library::visualize::ImageElem;
+
+use crate::css::{self, HtmlElemExt};
/// Register show rules for the [HTML target](Target::Html).
pub fn register(rules: &mut NativeRuleMap) {
@@ -47,6 +50,9 @@ pub fn register(rules: &mut NativeRuleMap) {
rules.register(Html, HIGHLIGHT_RULE);
rules.register(Html, RAW_RULE);
rules.register(Html, RAW_LINE_RULE);
+
+ // Visualize.
+ rules.register(Html, IMAGE_RULE);
}
const STRONG_RULE: ShowFn = |elem, _, _| {
@@ -338,7 +344,7 @@ fn show_cellgrid(grid: CellGrid, styles: StyleChain) -> Content {
fn show_cell(tag: HtmlTag, cell: &Cell, styles: StyleChain) -> Content {
let cell = cell.body.clone();
let Some(cell) = cell.to_packed::() else { return cell };
- let mut attrs = HtmlAttrs::default();
+ let mut attrs = HtmlAttrs::new();
let span = |n: NonZeroUsize| (n != NonZeroUsize::MIN).then(|| n.to_string());
if let Some(colspan) = span(cell.colspan.get(styles)) {
attrs.push(attr::colspan, colspan);
@@ -409,3 +415,36 @@ const RAW_RULE: ShowFn = |elem, _, styles| {
};
const RAW_LINE_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone());
+
+const IMAGE_RULE: ShowFn = |elem, engine, styles| {
+ let image = elem.decode(engine, styles)?;
+
+ let mut attrs = HtmlAttrs::new();
+ attrs.push(attr::src, typst_svg::convert_image_to_base64_url(&image));
+
+ if let Some(alt) = elem.alt.get_cloned(styles) {
+ attrs.push(attr::alt, alt);
+ }
+
+ let mut inline = css::Properties::new();
+
+ // TODO: Exclude in semantic profile.
+ if let Some(value) = typst_svg::convert_image_scaling(image.scaling()) {
+ inline.push("image-rendering", value);
+ }
+
+ // TODO: Exclude in semantic profile?
+ match elem.width.get(styles) {
+ Smart::Auto => {}
+ Smart::Custom(rel) => inline.push("width", css::rel(rel)),
+ }
+
+ // TODO: Exclude in semantic profile?
+ match elem.height.get(styles) {
+ Sizing::Auto => {}
+ Sizing::Rel(rel) => inline.push("height", css::rel(rel)),
+ Sizing::Fr(_) => {}
+ }
+
+ Ok(HtmlElem::new(tag::img).with_attrs(attrs).with_styles(inline).pack())
+};
diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs
index 261a58fa3..d4fd121ec 100644
--- a/crates/typst-layout/src/image.rs
+++ b/crates/typst-layout/src/image.rs
@@ -1,18 +1,11 @@
-use std::ffi::OsStr;
-
-use typst_library::diag::{warning, At, LoadedWithin, SourceResult, StrResult};
+use typst_library::diag::SourceResult;
use typst_library::engine::Engine;
-use typst_library::foundations::{Bytes, Derived, Packed, Smart, StyleChain};
+use typst_library::foundations::{Packed, StyleChain};
use typst_library::introspection::Locator;
use typst_library::layout::{
Abs, Axes, FixedAlignment, Frame, FrameItem, Point, Region, Size,
};
-use typst_library::loading::DataSource;
-use typst_library::text::families;
-use typst_library::visualize::{
- Curve, ExchangeFormat, Image, ImageElem, ImageFit, ImageFormat, ImageKind,
- RasterImage, SvgImage, VectorFormat,
-};
+use typst_library::visualize::{Curve, Image, ImageElem, ImageFit};
/// Layout the image.
#[typst_macros::time(span = elem.span())]
@@ -23,53 +16,7 @@ pub fn layout_image(
styles: StyleChain,
region: Region,
) -> SourceResult {
- let span = elem.span();
-
- // Take the format that was explicitly defined, or parse the extension,
- // or try to detect the format.
- let Derived { source, derived: loaded } = &elem.source;
- let format = match elem.format.get(styles) {
- Smart::Custom(v) => v,
- Smart::Auto => determine_format(source, &loaded.data).at(span)?,
- };
-
- // Warn the user if the image contains a foreign object. Not perfect
- // because the svg could also be encoded, but that's an edge case.
- if format == ImageFormat::Vector(VectorFormat::Svg) {
- let has_foreign_object =
- memchr::memmem::find(&loaded.data, b" ImageKind::Raster(
- RasterImage::new(
- loaded.data.clone(),
- format,
- elem.icc.get_ref(styles).as_ref().map(|icc| icc.derived.clone()),
- )
- .at(span)?,
- ),
- ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg(
- SvgImage::with_fonts(
- loaded.data.clone(),
- engine.world,
- &families(styles).map(|f| f.as_str()).collect::>(),
- )
- .within(loaded)?,
- ),
- };
-
- let image = Image::new(kind, elem.alt.get_cloned(styles), elem.scaling.get(styles));
+ let image = elem.decode(engine, styles)?;
// Determine the image's pixel aspect ratio.
let pxw = image.width();
@@ -122,7 +69,7 @@ pub fn layout_image(
// the frame to the target size, center aligning the image in the
// process.
let mut frame = Frame::soft(fitted);
- frame.push(Point::zero(), FrameItem::Image(image, fitted, span));
+ frame.push(Point::zero(), FrameItem::Image(image, fitted, elem.span()));
frame.resize(target, Axes::splat(FixedAlignment::Center));
// Create a clipping group if only part of the image should be visible.
@@ -132,25 +79,3 @@ pub fn layout_image(
Ok(frame)
}
-
-/// Try to determine the image format based on the data.
-fn determine_format(source: &DataSource, data: &Bytes) -> StrResult {
- if let DataSource::Path(path) = source {
- let ext = std::path::Path::new(path.as_str())
- .extension()
- .and_then(OsStr::to_str)
- .unwrap_or_default()
- .to_lowercase();
-
- match ext.as_str() {
- "png" => return Ok(ExchangeFormat::Png.into()),
- "jpg" | "jpeg" => return Ok(ExchangeFormat::Jpg.into()),
- "gif" => return Ok(ExchangeFormat::Gif.into()),
- "svg" | "svgz" => return Ok(VectorFormat::Svg.into()),
- "webp" => return Ok(ExchangeFormat::Webp.into()),
- _ => {}
- }
- }
-
- Ok(ImageFormat::detect(data).ok_or("unknown image format")?)
-}
diff --git a/crates/typst-library/src/html/dom.rs b/crates/typst-library/src/html/dom.rs
index 47bcf9954..49ff37c45 100644
--- a/crates/typst-library/src/html/dom.rs
+++ b/crates/typst-library/src/html/dom.rs
@@ -165,6 +165,11 @@ cast! {
pub struct HtmlAttrs(pub EcoVec<(HtmlAttr, EcoString)>);
impl HtmlAttrs {
+ /// Creates an empty attribute list.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
/// Add an attribute.
pub fn push(&mut self, attr: HtmlAttr, value: impl Into) {
self.0.push((attr, value.into()));
diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs
index 95021b818..f1fa6381b 100644
--- a/crates/typst-library/src/visualize/image/mod.rs
+++ b/crates/typst-library/src/visualize/image/mod.rs
@@ -8,6 +8,7 @@ pub use self::raster::{
};
pub use self::svg::SvgImage;
+use std::ffi::OsStr;
use std::fmt::{self, Debug, Formatter};
use std::sync::Arc;
@@ -15,14 +16,16 @@ use ecow::EcoString;
use typst_syntax::{Span, Spanned};
use typst_utils::LazyHash;
-use crate::diag::StrResult;
+use crate::diag::{warning, At, LoadedWithin, SourceResult, StrResult};
+use crate::engine::Engine;
use crate::foundations::{
cast, elem, func, scope, Bytes, Cast, Content, Derived, NativeElement, Packed, Smart,
+ StyleChain,
};
use crate::layout::{Length, Rel, Sizing};
use crate::loading::{DataSource, Load, LoadSource, Loaded, Readable};
use crate::model::Figurable;
-use crate::text::LocalName;
+use crate::text::{families, LocalName};
/// A raster or vector graphic.
///
@@ -217,6 +220,81 @@ impl ImageElem {
}
}
+impl Packed {
+ /// Decodes the image.
+ pub fn decode(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult {
+ let span = self.span();
+ let loaded = &self.source.derived;
+ let format = self.determine_format(styles).at(span)?;
+
+ // Warn the user if the image contains a foreign object. Not perfect
+ // because the svg could also be encoded, but that's an edge case.
+ if format == ImageFormat::Vector(VectorFormat::Svg) {
+ let has_foreign_object =
+ memchr::memmem::find(&loaded.data, b" ImageKind::Raster(
+ RasterImage::new(
+ loaded.data.clone(),
+ format,
+ self.icc.get_ref(styles).as_ref().map(|icc| icc.derived.clone()),
+ )
+ .at(span)?,
+ ),
+ ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg(
+ SvgImage::with_fonts(
+ loaded.data.clone(),
+ engine.world,
+ &families(styles).map(|f| f.as_str()).collect::>(),
+ )
+ .within(loaded)?,
+ ),
+ };
+
+ Ok(Image::new(kind, self.alt.get_cloned(styles), self.scaling.get(styles)))
+ }
+
+ /// Tries to determine the image format based on the format that was
+ /// explicitly defined, or else the extension, or else the data.
+ fn determine_format(&self, styles: StyleChain) -> StrResult {
+ if let Smart::Custom(v) = self.format.get(styles) {
+ return Ok(v);
+ };
+
+ let Derived { source, derived: loaded } = &self.source;
+ if let DataSource::Path(path) = source {
+ let ext = std::path::Path::new(path.as_str())
+ .extension()
+ .and_then(OsStr::to_str)
+ .unwrap_or_default()
+ .to_lowercase();
+
+ match ext.as_str() {
+ "png" => return Ok(ExchangeFormat::Png.into()),
+ "jpg" | "jpeg" => return Ok(ExchangeFormat::Jpg.into()),
+ "gif" => return Ok(ExchangeFormat::Gif.into()),
+ "svg" | "svgz" => return Ok(VectorFormat::Svg.into()),
+ "webp" => return Ok(ExchangeFormat::Webp.into()),
+ _ => {}
+ }
+ }
+
+ Ok(ImageFormat::detect(&loaded.data).ok_or("unknown image format")?)
+ }
+}
+
impl LocalName for Packed {
const KEY: &'static str = "figure";
}
diff --git a/crates/typst-svg/src/image.rs b/crates/typst-svg/src/image.rs
index 1868ca39b..e6dd579f3 100644
--- a/crates/typst-svg/src/image.rs
+++ b/crates/typst-svg/src/image.rs
@@ -18,21 +18,27 @@ impl SVGRenderer {
self.xml.write_attribute("width", &size.x.to_pt());
self.xml.write_attribute("height", &size.y.to_pt());
self.xml.write_attribute("preserveAspectRatio", "none");
- match image.scaling() {
- Smart::Auto => {}
- Smart::Custom(ImageScaling::Smooth) => {
- // This is still experimental and not implemented in all major browsers.
- // https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering#browser_compatibility
- self.xml.write_attribute("style", "image-rendering: smooth")
- }
- Smart::Custom(ImageScaling::Pixelated) => {
- self.xml.write_attribute("style", "image-rendering: pixelated")
- }
+ if let Some(value) = convert_image_scaling(image.scaling()) {
+ self.xml
+ .write_attribute("style", &format_args!("image-rendering: {value}"))
}
self.xml.end_element();
}
}
+/// Converts an image scaling to a CSS `image-rendering` propery value.
+pub fn convert_image_scaling(scaling: Smart) -> Option<&'static str> {
+ match scaling {
+ Smart::Auto => None,
+ Smart::Custom(ImageScaling::Smooth) => {
+ // This is still experimental and not implemented in all major browsers.
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering#browser_compatibility
+ Some("smooth")
+ }
+ Smart::Custom(ImageScaling::Pixelated) => Some("pixelated"),
+ }
+}
+
/// Encode an image into a data URL. The format of the URL is
/// `data:image/{format};base64,`.
#[comemo::memoize]
diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs
index f4e81250f..3931b67f7 100644
--- a/crates/typst-svg/src/lib.rs
+++ b/crates/typst-svg/src/lib.rs
@@ -5,6 +5,8 @@ mod paint;
mod shape;
mod text;
+pub use image::{convert_image_scaling, convert_image_to_base64_url};
+
use std::collections::HashMap;
use std::fmt::{self, Display, Formatter, Write};
diff --git a/tests/ref/html/image-jpg-html-base64.html b/tests/ref/html/image-jpg-html-base64.html
new file mode 100644
index 000000000..89075323c
--- /dev/null
+++ b/tests/ref/html/image-jpg-html-base64.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/tests/ref/html/image-scaling-methods.html b/tests/ref/html/image-scaling-methods.html
new file mode 100644
index 000000000..a15664d51
--- /dev/null
+++ b/tests/ref/html/image-scaling-methods.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ
index 45c70c4b8..36ec06cb1 100644
--- a/tests/suite/visualize/image.typ
+++ b/tests/suite/visualize/image.typ
@@ -9,6 +9,9 @@
#set page(height: 60pt)
#image("/assets/images/tiger.jpg")
+--- image-jpg-html-base64 html ---
+#image("/assets/images/f2t.jpg", alt: "The letter F")
+
--- image-sizing ---
// Test configuring the size and fitting behaviour of images.
@@ -128,7 +131,7 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B
width: 1cm,
)
---- image-scaling-methods ---
+--- image-scaling-methods render html ---
#let img(scaling) = image(
bytes((
0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF,
@@ -144,14 +147,26 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B
scaling: scaling,
)
-#stack(
- dir: ltr,
- spacing: 4pt,
+#let images = (
img(auto),
img("smooth"),
img("pixelated"),
)
+#context if target() == "html" {
+ // TODO: Remove this once `stack` is supported in HTML export.
+ html.div(
+ style: "display: flex; flex-direction: row; gap: 4pt",
+ images.join(),
+ )
+} else {
+ stack(
+ dir: ltr,
+ spacing: 4pt,
+ ..images,
+ )
+}
+
--- image-natural-dpi-sizing ---
// Test that images aren't upscaled.
// Image is just 48x80 at 220dpi. It should not be scaled to fit the page