Add HTML export format

This commit is contained in:
Laurenz 2024-12-02 14:19:52 +01:00
parent 885c7d96ee
commit e0122a5b50
22 changed files with 824 additions and 110 deletions

16
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -512,6 +512,7 @@ pub enum OutputFormat {
Pdf,
Png,
Svg,
Html,
}
impl Display for OutputFormat {

View File

@ -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<SourceResult<()>> {
let format = command.output_format().unwrap();
match format {
OutputFormat::Html => {
let Warned { output, warnings } = typst::compile::<HtmlDocument>(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::<PagedDocument>(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!(),
}
}

View File

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

View File

@ -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<String> {
let mut w = Writer { buf: String::new() };
w.buf.push_str("<!DOCTYPE html>");
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("</");
w.buf.push_str(&element.tag.resolve());
w.buf.push('>');
Ok(())
}
/// Escape a character.
fn write_escape(w: &mut Writer, c: char) -> StrResult<()> {
// See <https://html.spec.whatwg.org/multipage/syntax.html#syntax-charref>
match c {
'&' => w.buf.push_str("&amp;"),
'<' => w.buf.push_str("&lt;"),
'>' => w.buf.push_str("&gt;"),
'"' => w.buf.push_str("&quot;"),
'\'' => w.buf.push_str("&apos;"),
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("<svg class", "<svg style=\"overflow: visible;\" class");
w.buf.push_str(&svg);
}

View File

@ -0,0 +1,315 @@
//! Typst's HTML exporter.
mod encode;
pub use self::encode::html;
use comemo::{Track, Tracked, TrackedMut};
use typst_library::diag::{bail, warning, At, SourceResult};
use typst_library::engine::{Engine, Route, Sink, Traced};
use typst_library::foundations::{Content, StyleChain, Target, TargetElem};
use typst_library::html::{
attr, tag, FrameElem, HtmlDocument, HtmlElem, HtmlElement, HtmlNode,
};
use typst_library::introspection::{
Introspector, Locator, LocatorLink, SplitLocator, TagElem,
};
use typst_library::layout::{Abs, Axes, BoxElem, Region, Size};
use typst_library::model::{DocumentInfo, ParElem};
use typst_library::routines::{Arenas, Pair, RealizationKind, Routines};
use typst_library::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem};
use typst_library::World;
use typst_syntax::Span;
/// Produce an HTML document from content.
///
/// This first performs root-level realization and then turns the resulting
/// elements into HTML.
#[typst_macros::time(name = "html document")]
pub fn html_document(
engine: &mut Engine,
content: &Content,
styles: StyleChain,
) -> SourceResult<HtmlDocument> {
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<dyn World + '_>,
introspector: Tracked<Introspector>,
traced: Tracked<Traced>,
sink: TrackedMut<Sink>,
route: Tracked<Route>,
content: &Content,
styles: StyleChain,
) -> SourceResult<HtmlDocument> {
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<Vec<HtmlNode>> {
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<dyn World + '_>,
introspector: Tracked<Introspector>,
traced: Tracked<Traced>,
sink: TrackedMut<Sink>,
route: Tracked<Route>,
content: &Content,
locator: Tracked<Locator>,
styles: StyleChain,
) -> SourceResult<Vec<HtmlNode>> {
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<Item = Pair<'a>>,
) -> SourceResult<Vec<HtmlNode>> {
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<HtmlNode>,
) -> SourceResult<()> {
if let Some(elem) = child.to_packed::<TagElem>() {
output.push(HtmlNode::Tag(elem.tag.clone()));
} else if let Some(elem) = child.to_packed::<HtmlElem>() {
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::<ParElem>() {
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::<BoxElem>() {
// 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::<SpaceElem>() {
output.push(HtmlNode::text(' ', child.span()));
} else if let Some(elem) = child.to_packed::<TextElem>() {
output.push(HtmlNode::text(elem.text.clone(), elem.span()));
} else if let Some(elem) = child.to_packed::<LinebreakElem>() {
output.push(HtmlElement::new(tag::br).spanned(elem.span()).into());
} else if let Some(elem) = child.to_packed::<SmartQuoteElem>() {
output.push(HtmlNode::text(
if elem.double(styles) { '"' } else { '\'' },
child.span(),
));
} else if let Some(elem) = child.to_packed::<FrameElem>() {
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 `<html>` and `<body>` if they are not yet rooted,
/// supplying a suitable `<head>`.
fn root_element(output: Vec<HtmlNode>, info: &DocumentInfo) -> SourceResult<HtmlElement> {
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 `<head>` 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<HtmlNode>) -> SourceResult<OutputKind> {
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 `<html>` element. We do not need to supply
/// one.
Html(HtmlElement),
/// The user generate their own `<body>` element. We do not need to supply
/// one, but need supply the `<html>` element.
Body(HtmlElement),
/// The user generated leafs which we wrap in a `<body>` and `<html>`.
Leafs(Vec<HtmlNode>),
}

View File

@ -37,7 +37,7 @@ pub fn analyze_expr(
}
}
return typst::trace(world.upcast(), node.span());
return typst::trace::<PagedDocument>(world.upcast(), node.span());
}
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Option<Numbering>>,
page_supplements: Vec<Content>,
seen: HashSet<Location>,
@ -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());
// 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);
self.finalize(elems)
}
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<Pair>,
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<Pair>, 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<Pair>, 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<Pair>) -> 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: Pair) {

View File

@ -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),
}

View File

@ -86,13 +86,6 @@ routines! {
styles: StyleChain<'a>,
) -> SourceResult<Vec<Pair<'a>>>
/// Layout content into a document.
fn layout_document(
engine: &mut Engine,
content: &Content,
styles: StyleChain,
) -> SourceResult<PagedDocument>
/// 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,
}

View File

@ -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<bool> {
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<bool> {
if content.is::<SpaceElem>() && !matches!(s.kind, RealizationKind::Math) {
if content.is::<SpaceElem>()
&& !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::<HtmlElem>()
.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<T: ListLike>() -> 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::<T>,
}
@ -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 });
}

View File

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

View File

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

View File

@ -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<SourceResult<PagedDocument>> {
pub fn compile<D>(world: &dyn World) -> Warned<SourceResult<D>>
where
D: Document,
{
let mut sink = Sink::new();
let output = compile_impl(world.track(), Traced::default().track(), &mut sink)
let output = compile_impl::<D>(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<SourceResult<PagedDocument>> {
/// 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<Styles>)> {
pub fn trace<D>(world: &dyn World, span: Span) -> EcoVec<(Value, Option<Styles>)>
where
D: Document,
{
let mut sink = Sink::new();
let traced = Traced::new(span);
compile_impl(world.track(), traced.track(), &mut sink).ok();
compile_impl::<D>(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<D: Document>(
world: Tracked<dyn World + '_>,
traced: Tracked<Traced>,
sink: &mut Sink,
) -> SourceResult<PagedDocument> {
) -> SourceResult<D> {
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 = <Introspector as Validate>::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<dyn World + '_>,
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<Self>;
}
impl Sealed for PagedDocument {
const TARGET: Target = Target::Paged;
fn create(
engine: &mut Engine,
content: &Content,
styles: StyleChain,
) -> SourceResult<Self> {
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<Self> {
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,

View File

@ -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::<PagedDocument>(&world).output {
Ok(doc) => doc,
Err(err) => {
let msg = &err[0].message;

View File

@ -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::<PagedDocument>(&world).output {
if let Some(page) = document.pages.first() {
std::hint::black_box(typst_render::render(page, 1.0));
}