diff --git a/Cargo.lock b/Cargo.lock index 0afce4620..2b9020578 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,6 +2674,7 @@ dependencies = [ "comemo", "ecow", "typst-eval", + "typst-html", "typst-layout", "typst-library", "typst-macros", @@ -2720,6 +2721,7 @@ dependencies = [ "toml", "typst", "typst-eval", + "typst-html", "typst-kit", "typst-macros", "typst-pdf", @@ -2787,6 +2789,20 @@ dependencies = [ "typst-syntax", ] +[[package]] +name = "typst-html" +version = "0.12.0" +dependencies = [ + "comemo", + "ecow", + "typst-library", + "typst-macros", + "typst-svg", + "typst-syntax", + "typst-timing", + "typst-utils", +] + [[package]] name = "typst-ide" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index 66885bcf9..639136ce5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ readme = "README.md" typst = { path = "crates/typst", version = "0.12.0" } typst-cli = { path = "crates/typst-cli", version = "0.12.0" } typst-eval = { path = "crates/typst-eval", version = "0.12.0" } +typst-html = { path = "crates/typst-html", version = "0.12.0" } typst-ide = { path = "crates/typst-ide", version = "0.12.0" } typst-kit = { path = "crates/typst-kit", version = "0.12.0" } typst-layout = { path = "crates/typst-layout", version = "0.12.0" } diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index 4b4abc55f..12cf94a7d 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -20,6 +20,7 @@ doc = false [dependencies] typst = { workspace = true } typst-eval = { workspace = true } +typst-html = { workspace = true } typst-kit = { workspace = true } typst-macros = { workspace = true } typst-pdf = { workspace = true } diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index bc3d12251..d9ecc9217 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -512,6 +512,7 @@ pub enum OutputFormat { Pdf, Png, Svg, + Html, } impl Display for OutputFormat { diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index c6d37ffec..2a319424c 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -12,6 +12,7 @@ use typst::diag::{ bail, At, Severity, SourceDiagnostic, SourceResult, StrResult, Warned, }; use typst::foundations::{Datetime, Smart}; +use typst::html::HtmlDocument; use typst::layout::{Frame, Page, PageRanges, PagedDocument}; use typst::syntax::{FileId, Source, Span}; use typst::WorldExt; @@ -41,6 +42,7 @@ impl CompileCommand { OutputFormat::Pdf => "pdf", OutputFormat::Png => "png", OutputFormat::Svg => "svg", + OutputFormat::Html => "html", }, )) }) @@ -57,6 +59,7 @@ impl CompileCommand { Some(ext) if ext.eq_ignore_ascii_case("pdf") => OutputFormat::Pdf, Some(ext) if ext.eq_ignore_ascii_case("png") => OutputFormat::Png, Some(ext) if ext.eq_ignore_ascii_case("svg") => OutputFormat::Svg, + Some(ext) if ext.eq_ignore_ascii_case("html") => OutputFormat::Html, _ => bail!( "could not infer output format for path {}.\n\ consider providing the format manually with `--format/-f`", @@ -95,9 +98,6 @@ impl CompileCommand { /// Execute a compilation command. pub fn compile(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { - // Only meant for input validation - _ = command.output_format()?; - let mut world = SystemWorld::new(&command.common).map_err(|err| eco_format!("{err}"))?; timer.record(&mut world, |world| compile_once(world, &mut command, false))??; @@ -113,15 +113,16 @@ pub fn compile_once( command: &mut CompileCommand, watching: bool, ) -> StrResult<()> { + _ = command.output_format()?; + let start = std::time::Instant::now(); if watching { Status::Compiling.print(command).unwrap(); } - let Warned { output, warnings } = typst::compile(world); - let result = output.and_then(|document| export(world, &document, command, watching)); + let Warned { output, warnings } = compile_and_export(world, command, watching); - match result { + match output { // Export the PDF / PNG. Ok(()) => { let duration = start.elapsed(); @@ -167,14 +168,43 @@ pub fn compile_once( Ok(()) } +fn compile_and_export( + world: &mut SystemWorld, + command: &mut CompileCommand, + watching: bool, +) -> Warned> { + let format = command.output_format().unwrap(); + + match format { + OutputFormat::Html => { + let Warned { output, warnings } = typst::compile::(world); + let result = output.and_then(|document| { + command + .output() + .write(typst_html::html(&document)?.as_bytes()) + .map_err(|err| eco_format!("failed to write HTML file ({err})")) + .at(Span::detached()) + }); + Warned { output: result, warnings } + } + _ => { + let Warned { output, warnings } = typst::compile::(world); + let result = output + .and_then(|document| export_paged(world, &document, command, watching)); + Warned { output: result, warnings } + } + } +} + /// Export into the target format. -fn export( +fn export_paged( world: &mut SystemWorld, document: &PagedDocument, command: &CompileCommand, watching: bool, ) -> SourceResult<()> { match command.output_format().at(Span::detached())? { + OutputFormat::Pdf => export_pdf(document, command), OutputFormat::Png => { export_image(world, document, command, watching, ImageExportFormat::Png) .at(Span::detached()) @@ -183,7 +213,7 @@ fn export( export_image(world, document, command, watching, ImageExportFormat::Svg) .at(Span::detached()) } - OutputFormat::Pdf => export_pdf(document, command), + OutputFormat::Html => unreachable!(), } } diff --git a/crates/typst-html/Cargo.toml b/crates/typst-html/Cargo.toml new file mode 100644 index 000000000..534848f96 --- /dev/null +++ b/crates/typst-html/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "typst-html" +description = "Typst's HTML exporter." +version = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +categories = { workspace = true } +keywords = { workspace = true } +readme = { workspace = true } + +[dependencies] +typst-library = { workspace = true } +typst-macros = { workspace = true } +typst-syntax = { workspace = true } +typst-timing = { workspace = true } +typst-utils = { workspace = true } +typst-svg = { workspace = true } +comemo = { workspace = true } +ecow = { workspace = true } + +[lints] +workspace = true diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs new file mode 100644 index 000000000..d4ff83d67 --- /dev/null +++ b/crates/typst-html/src/encode.rs @@ -0,0 +1,104 @@ +use std::fmt::Write; + +use typst_library::diag::{bail, At, SourceResult, StrResult}; +use typst_library::foundations::Repr; +use typst_library::html::{charsets, tag, HtmlDocument, HtmlElement, HtmlNode}; +use typst_library::layout::Frame; +use typst_syntax::Span; + +/// Encodes an HTML document into a string. +pub fn html(document: &HtmlDocument) -> SourceResult { + let mut w = Writer { buf: String::new() }; + w.buf.push_str(""); + write_element(&mut w, &document.root)?; + Ok(w.buf) +} + +struct Writer { + buf: String, +} + +/// Encode an HTML node into the writer. +fn write_node(w: &mut Writer, node: &HtmlNode) -> SourceResult<()> { + match node { + HtmlNode::Tag(_) => {} + HtmlNode::Text(text, span) => write_text(w, text, *span)?, + HtmlNode::Element(element) => write_element(w, element)?, + HtmlNode::Frame(frame) => write_frame(w, frame), + } + Ok(()) +} + +/// Encode plain text into the writer. +fn write_text(w: &mut Writer, text: &str, span: Span) -> SourceResult<()> { + for c in text.chars() { + if charsets::is_valid_in_normal_element_text(c) { + w.buf.push(c); + } else { + write_escape(w, c).at(span)?; + } + } + Ok(()) +} + +/// Encode one element into the write. +fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { + w.buf.push('<'); + w.buf.push_str(&element.tag.resolve()); + + for (attr, value) in &element.attrs.0 { + w.buf.push(' '); + w.buf.push_str(&attr.resolve()); + w.buf.push('='); + w.buf.push('"'); + for c in value.chars() { + if charsets::is_valid_in_attribute_value(c) { + w.buf.push(c); + } else { + write_escape(w, c).at(element.span)?; + } + } + w.buf.push('"'); + } + + w.buf.push('>'); + + if tag::is_void(element.tag) { + return Ok(()); + } + + for node in &element.children { + write_node(w, node)?; + } + + w.buf.push_str("'); + + Ok(()) +} + +/// Escape a character. +fn write_escape(w: &mut Writer, c: char) -> StrResult<()> { + // See + match c { + '&' => w.buf.push_str("&"), + '<' => w.buf.push_str("<"), + '>' => w.buf.push_str(">"), + '"' => w.buf.push_str("""), + '\'' => w.buf.push_str("'"), + c if charsets::is_w3c_text_char(c) && c != '\r' => { + write!(w.buf, "&#x{:x};", c as u32).unwrap() + } + _ => bail!("the character {} cannot be encoded in HTML", c.repr()), + } + Ok(()) +} + +/// Encode a laid out frame into the writer. +fn write_frame(w: &mut Writer, frame: &Frame) { + // FIXME: This string replacement is obviously a hack. + let svg = typst_svg::svg_frame(frame) + .replace(" SourceResult { + html_document_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + styles, + ) +} + +/// The internal implementation of `html_document`. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn html_document_impl( + routines: &Routines, + world: Tracked, + introspector: Tracked, + traced: Tracked, + sink: TrackedMut, + route: Tracked, + content: &Content, + styles: StyleChain, +) -> SourceResult { + let mut locator = Locator::root().split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route).unnested(), + }; + + // Mark the external styles as "outside" so that they are valid at the page + // level. + let styles = styles.to_map().outside(); + let styles = StyleChain::new(&styles); + + let arenas = Arenas::default(); + let mut info = DocumentInfo::default(); + let children = (engine.routines.realize)( + RealizationKind::HtmlDocument(&mut info), + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + let output = handle_list(&mut engine, &mut locator, children.iter().copied())?; + let root = root_element(output, &info)?; + let introspector = Introspector::html(&root); + + Ok(HtmlDocument { info, root, introspector }) +} + +/// Produce HTML nodes from content. +#[typst_macros::time(name = "html fragment")] +pub fn html_fragment( + engine: &mut Engine, + content: &Content, + locator: Locator, + styles: StyleChain, +) -> SourceResult> { + html_fragment_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + locator.track(), + styles, + ) +} + +/// The cached, internal implementation of [`html_fragment`]. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn html_fragment_impl( + routines: &Routines, + world: Tracked, + introspector: Tracked, + traced: Tracked, + sink: TrackedMut, + route: Tracked, + content: &Content, + locator: Tracked, + styles: StyleChain, +) -> SourceResult> { + let link = LocatorLink::new(locator); + let mut locator = Locator::link(&link).split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route), + }; + + engine.route.check_html_depth().at(content.span())?; + + let arenas = Arenas::default(); + let children = (engine.routines.realize)( + RealizationKind::HtmlFragment, + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + handle_list(&mut engine, &mut locator, children.iter().copied()) +} + +/// Convert children into HTML nodes. +fn handle_list<'a>( + engine: &mut Engine, + locator: &mut SplitLocator, + children: impl IntoIterator>, +) -> SourceResult> { + let mut output = Vec::new(); + for (child, styles) in children { + handle(engine, child, locator, styles, &mut output)?; + } + Ok(output) +} + +/// Convert a child into HTML node(s). +fn handle( + engine: &mut Engine, + child: &Content, + locator: &mut SplitLocator, + styles: StyleChain, + output: &mut Vec, +) -> SourceResult<()> { + if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::Tag(elem.tag.clone())); + } else if let Some(elem) = child.to_packed::() { + let mut children = vec![]; + if let Some(body) = elem.body(styles) { + children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; + } + if tag::is_void(elem.tag) && !children.is_empty() { + bail!(elem.span(), "HTML void elements may not have children"); + } + let element = HtmlElement { + tag: elem.tag, + attrs: elem.attrs(styles).clone(), + children, + span: elem.span(), + }; + output.push(element.into()); + } else if let Some(elem) = child.to_packed::() { + let children = handle_list(engine, locator, elem.children.iter(&styles))?; + output.push( + HtmlElement::new(tag::p) + .with_children(children) + .spanned(elem.span()) + .into(), + ); + } else if let Some(elem) = child.to_packed::() { + // FIXME: Very incomplete and hacky, but makes boxes kind fulfill their + // purpose for now. + if let Some(body) = elem.body(styles) { + let children = + html_fragment(engine, body, locator.next(&elem.span()), styles)?; + output.extend(children); + } + } else if child.is::() { + output.push(HtmlNode::text(' ', child.span())); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::text(elem.text.clone(), elem.span())); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlElement::new(tag::br).spanned(elem.span()).into()); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::text( + if elem.double(styles) { '"' } else { '\'' }, + child.span(), + )); + } else if let Some(elem) = child.to_packed::() { + let locator = locator.next(&elem.span()); + let style = TargetElem::set_target(Target::Paged).wrap(); + let frame = (engine.routines.layout_frame)( + engine, + &elem.body, + locator, + styles.chain(&style), + Region::new(Size::splat(Abs::inf()), Axes::splat(false)), + )?; + output.push(HtmlNode::Frame(frame)); + } else { + engine.sink.warn(warning!( + child.span(), + "{} was ignored during HTML export", + child.elem().name() + )); + } + Ok(()) +} + +/// Wrap the nodes in `` and `` if they are not yet rooted, +/// supplying a suitable ``. +fn root_element(output: Vec, info: &DocumentInfo) -> SourceResult { + let body = match classify_output(output)? { + OutputKind::Html(element) => return Ok(element), + OutputKind::Body(body) => body, + OutputKind::Leafs(leafs) => HtmlElement::new(tag::body).with_children(leafs), + }; + Ok(HtmlElement::new(tag::html) + .with_children(vec![head_element(info).into(), body.into()])) +} + +/// Generate a `` element. +fn head_element(info: &DocumentInfo) -> HtmlElement { + let mut children = vec![]; + + children.push(HtmlElement::new(tag::meta).with_attr(attr::charset, "utf-8").into()); + + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "viewport") + .with_attr(attr::content, "width=device-width, initial-scale=1") + .into(), + ); + + if let Some(title) = &info.title { + children.push( + HtmlElement::new(tag::title) + .with_children(vec![HtmlNode::Text(title.clone(), Span::detached())]) + .into(), + ); + } + + if let Some(description) = &info.description { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "description") + .with_attr(attr::content, description.clone()) + .into(), + ); + } + + HtmlElement::new(tag::head).with_children(children) +} + +/// Determine which kind of output the user generated. +fn classify_output(mut output: Vec) -> SourceResult { + let len = output.len(); + for node in &mut output { + let HtmlNode::Element(elem) = node else { continue }; + let tag = elem.tag; + let mut take = || std::mem::replace(elem, HtmlElement::new(tag::html)); + match (tag, len) { + (tag::html, 1) => return Ok(OutputKind::Html(take())), + (tag::body, 1) => return Ok(OutputKind::Body(take())), + (tag::html | tag::body, _) => bail!( + elem.span, + "`{}` element must be the only element in the document", + elem.tag + ), + _ => {} + } + } + Ok(OutputKind::Leafs(output)) +} + +/// What kinds of output the user generated. +enum OutputKind { + /// The user generated their own `` element. We do not need to supply + /// one. + Html(HtmlElement), + /// The user generate their own `` element. We do not need to supply + /// one, but need supply the `` element. + Body(HtmlElement), + /// The user generated leafs which we wrap in a `` and ``. + Leafs(Vec), +} diff --git a/crates/typst-ide/src/analyze.rs b/crates/typst-ide/src/analyze.rs index 0b41fb68d..7ee83e709 100644 --- a/crates/typst-ide/src/analyze.rs +++ b/crates/typst-ide/src/analyze.rs @@ -37,7 +37,7 @@ pub fn analyze_expr( } } - return typst::trace(world.upcast(), node.span()); + return typst::trace::(world.upcast(), node.span()); } }; diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs index 49461e809..12cfa152e 100644 --- a/crates/typst-layout/src/flow/collect.rs +++ b/crates/typst-layout/src/flow/collect.rs @@ -5,7 +5,7 @@ use std::hash::Hash; use bumpalo::boxed::Box as BumpBox; use bumpalo::Bump; use comemo::{Track, Tracked, TrackedMut}; -use typst_library::diag::{bail, SourceResult}; +use typst_library::diag::{bail, warning, SourceResult}; use typst_library::engine::{Engine, Route, Sink, Traced}; use typst_library::foundations::{Packed, Resolve, Smart, StyleChain}; use typst_library::introspection::{ @@ -83,7 +83,11 @@ impl<'a> Collector<'a, '_, '_> { hint: "try using a `#colbreak()` instead", ); } else { - bail!(child.span(), "{} is not allowed here", child.func().name()); + self.engine.sink.warn(warning!( + child.span(), + "{} was ignored during paged export", + child.func().name() + )); } } diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs index 7cbec59af..df716b338 100644 --- a/crates/typst-layout/src/flow/mod.rs +++ b/crates/typst-layout/src/flow/mod.rs @@ -142,7 +142,7 @@ fn layout_fragment_impl( let arenas = Arenas::default(); let children = (engine.routines.realize)( - RealizationKind::Container, + RealizationKind::LayoutFragment, &mut engine, &mut locator, &arenas, diff --git a/crates/typst-layout/src/pages/mod.rs b/crates/typst-layout/src/pages/mod.rs index 667e16b3f..27002a6c9 100644 --- a/crates/typst-layout/src/pages/mod.rs +++ b/crates/typst-layout/src/pages/mod.rs @@ -75,7 +75,7 @@ fn layout_document_impl( let arenas = Arenas::default(); let mut info = DocumentInfo::default(); let mut children = (engine.routines.realize)( - RealizationKind::Root(&mut info), + RealizationKind::LayoutDocument(&mut info), &mut engine, &mut locator, &arenas, @@ -84,7 +84,7 @@ fn layout_document_impl( )?; let pages = layout_pages(&mut engine, &mut children, locator, styles)?; - let introspector = Introspector::new(&pages); + let introspector = Introspector::paged(&pages); Ok(PagedDocument { pages, info, introspector }) } diff --git a/crates/typst-library/src/engine.rs b/crates/typst-library/src/engine.rs index e532172e5..cd25ec486 100644 --- a/crates/typst-library/src/engine.rs +++ b/crates/typst-library/src/engine.rs @@ -301,6 +301,9 @@ impl Route<'_> { /// The maximum layout nesting depth. const MAX_LAYOUT_DEPTH: usize = 72; + /// The maximum HTML nesting depth. + const MAX_HTML_DEPTH: usize = 72; + /// The maximum function call nesting depth. const MAX_CALL_DEPTH: usize = 80; @@ -326,6 +329,17 @@ impl Route<'_> { Ok(()) } + /// Ensures that we are within the maximum HTML depth. + pub fn check_html_depth(&self) -> HintedStrResult<()> { + if !self.within(Route::MAX_HTML_DEPTH) { + bail!( + "maximum HTML depth exceeded"; + hint: "try to reduce the amount of nesting of your HTML", + ); + } + Ok(()) + } + /// Ensures that we are within the maximum function call depth. pub fn check_call_depth(&self) -> StrResult<()> { if !self.within(Route::MAX_CALL_DEPTH) { diff --git a/crates/typst-library/src/introspection/introspector.rs b/crates/typst-library/src/introspection/introspector.rs index 388d1f00f..8cbaea891 100644 --- a/crates/typst-library/src/introspection/introspector.rs +++ b/crates/typst-library/src/introspection/introspector.rs @@ -10,6 +10,7 @@ use typst_utils::NonZeroExt; use crate::diag::{bail, StrResult}; use crate::foundations::{Content, Label, Repr, Selector}; +use crate::html::{HtmlElement, HtmlNode}; use crate::introspection::{Location, Tag}; use crate::layout::{Frame, FrameItem, Page, Point, Position, Transform}; use crate::model::Numbering; @@ -47,9 +48,15 @@ type Pair = (Content, Position); impl Introspector { /// Creates an introspector for a page list. - #[typst_macros::time(name = "introspect")] - pub fn new(pages: &[Page]) -> Self { - IntrospectorBuilder::new().build(pages) + #[typst_macros::time(name = "introspect pages")] + pub fn paged(pages: &[Page]) -> Self { + IntrospectorBuilder::new().build_paged(pages) + } + + /// Creates an introspector for HTML. + #[typst_macros::time(name = "introspect html")] + pub fn html(root: &HtmlElement) -> Self { + IntrospectorBuilder::new().build_html(root) } /// Iterates over all locatable elements. @@ -346,6 +353,7 @@ impl Clone for QueryCache { /// Builds the introspector. #[derive(Default)] struct IntrospectorBuilder { + pages: usize, page_numberings: Vec>, page_supplements: Vec, seen: HashSet, @@ -361,46 +369,37 @@ impl IntrospectorBuilder { Self::default() } - /// Build the introspector. - fn build(mut self, pages: &[Page]) -> Introspector { + /// Build an introspector for a page list. + fn build_paged(mut self, pages: &[Page]) -> Introspector { + self.pages = pages.len(); self.page_numberings.reserve(pages.len()); self.page_supplements.reserve(pages.len()); // Discover all elements. - let mut root = Vec::new(); + let mut elems = Vec::new(); for (i, page) in pages.iter().enumerate() { self.page_numberings.push(page.numbering.clone()); self.page_supplements.push(page.supplement.clone()); - self.discover( - &mut root, + self.discover_in_frame( + &mut elems, &page.frame, NonZeroUsize::new(1 + i).unwrap(), Transform::identity(), ); } - self.locations.reserve(self.seen.len()); + self.finalize(elems) + } - // Save all pairs and their descendants in the correct order. - let mut elems = Vec::with_capacity(self.seen.len()); - for pair in root { - self.visit(&mut elems, pair); - } - - Introspector { - pages: pages.len(), - page_numberings: self.page_numberings, - page_supplements: self.page_supplements, - elems, - keys: self.keys, - locations: self.locations, - labels: self.labels, - queries: QueryCache::default(), - } + /// Build an introspector for an HTML document. + fn build_html(mut self, root: &HtmlElement) -> Introspector { + let mut elems = Vec::new(); + self.discover_in_html(&mut elems, root); + self.finalize(elems) } /// Processes the tags in the frame. - fn discover( + fn discover_in_frame( &mut self, sink: &mut Vec, frame: &Frame, @@ -416,27 +415,83 @@ impl IntrospectorBuilder { if let Some(parent) = group.parent { let mut nested = vec![]; - self.discover(&mut nested, &group.frame, page, ts); + self.discover_in_frame(&mut nested, &group.frame, page, ts); self.insertions.insert(parent, nested); } else { - self.discover(sink, &group.frame, page, ts); + self.discover_in_frame(sink, &group.frame, page, ts); } } - FrameItem::Tag(Tag::Start(elem)) => { - let loc = elem.location().unwrap(); - if self.seen.insert(loc) { - let point = pos.transform(ts); - sink.push((elem.clone(), Position { page, point })); - } - } - FrameItem::Tag(Tag::End(loc, key)) => { - self.keys.insert(*key, *loc); + FrameItem::Tag(tag) => { + self.discover_in_tag( + sink, + tag, + Position { page, point: pos.transform(ts) }, + ); } _ => {} } } } + /// Processes the tags in the HTML element. + fn discover_in_html(&mut self, sink: &mut Vec, elem: &HtmlElement) { + for child in &elem.children { + match child { + HtmlNode::Tag(tag) => self.discover_in_tag( + sink, + tag, + Position { page: NonZeroUsize::ONE, point: Point::zero() }, + ), + HtmlNode::Text(_, _) => {} + HtmlNode::Element(elem) => self.discover_in_html(sink, elem), + HtmlNode::Frame(frame) => self.discover_in_frame( + sink, + frame, + NonZeroUsize::ONE, + Transform::identity(), + ), + } + } + } + + /// Handle a tag. + fn discover_in_tag(&mut self, sink: &mut Vec, tag: &Tag, position: Position) { + match tag { + Tag::Start(elem) => { + let loc = elem.location().unwrap(); + if self.seen.insert(loc) { + sink.push((elem.clone(), position)); + } + } + Tag::End(loc, key) => { + self.keys.insert(*key, *loc); + } + } + } + + /// Build a complete introspector with all acceleration structures from a + /// list of top-level pairs. + fn finalize(mut self, root: Vec) -> Introspector { + self.locations.reserve(self.seen.len()); + + // Save all pairs and their descendants in the correct order. + let mut elems = Vec::with_capacity(self.seen.len()); + for pair in root { + self.visit(&mut elems, pair); + } + + Introspector { + pages: self.pages, + page_numberings: self.page_numberings, + page_supplements: self.page_supplements, + elems, + keys: self.keys, + locations: self.locations, + labels: self.labels, + queries: QueryCache::default(), + } + } + /// Saves a pair and all its descendants into `elems` and populates the /// acceleration structures. fn visit(&mut self, elems: &mut Vec, pair: Pair) { diff --git a/crates/typst-library/src/layout/frame.rs b/crates/typst-library/src/layout/frame.rs index 204584fc3..fc8634e8f 100644 --- a/crates/typst-library/src/layout/frame.rs +++ b/crates/typst-library/src/layout/frame.rs @@ -520,8 +520,7 @@ pub enum FrameItem { Image(Image, Size, Span), /// An internal or external link to a destination. Link(Destination, Size), - /// An introspectable element that produced something within this frame - /// alongside its key. + /// An introspectable element that produced something within this frame. Tag(Tag), } diff --git a/crates/typst-library/src/routines.rs b/crates/typst-library/src/routines.rs index 000b3bba5..aa92012b1 100644 --- a/crates/typst-library/src/routines.rs +++ b/crates/typst-library/src/routines.rs @@ -86,13 +86,6 @@ routines! { styles: StyleChain<'a>, ) -> SourceResult>> - /// Layout content into a document. - fn layout_document( - engine: &mut Engine, - content: &Content, - styles: StyleChain, - ) -> SourceResult - /// Lays out content into multiple regions. fn layout_fragment( engine: &mut Engine, @@ -343,11 +336,16 @@ pub enum EvalMode { /// Defines what kind of realization we are performing. pub enum RealizationKind<'a> { - /// This the root realization for the document. Requires a mutable reference + /// This the root realization for layout. Requires a mutable reference /// to document metadata that will be filled from `set document` rules. - Root(&'a mut DocumentInfo), + LayoutDocument(&'a mut DocumentInfo), /// A nested realization in a container (e.g. a `block`). - Container, + LayoutFragment, + /// This the root realization for HTML. Requires a mutable reference + /// to document metadata that will be filled from `set document` rules. + HtmlDocument(&'a mut DocumentInfo), + /// A nested realization in a container (e.g. a `block`). + HtmlFragment, /// A realization within math. Math, } diff --git a/crates/typst-realize/src/lib.rs b/crates/typst-realize/src/lib.rs index ec7ee0ae5..c46a15351 100644 --- a/crates/typst-realize/src/lib.rs +++ b/crates/typst-realize/src/lib.rs @@ -18,6 +18,7 @@ use typst_library::foundations::{ SequenceElem, Show, ShowSet, Style, StyleChain, StyleVec, StyledElem, Styles, Synthesize, Transformation, }; +use typst_library::html::{tag, HtmlElem}; use typst_library::introspection::{Locatable, SplitLocator, Tag, TagElem}; use typst_library::layout::{ AlignElem, BoxElem, HElem, InlineElem, PageElem, PagebreakElem, VElem, @@ -47,12 +48,16 @@ pub fn realize<'a>( locator, arenas, rules: match kind { - RealizationKind::Root(_) | RealizationKind::Container => NORMAL_RULES, + RealizationKind::LayoutDocument(_) | RealizationKind::LayoutFragment => { + LAYOUT_RULES + } + RealizationKind::HtmlDocument(_) => HTML_DOCUMENT_RULES, + RealizationKind::HtmlFragment => HTML_FRAGMENT_RULES, RealizationKind::Math => MATH_RULES, }, sink: vec![], groupings: ArrayVec::new(), - outside: matches!(kind, RealizationKind::Root(_)), + outside: matches!(kind, RealizationKind::LayoutDocument(_)), may_attach: false, kind, }; @@ -105,10 +110,10 @@ struct GroupingRule { /// be visible to `finish`. tags: bool, /// Defines which kinds of elements start and make up this kind of grouping. - trigger: fn(Element) -> bool, + trigger: fn(&Content, &RealizationKind) -> bool, /// Defines elements that may appear in the interior of the grouping, but /// not at the edges. - inner: fn(Element) -> bool, + inner: fn(&Content) -> bool, /// Defines whether styles for this kind of element interrupt the grouping. interrupt: fn(Element) -> bool, /// Should convert the accumulated elements in `s.sink[start..]` into @@ -555,14 +560,16 @@ fn visit_styled<'a>( for style in local.iter() { let Some(elem) = style.element() else { continue }; if elem == DocumentElem::elem() { - let RealizationKind::Root(info) = &mut s.kind else { - let span = style.span(); - bail!(span, "document set rules are not allowed inside of containers"); - }; - - info.populate(&local); + match &mut s.kind { + RealizationKind::LayoutDocument(info) + | RealizationKind::HtmlDocument(info) => info.populate(&local), + _ => bail!( + style.span(), + "document set rules are not allowed inside of containers" + ), + } } else if elem == PageElem::elem() { - let RealizationKind::Root(_) = s.kind else { + let RealizationKind::LayoutDocument(_) = s.kind else { let span = style.span(); bail!(span, "page configuration is not allowed inside of containers"); }; @@ -618,8 +625,7 @@ fn visit_grouping_rules<'a>( content: &'a Content, styles: StyleChain<'a>, ) -> SourceResult { - let elem = content.elem(); - let matching = s.rules.iter().find(|&rule| (rule.trigger)(elem)); + let matching = s.rules.iter().find(|&rule| (rule.trigger)(content, &s.kind)); // Try to continue or finish an existing grouping. while let Some(active) = s.groupings.last() { @@ -629,7 +635,7 @@ fn visit_grouping_rules<'a>( } // If the element can be added to the active grouping, do it. - if (active.rule.trigger)(elem) || (active.rule.inner)(elem) { + if (active.rule.trigger)(content, &s.kind) || (active.rule.inner)(content) { s.sink.push((content, styles)); return Ok(true); } @@ -655,7 +661,9 @@ fn visit_filter_rules<'a>( content: &'a Content, styles: StyleChain<'a>, ) -> SourceResult { - if content.is::() && !matches!(s.kind, RealizationKind::Math) { + if content.is::() + && !matches!(s.kind, RealizationKind::Math | RealizationKind::HtmlFragment) + { // Outside of maths, spaces that were not collected by the paragraph // grouper don't interest us. return Ok(true); @@ -730,7 +738,7 @@ fn finish_innermost_grouping(s: &mut State) -> SourceResult<()> { let Grouping { start, rule } = s.groupings.pop().unwrap(); // Trim trailing non-trigger elements. - let trimmed = s.sink[start..].trim_end_matches(|(c, _)| !(rule.trigger)(c.elem())); + let trimmed = s.sink[start..].trim_end_matches(|(c, _)| !(rule.trigger)(c, &s.kind)); let end = start + trimmed.len(); let tail = s.store_slice(&s.sink[end..]); s.sink.truncate(end); @@ -768,22 +776,30 @@ fn finish_innermost_grouping(s: &mut State) -> SourceResult<()> { /// number of unique priority levels. const MAX_GROUP_NESTING: usize = 3; -/// Grouping rules used in normal realizations. -static NORMAL_RULES: &[&GroupingRule] = &[&TEXTUAL, &PAR, &CITES, &LIST, &ENUM, &TERMS]; +/// Grouping rules used in layout realization. +static LAYOUT_RULES: &[&GroupingRule] = &[&TEXTUAL, &PAR, &CITES, &LIST, &ENUM, &TERMS]; -/// Grouping rules used in math realization. +/// Grouping rules used in HTML root realization. +static HTML_DOCUMENT_RULES: &[&GroupingRule] = + &[&TEXTUAL, &PAR, &CITES, &LIST, &ENUM, &TERMS]; + +/// Grouping rules used in HTML fragment realization. +static HTML_FRAGMENT_RULES: &[&GroupingRule] = &[&TEXTUAL, &CITES, &LIST, &ENUM, &TERMS]; + +/// Grouping rules used in math realizatio. static MATH_RULES: &[&GroupingRule] = &[&CITES, &LIST, &ENUM, &TERMS]; /// Groups adjacent textual elements for text show rule application. static TEXTUAL: GroupingRule = GroupingRule { priority: 3, tags: true, - trigger: |elem| { + trigger: |content, _| { + let elem = content.elem(); elem == TextElem::elem() || elem == LinebreakElem::elem() || elem == SmartQuoteElem::elem() }, - inner: |elem| elem == SpaceElem::elem(), + inner: |content| content.elem() == SpaceElem::elem(), // Any kind of style interrupts this kind of grouping since regex show // rules cannot match over style changes anyway. interrupt: |_| true, @@ -794,15 +810,22 @@ static TEXTUAL: GroupingRule = GroupingRule { static PAR: GroupingRule = GroupingRule { priority: 1, tags: true, - trigger: |elem| { + trigger: |content, kind| { + let elem = content.elem(); elem == TextElem::elem() || elem == HElem::elem() || elem == LinebreakElem::elem() || elem == SmartQuoteElem::elem() || elem == InlineElem::elem() || elem == BoxElem::elem() + || (matches!( + kind, + RealizationKind::HtmlDocument(_) | RealizationKind::HtmlFragment + ) && content + .to_packed::() + .is_some_and(|elem| tag::is_inline(elem.tag))) }, - inner: |elem| elem == SpaceElem::elem(), + inner: |content| content.elem() == SpaceElem::elem(), interrupt: |elem| elem == ParElem::elem() || elem == AlignElem::elem(), finish: finish_par, }; @@ -811,8 +834,8 @@ static PAR: GroupingRule = GroupingRule { static CITES: GroupingRule = GroupingRule { priority: 2, tags: false, - trigger: |elem| elem == CiteElem::elem(), - inner: |elem| elem == SpaceElem::elem(), + trigger: |content, _| content.elem() == CiteElem::elem(), + inner: |content| content.elem() == SpaceElem::elem(), interrupt: |elem| elem == CiteGroup::elem(), finish: finish_cites, }; @@ -831,8 +854,11 @@ const fn list_like_grouping() -> GroupingRule { GroupingRule { priority: 2, tags: false, - trigger: |elem| elem == T::Item::elem(), - inner: |elem| elem == SpaceElem::elem() || elem == ParbreakElem::elem(), + trigger: |content, _| content.elem() == T::Item::elem(), + inner: |content| { + let elem = content.elem(); + elem == SpaceElem::elem() || elem == ParbreakElem::elem() + }, interrupt: |elem| elem == T::elem(), finish: finish_list_like::, } @@ -867,7 +893,7 @@ fn finish_textual(Grouped { s, mut start }: Grouped) -> SourceResult<()> { // 1. We are already in a paragraph group. In this case, the elements just // transparently become part of it. // 2. There is no group at all. In this case, we create one. - if s.groupings.is_empty() { + if s.groupings.is_empty() && s.rules.iter().any(|&rule| std::ptr::eq(rule, &PAR)) { s.groupings.push(Grouping { start, rule: &PAR }); } diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs index f9ce4b860..b21fe4fb8 100644 --- a/crates/typst-svg/src/lib.rs +++ b/crates/typst-svg/src/lib.rs @@ -32,6 +32,17 @@ pub fn svg(page: &Page) -> String { renderer.finalize() } +/// Export a frame into a SVG file. +#[typst_macros::time(name = "svg frame")] +pub fn svg_frame(frame: &Frame) -> String { + let mut renderer = SVGRenderer::new(); + renderer.write_header(frame.size()); + + let state = State::new(frame.size(), Transform::identity()); + renderer.render_frame(state, Transform::identity(), frame); + renderer.finalize() +} + /// Export a document with potentially multiple pages into a single SVG file. /// /// The padding will be added around and between the individual frames. diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml index 6158d7912..3eb6dea9b 100644 --- a/crates/typst/Cargo.toml +++ b/crates/typst/Cargo.toml @@ -14,6 +14,7 @@ readme = { workspace = true } [dependencies] typst-eval = { workspace = true } +typst-html = { workspace = true } typst-layout = { workspace = true } typst-library = { workspace = true } typst-macros = { workspace = true } diff --git a/crates/typst/src/lib.rs b/crates/typst/src/lib.rs index feb17ba9e..2fb0a7306 100644 --- a/crates/typst/src/lib.rs +++ b/crates/typst/src/lib.rs @@ -13,11 +13,11 @@ //! order-independent and thus much better suited for further processing than //! the raw markup. //! - **Layouting:** -//! Next, the content is [laid out] into a [document] containing one [frame] -//! per page with items at fixed positions. +//! Next, the content is [laid out] into a [`PagedDocument`] containing one +//! [frame] per page with items at fixed positions. //! - **Exporting:** //! These frames can finally be exported into an output format (currently PDF, -//! PNG, or SVG). +//! PNG, SVG, and HTML). //! //! [tokens]: typst_syntax::SyntaxKind //! [parsed]: typst_syntax::parse @@ -43,23 +43,32 @@ use std::collections::HashSet; use comemo::{Track, Tracked, Validate}; use ecow::{eco_format, eco_vec, EcoString, EcoVec}; -use typst_library::diag::{warning, FileError, SourceDiagnostic, SourceResult, Warned}; +use typst_library::diag::{ + bail, warning, FileError, SourceDiagnostic, SourceResult, Warned, +}; use typst_library::engine::{Engine, Route, Sink, Traced}; use typst_library::foundations::{StyleChain, Styles, Value}; +use typst_library::html::HtmlDocument; use typst_library::introspection::Introspector; use typst_library::layout::PagedDocument; use typst_library::routines::Routines; use typst_syntax::{FileId, Span}; use typst_timing::{timed, TimingScope}; +use crate::foundations::{Target, TargetElem}; +use crate::model::DocumentInfo; + /// Compile sources into a fully layouted document. /// /// - Returns `Ok(document)` if there were no fatal errors. /// - Returns `Err(errors)` if there were fatal errors. #[typst_macros::time] -pub fn compile(world: &dyn World) -> Warned> { +pub fn compile(world: &dyn World) -> Warned> +where + D: Document, +{ let mut sink = Sink::new(); - let output = compile_impl(world.track(), Traced::default().track(), &mut sink) + let output = compile_impl::(world.track(), Traced::default().track(), &mut sink) .map_err(deduplicate); Warned { output, warnings: sink.warnings() } } @@ -67,22 +76,32 @@ pub fn compile(world: &dyn World) -> Warned> { /// Compiles sources and returns all values and styles observed at the given /// `span` during compilation. #[typst_macros::time] -pub fn trace(world: &dyn World, span: Span) -> EcoVec<(Value, Option)> { +pub fn trace(world: &dyn World, span: Span) -> EcoVec<(Value, Option)> +where + D: Document, +{ let mut sink = Sink::new(); let traced = Traced::new(span); - compile_impl(world.track(), traced.track(), &mut sink).ok(); + compile_impl::(world.track(), traced.track(), &mut sink).ok(); sink.values() } /// The internal implementation of `compile` with a bit lower-level interface /// that is also used by `trace`. -fn compile_impl( +fn compile_impl( world: Tracked, traced: Tracked, sink: &mut Sink, -) -> SourceResult { +) -> SourceResult { + if D::TARGET == Target::Html { + warn_or_error_for_html(world, sink)?; + } + let library = world.library(); - let styles = StyleChain::new(&library.styles); + let base = StyleChain::new(&library.styles); + let target = TargetElem::set_target(D::TARGET).wrap(); + let styles = base.chain(&target); + let empty_introspector = Introspector::default(); // Fetch the main source file once. let main = world.main(); @@ -103,7 +122,8 @@ fn compile_impl( let mut iter = 0; let mut subsink; - let mut document = PagedDocument::default(); + let mut introspector = &empty_introspector; + let mut document: D; // Relayout until all introspections stabilize. // If that doesn't happen within five attempts, we give up. @@ -118,7 +138,7 @@ fn compile_impl( let constraint = ::Constraint::new(); let mut engine = Engine { world, - introspector: document.introspector.track_with(&constraint), + introspector: introspector.track_with(&constraint), traced, sink: subsink.track_mut(), route: Route::default(), @@ -126,10 +146,11 @@ fn compile_impl( }; // Layout! - document = (engine.routines.layout_document)(&mut engine, &content, styles)?; + document = D::create(&mut engine, &content, styles)?; + introspector = document.introspector(); iter += 1; - if timed!("check stabilized", document.introspector.validate(&constraint)) { + if timed!("check stabilized", introspector.validate(&constraint)) { break; } @@ -208,6 +229,97 @@ fn hint_invalid_main_file( eco_vec![diagnostic] } +/// HTML export will warn or error depending on whether the feature flag is enabled. +fn warn_or_error_for_html( + world: Tracked, + sink: &mut Sink, +) -> SourceResult<()> { + if world.library().features.is_enabled(Feature::Html) { + sink.warn(warning!( + Span::detached(), + "html export is under active development and incomplete"; + hint: "its behaviour may change at any time"; + hint: "do not rely on this feature for production use cases" + )); + } else { + bail!( + Span::detached(), + "html export is only available when `--feature html` is passed"; + hint: "html export is under active development and incomplete" + ); + } + Ok(()) +} + +/// A document is what results from compilation. +pub trait Document: sealed::Sealed { + /// Get the document's metadata. + fn info(&self) -> &DocumentInfo; + + /// Get the document's introspector. + fn introspector(&self) -> &Introspector; +} + +impl Document for PagedDocument { + fn info(&self) -> &DocumentInfo { + &self.info + } + + fn introspector(&self) -> &Introspector { + &self.introspector + } +} + +impl Document for HtmlDocument { + fn info(&self) -> &DocumentInfo { + &self.info + } + + fn introspector(&self) -> &Introspector { + &self.introspector + } +} + +mod sealed { + use typst_library::foundations::{Content, Target}; + + use super::*; + + pub trait Sealed: Sized { + const TARGET: Target; + + fn create( + engine: &mut Engine, + content: &Content, + styles: StyleChain, + ) -> SourceResult; + } + + impl Sealed for PagedDocument { + const TARGET: Target = Target::Paged; + + fn create( + engine: &mut Engine, + content: &Content, + styles: StyleChain, + ) -> SourceResult { + typst_layout::layout_document(engine, content, styles) + } + } + + impl Sealed for HtmlDocument { + const TARGET: Target = Target::Html; + + fn create( + engine: &mut Engine, + content: &Content, + styles: StyleChain, + ) -> SourceResult { + typst_html::html_document(engine, content, styles) + } + } +} + /// Defines implementation of various Typst compiler routines as a table of /// function pointers. /// @@ -216,7 +328,6 @@ pub static ROUTINES: Routines = Routines { eval_string: typst_eval::eval_string, eval_closure: typst_eval::eval_closure, realize: typst_realize::realize, - layout_document: typst_layout::layout_document, layout_fragment: typst_layout::layout_fragment, layout_frame: typst_layout::layout_frame, layout_inline: typst_layout::layout_inline, diff --git a/docs/src/html.rs b/docs/src/html.rs index c9ed7a7fd..a1206032d 100644 --- a/docs/src/html.rs +++ b/docs/src/html.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use typed_arena::Arena; use typst::diag::{FileError, FileResult, StrResult}; use typst::foundations::{Bytes, Datetime}; -use typst::layout::{Abs, Point, Size}; +use typst::layout::{Abs, PagedDocument, Point, Size}; use typst::syntax::{FileId, Source, VirtualPath}; use typst::text::{Font, FontBook}; use typst::utils::LazyHash; @@ -419,7 +419,7 @@ fn code_block(resolver: &dyn Resolver, lang: &str, text: &str) -> Html { let source = Source::new(id, compile); let world = DocWorld(source); - let mut document = match typst::compile(&world).output { + let mut document = match typst::compile::(&world).output { Ok(doc) => doc, Err(err) => { let msg = &err[0].message; diff --git a/tests/fuzz/src/compile.rs b/tests/fuzz/src/compile.rs index fa9397814..37e21deb9 100644 --- a/tests/fuzz/src/compile.rs +++ b/tests/fuzz/src/compile.rs @@ -3,6 +3,7 @@ use libfuzzer_sys::fuzz_target; use typst::diag::{FileError, FileResult}; use typst::foundations::{Bytes, Datetime}; +use typst::layout::PagedDocument; use typst::syntax::{FileId, Source}; use typst::text::{Font, FontBook}; use typst::utils::LazyHash; @@ -65,7 +66,7 @@ impl World for FuzzWorld { fuzz_target!(|text: &str| { let world = FuzzWorld::new(text); - if let Ok(document) = typst::compile(&world).output { + if let Ok(document) = typst::compile::(&world).output { if let Some(page) = document.pages.first() { std::hint::black_box(typst_render::render(page, 1.0)); }