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 @@ + + + + + + + The letter F + 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