Compare commits

...

9 Commits

Author SHA1 Message Date
Myriad-Dreamin
fafb282885
Merge eaf63ca80cdd13b6ef801262ab1b47c82dc0fd4a into ac77fdbb6ee9c4a33813a75e056cb5953d14b1db 2025-07-09 22:18:16 +08:00
Laurenz
ac77fdbb6e
Fix tooltip for figure reference (#6580) 2025-07-09 13:50:54 +00:00
Laurenz
3aa7e861e7
Support images in HTML export (#6578) 2025-07-09 13:48:43 +00:00
Laurenz
a45c3388a6
More consistent Packed<T> to Content conversion methods (#6579) 2025-07-09 13:40:22 +00:00
Myriad-Dreamin
eaf63ca80c Remove comment 2025-06-03 02:06:53 +08:00
Myriad-Dreamin
e4c316f2cc Add tests 2025-05-28 22:19:59 +08:00
Myriad-Dreamin
1130d6b746 Core Impl 2025-05-28 22:19:56 +08:00
Myriad-Dreamin
1b44fea9d8 Add egotree 2025-05-28 22:00:33 +08:00
Myriad-Dreamin
8311997274 Add scraper 2025-05-28 22:00:33 +08:00
20 changed files with 643 additions and 109 deletions

217
Cargo.lock generated
View File

@ -545,6 +545,29 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
[[package]]
name = "cssparser"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "csv"
version = "1.3.1"
@ -592,6 +615,17 @@ dependencies = [
"syn",
]
[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "dirs"
version = "6.0.0"
@ -630,6 +664,21 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dtoa"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "ecow"
version = "0.2.3"
@ -639,6 +688,12 @@ dependencies = [
"serde",
]
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]]
name = "either"
version = "1.13.0"
@ -861,6 +916,16 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "fxhash"
version = "0.2.1"
@ -970,6 +1035,18 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "html5ever"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
dependencies = [
"log",
"mac",
"markup5ever",
"match_token",
]
[[package]]
name = "httpdate"
version = "1.0.3"
@ -1540,6 +1617,37 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "markup5ever"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
dependencies = [
"log",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
"tendril",
]
[[package]]
name = "match_token"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.7.4"
@ -1612,6 +1720,12 @@ dependencies = [
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "7.1.3"
@ -1869,6 +1983,16 @@ dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
@ -1981,6 +2105,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "proc-macro2"
version = "1.0.93"
@ -2282,6 +2412,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527e65d9d888567588db4c12da1087598d0f6f8b346cc2c5abc91f05fc2dffe2"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@ -2305,6 +2450,25 @@ dependencies = [
"libc",
]
[[package]]
name = "selectors"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
dependencies = [
"bitflags 2.8.0",
"cssparser",
"derive_more",
"fxhash",
"log",
"new_debug_unreachable",
"phf",
"phf_codegen",
"precomputed-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "self-replace"
version = "1.5.0"
@ -2388,6 +2552,15 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "servo_arc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae65c4249478a2647db249fb43e23cec56a2c8974a427e7bd8cb5a1d0964921a"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "shell-escape"
version = "0.1.5"
@ -2499,6 +2672,31 @@ dependencies = [
"serde",
]
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
@ -2621,6 +2819,17 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@ -3071,6 +3280,7 @@ dependencies = [
"comemo",
"csv",
"ecow",
"ego-tree",
"flate2",
"fontdb",
"glidesort",
@ -3094,6 +3304,7 @@ dependencies = [
"roxmltree",
"rust_decimal",
"rustybuzz",
"scraper",
"serde",
"serde_json",
"serde_yaml 0.9.34+deprecated",
@ -3426,6 +3637,12 @@ dependencies = [
"xmlwriter",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf16_iter"
version = "1.0.5"

View File

@ -54,6 +54,7 @@ csv = "1"
ctrlc = "3.4.1"
dirs = "6"
ecow = { version = "0.2", features = ["serde"] }
ego-tree = "0.10"
env_proxy = "0.4"
fastrand = "2.3"
flate2 = "1"
@ -104,6 +105,7 @@ roxmltree = "0.20"
rust_decimal = { version = "1.36.0", default-features = false, features = ["maths"] }
rustybuzz = "0.20"
same-file = "1"
scraper = "0.23.1"
self-replace = "1.3.7"
semver = "1"
serde = { version = "1.0.184", features = ["derive"] }

View File

@ -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) => {

View File

@ -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())
};

View File

@ -2,7 +2,7 @@ use comemo::Track;
use ecow::{eco_vec, EcoString, EcoVec};
use typst::foundations::{Label, Styles, Value};
use typst::layout::PagedDocument;
use typst::model::BibliographyElem;
use typst::model::{BibliographyElem, FigureElem};
use typst::syntax::{ast, LinkedNode, SyntaxKind};
use crate::IdeWorld;
@ -75,8 +75,13 @@ pub fn analyze_labels(
for elem in document.introspector.all() {
let Some(label) = elem.label() else { continue };
let details = elem
.get_by_name("caption")
.or_else(|_| elem.get_by_name("body"))
.to_packed::<FigureElem>()
.and_then(|figure| match figure.caption.as_option() {
Some(Some(caption)) => Some(caption.pack_ref()),
_ => None,
})
.unwrap_or(elem)
.get_by_name("body")
.ok()
.and_then(|field| match field {
Value::Content(content) => Some(content),

View File

@ -378,4 +378,9 @@ mod tests {
.with_source("other.typ", "#let f = (x) => 1");
test(&world, -4, Side::After).must_be_code("(..) => ..");
}
#[test]
fn test_tooltip_reference() {
test("#figure(caption: [Hi])[]<f> @f", -1, Side::Before).must_be_text("Hi");
}
}

View File

@ -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<Frame> {
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"<foreignObject").is_some();
if has_foreign_object {
engine.sink.warn(warning!(
span,
"image contains foreign object";
hint: "SVG images with foreign objects might render incorrectly in typst";
hint: "see https://github.com/typst/typst/issues/1421 for more information"
));
}
}
// Construct the image itself.
let kind = match format {
ImageFormat::Raster(format) => 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::<Vec<_>>(),
)
.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<ImageFormat> {
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")?)
}

View File

@ -27,6 +27,7 @@ codex = { workspace = true }
comemo = { workspace = true }
csv = { workspace = true }
ecow = { workspace = true }
ego-tree = { workspace = true }
flate2 = { workspace = true }
fontdb = { workspace = true }
glidesort = { workspace = true }
@ -50,6 +51,7 @@ regex-syntax = { workspace = true }
roxmltree = { workspace = true }
rust_decimal = { workspace = true }
rustybuzz = { workspace = true }
scraper = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }

View File

@ -64,6 +64,16 @@ impl<T: NativeElement> Packed<T> {
self.0
}
/// Pack back into a reference to content.
pub fn pack_ref(&self) -> &Content {
&self.0
}
/// Pack back into a mutable reference to content.
pub fn pack_mut(&mut self) -> &mut Content {
&mut self.0
}
/// Extract the raw underlying element.
pub fn unpack(self) -> T {
// This function doesn't yet need owned self, but might in the future.
@ -94,10 +104,6 @@ impl<T: NativeElement> Packed<T> {
pub fn set_location(&mut self, location: Location) {
self.0.set_location(location);
}
pub fn as_content(&self) -> &Content {
&self.0
}
}
impl<T: NativeElement> AsRef<T> for Packed<T> {

View File

@ -141,7 +141,7 @@ impl RawContent {
/// Clones a packed element into new raw content.
pub(super) fn clone_impl<E: NativeElement>(elem: &Packed<E>) -> Self {
let raw = &elem.as_content().0;
let raw = &elem.pack_ref().0;
let header = raw.header();
RawContent::create(
elem.as_ref().clone(),

View File

@ -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<EcoString>) {
self.0.push((attr, value.into()));

View File

@ -0,0 +1,98 @@
use ecow::eco_format;
use ego_tree::NodeRef;
use scraper::Node;
use typst_syntax::Spanned;
use crate::diag::{At, FileError, SourceDiagnostic, SourceResult};
use crate::engine::Engine;
use crate::foundations::{dict, func, Array, Dict, IntoValue, Value};
use crate::loading::{DataSource, Load};
/// Reads structured data from an HTML file.
///
/// The HTML file is parsed into an array of dictionaries and strings. It is compatible with
/// the XML format, parsed by the [`xml`]($xml) function.
#[func(title = "HTML")]
pub fn html_decode(
engine: &mut Engine,
/// A [path]($syntax/#paths) to an HTML file or raw HTML bytes.
source: Spanned<DataSource>,
) -> SourceResult<Value> {
let data = source.load(engine.world)?;
let text = data.as_str().map_err(FileError::from).at(source.span)?;
let document = scraper::Html::parse_document(text);
if !document.errors.is_empty() {
let errors = document.errors.iter();
return Err(errors
.map(|msg| {
SourceDiagnostic::error(
source.span,
eco_format!("failed to parse HTML ({msg})"),
)
})
.collect());
}
Ok(convert_html(document.tree.root()))
}
/// Convert an HTML node to a Typst value.
fn convert_html(node_ref: NodeRef<Node>) -> Value {
// `prefix` and `name` are part of the tag name. For example,
// in the following HTML, `html5` is the prefix and `div` is the name:
// ```
// <html5:div class="example" />
// ```
let (prefix, name, attrs) = match node_ref.value() {
Node::Text(text) => return (*text).into_value(),
Node::Document => return Value::Array(convert_html_children(node_ref)),
// todo: the namespace is ignored
Node::Element(element) => {
(element.name.prefix.as_ref(), &*element.name.local, Some(element.attrs()))
}
Node::Fragment => (None, "fragment", None),
// todo: doc type and processing instruction are ignored
// https://en.wikipedia.org/wiki/Processing_Instruction
Node::Doctype(..) | Node::ProcessingInstruction(..) => return Value::None,
Node::Comment(comment) => {
return Value::Dict(dict! {
"tag" => "",
"attrs" => dict! {},
"children" => [(*comment).into_value()].into_iter().collect::<Array>(),
});
}
};
let children = convert_html_children(node_ref);
let attrs: Dict = attrs
.into_iter()
.flatten()
.map(|(name, value)| (name.into(), value.into_value()))
.collect();
let mut converted = dict! {
"tag" => name.into_value(),
"attrs" => attrs,
"children" => children,
};
// In most cases, the prefix is not set, so we only add it if it exists.
if let Some(prefix) = prefix {
converted.insert("prefix".into(), (*prefix).into_value());
}
Value::Dict(converted)
}
/// Convert children an HTML node to a Typst value.
fn convert_html_children(node_ref: NodeRef<Node>) -> Array {
node_ref
.children()
.filter(|v| {
!matches!(v.value(), Node::Doctype(..) | Node::ProcessingInstruction(..))
})
.map(convert_html)
.collect()
}

View File

@ -4,6 +4,8 @@
mod cbor_;
#[path = "csv.rs"]
mod csv_;
#[path = "html.rs"]
mod html_;
#[path = "json.rs"]
mod json_;
#[path = "read.rs"]
@ -21,6 +23,7 @@ use typst_syntax::{FileId, Spanned};
pub use self::cbor_::*;
pub use self::csv_::*;
pub use self::html_::*;
pub use self::json_::*;
pub use self::read_::*;
pub use self::toml_::*;
@ -37,6 +40,7 @@ pub(super) fn define(global: &mut Scope) {
global.start_category(crate::Category::DataLoading);
global.define_func::<read>();
global.define_func::<csv>();
global.define_func::<html_decode>();
global.define_func::<json>();
global.define_func::<toml>();
global.define_func::<yaml>();

View File

@ -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<ImageElem> {
/// Decodes the image.
pub fn decode(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Image> {
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"<foreignObject").is_some();
if has_foreign_object {
engine.sink.warn(warning!(
span,
"image contains foreign object";
hint: "SVG images with foreign objects might render incorrectly in typst";
hint: "see https://github.com/typst/typst/issues/1421 for more information"
));
}
}
// Construct the image itself.
let kind = match format {
ImageFormat::Raster(format) => 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::<Vec<_>>(),
)
.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<ImageFormat> {
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<ImageElem> {
const KEY: &'static str = "figure";
}

View File

@ -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<ImageScaling>) -> 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]

View File

@ -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};

View File

@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><img src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAA3ADcAAD/4QBiRXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAEAAAITAAMAAAABAAEAAAAAAAAAAADcAAAAAQAAANwAAAAB/8AACwgAUAAwAQERAP/bAEMACAYGBwYFCAcHBwkJCAoMFA0MCwsMGRITDxQdGh8eHRocHCAkLicgIiwjHBwoNyksMDE0NDQfJzk9ODI8LjM0Mv/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/aAAgBAQAAPwD5/ooooooooooooor7+ooor4Bor7+ooor4Booor7+or4Booor7+or4Bor7+ooor4Bor7+ooor4Bor7+ooor4Bor7+ooor/2Q==" alt="The letter F"></body>
</html>

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div style="display: flex; flex-direction: row; gap: 4pt"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAKUlEQVR4AQEeAOH/AP8AAAD/AAAA/wCAAAAAgAAAAIAAgIAAAICAgACAcFMHfiTGz0oAAAAASUVORK5CYII=" style="width: 28.346456692913385pt"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAKUlEQVR4AQEeAOH/AP8AAAD/AAAA/wCAAAAAgAAAAIAAgIAAAICAgACAcFMHfiTGz0oAAAAASUVORK5CYII=" style="image-rendering: smooth; width: 28.346456692913385pt"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAKUlEQVR4AQEeAOH/AP8AAAD/AAAA/wCAAAAAgAAAAIAAgIAAAICAgACAcFMHfiTGz0oAAAAASUVORK5CYII=" style="image-rendering: pixelated; width: 28.346456692913385pt"></div>
</body>
</html>

View File

@ -0,0 +1,46 @@
--- html ---
// Test reading XML data.
#let data = html-decode("/assets/text/example.html")
#test(data, ((
tag: "html",
attrs: (:),
children: (
(
tag: "head",
attrs: (:),
children: (
"\n ",
(
tag: "meta",
attrs: (charset: "UTF-8"),
children: (),
),
"\n ",
(
tag: "title",
attrs: (:),
children: ("Example document",),
),
"\n ",
),
),
"\n ",
(
tag: "body",
attrs: (:),
children: (
"\n ",
(
tag: "h1",
attrs: (:),
children: ("Hello, world!",),
),
"\n \n\n",
),
),
),
),))
--- html-invalid ---
// Error: 14-38 failed to parse HTML (Unexpected token)
#html-decode("/assets/text/hello.txt")

View File

@ -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