summaryrefslogtreecommitdiff
path: root/crates/typst-html/src
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2025-07-09 15:48:43 +0200
committerGitHub <noreply@github.com>2025-07-09 13:48:43 +0000
commit3aa7e861e7ffe03193d94c2cfd249739ef746f09 (patch)
tree063945efc6eca31a1f2703fc3127e8723e2eab2d /crates/typst-html/src
parenta45c3388a642d6d7321b5c5b684d3582e91cff41 (diff)
Support images in HTML export (#6578)
Diffstat (limited to 'crates/typst-html/src')
-rw-r--r--crates/typst-html/src/css.rs65
-rw-r--r--crates/typst-html/src/rules.rs45
2 files changed, 105 insertions, 5 deletions
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<EcoString> {
+ (!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<StrongElem> = |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::<TableCell>() 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<RawElem> = |elem, _, styles| {
};
const RAW_LINE_RULE: ShowFn<RawLine> = |elem, _, _| Ok(elem.body.clone());
+
+const IMAGE_RULE: ShowFn<ImageElem> = |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())
+};