From 3aa7e861e7ffe03193d94c2cfd249739ef746f09 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 9 Jul 2025 15:48:43 +0200 Subject: Support images in HTML export (#6578) --- crates/typst-html/src/css.rs | 65 ++++++++++++++++++++++++++++++++++++++++-- crates/typst-html/src/rules.rs | 45 +++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 5 deletions(-) (limited to 'crates/typst-html') diff --git a/crates/typst-html/src/css.rs b/crates/typst-html/src/css.rs index 2b659188..6c84cba0 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 f361bfbb..5bf25e79 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()) +}; -- cgit v1.2.3