diff --git a/crates/typst-html/src/css.rs b/crates/typst-html/src/css.rs
index 5916d3147..ba0b8942f 100644
--- a/crates/typst-html/src/css.rs
+++ b/crates/typst-html/src/css.rs
@@ -26,7 +26,6 @@ impl Properties {
}
/// 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
diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs
index ee1034f62..54d79d024 100644
--- a/crates/typst-html/src/rules.rs
+++ b/crates/typst-html/src/rules.rs
@@ -1,17 +1,18 @@
use std::num::NonZeroUsize;
+use comemo::Track;
use ecow::{eco_format, EcoVec};
use typst_library::diag::{warning, At};
use typst_library::foundations::{
- Content, NativeElement, NativeRuleMap, ShowFn, Smart, StyleChain, Target,
+ Content, Context, NativeElement, NativeRuleMap, ShowFn, Smart, StyleChain, Target,
};
use typst_library::introspection::{Counter, Locator};
use typst_library::layout::resolve::{table_to_cellgrid, Cell, CellGrid, Entry};
use typst_library::layout::{OuterVAlignment, Sizing};
use typst_library::model::{
Attribution, CiteElem, CiteGroup, Destination, EmphElem, EnumElem, FigureCaption,
- FigureElem, HeadingElem, LinkElem, LinkTarget, ListElem, ParbreakElem, QuoteElem,
- RefElem, StrongElem, TableCell, TableElem, TermsElem,
+ FigureElem, HeadingElem, LinkElem, LinkTarget, ListElem, OutlineElem, OutlineEntry,
+ ParbreakElem, QuoteElem, RefElem, StrongElem, TableCell, TableElem, TermsElem,
};
use typst_library::text::{
HighlightElem, LinebreakElem, OverlineElem, RawElem, RawLine, SpaceElem, StrikeElem,
@@ -36,6 +37,8 @@ pub fn register(rules: &mut NativeRuleMap) {
rules.register(Html, FIGURE_RULE);
rules.register(Html, FIGURE_CAPTION_RULE);
rules.register(Html, QUOTE_RULE);
+ rules.register(Html, OUTLINE_RULE);
+ rules.register(Html, OUTLINE_ENTRY_RULE);
rules.register(Html, REF_RULE);
rules.register(Html, CITE_GROUP_RULE);
rules.register(Html, TABLE_RULE);
@@ -274,6 +277,56 @@ const QUOTE_RULE: ShowFn = |elem, _, styles| {
Ok(realized)
};
+const OUTLINE_RULE: ShowFn = |elem, engine, styles| {
+ let (title, entries) = elem.realize(engine, styles)?;
+ let list = HtmlElem::new(tag::ol)
+ .with_attr(attr::class, "outline")
+ .with_styles(css::Properties::new().with("list-style", "none"))
+ .with_body(Some(Content::sequence(entries)))
+ .pack()
+ .spanned(elem.span());
+ Ok(title.unwrap_or_default() + list)
+};
+
+const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| {
+ let span = elem.span();
+ let context = Context::new(None, Some(styles));
+ let context = context.track();
+
+ let prefix = elem.prefix(engine, context, span)?;
+ let body = elem.body().at(elem.span())?;
+
+ let inner = if let Some(prefix) = prefix {
+ let separator = if let Some(elem) = elem.element.to_packed::() {
+ match elem.caption.get_ref(styles) {
+ Some(caption) => caption.get_separator(styles),
+ None => FigureCaption::local_separator_in(styles),
+ }
+ } else {
+ SpaceElem::shared().clone()
+ };
+ Content::sequence([
+ HtmlElem::new(tag::span)
+ .with_attr(attr::class, "prefix")
+ .with_body(Some(prefix))
+ .pack()
+ .spanned(elem.span()),
+ separator,
+ body,
+ ])
+ } else {
+ body
+ };
+
+ let loc = elem.element_location().at(span)?;
+ let dest = Destination::Location(loc);
+
+ Ok(HtmlElem::new(tag::li)
+ .with_body(Some(LinkElem::new(dest.into(), inner).pack()))
+ .pack()
+ .spanned(elem.span()))
+};
+
const REF_RULE: ShowFn = |elem, engine, styles| elem.realize(engine, styles);
const CITE_GROUP_RULE: ShowFn = |elem, engine, _| elem.realize(engine);
diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs
index c78f94d55..72fcf7acb 100644
--- a/crates/typst-layout/src/rules.rs
+++ b/crates/typst-layout/src/rules.rs
@@ -20,8 +20,8 @@ use typst_library::math::EquationElem;
use typst_library::model::{
Attribution, BibliographyElem, CiteElem, CiteGroup, CslSource, Destination, EmphElem,
EnumElem, FigureCaption, FigureElem, FootnoteElem, FootnoteEntry, HeadingElem,
- LinkElem, ListElem, Outlinable, OutlineElem, OutlineEntry, ParElem, ParbreakElem,
- QuoteElem, RefElem, StrongElem, TableCell, TableElem, TermsElem, Works,
+ LinkElem, ListElem, OutlineElem, OutlineEntry, ParElem, ParbreakElem, QuoteElem,
+ RefElem, StrongElem, TableCell, TableElem, TermsElem, Works,
};
use typst_library::pdf::EmbedElem;
use typst_library::text::{
@@ -412,38 +412,8 @@ const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| {
};
const OUTLINE_RULE: ShowFn = |elem, engine, styles| {
- let span = elem.span();
-
- // Build the outline title.
- let mut seq = vec![];
- if let Some(title) = elem.title.get_cloned(styles).unwrap_or_else(|| {
- Some(TextElem::packed(Packed::::local_name_in(styles)).spanned(span))
- }) {
- seq.push(
- HeadingElem::new(title)
- .with_depth(NonZeroUsize::ONE)
- .pack()
- .spanned(span),
- );
- }
-
- let elems = engine.introspector.query(&elem.target.get_ref(styles).0);
- let depth = elem.depth.get(styles).unwrap_or(NonZeroUsize::MAX);
-
- // Build the outline entries.
- for elem in elems {
- let Some(outlinable) = elem.with::() else {
- bail!(span, "cannot outline {}", elem.func().name());
- };
-
- let level = outlinable.level();
- if outlinable.outlined() && level <= depth {
- let entry = OutlineEntry::new(level, elem);
- seq.push(entry.pack().spanned(span));
- }
- }
-
- Ok(Content::sequence(seq))
+ let (title, entries) = elem.realize(engine, styles)?;
+ Ok(Content::sequence(title.into_iter().chain(entries)))
};
const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| {
@@ -454,6 +424,7 @@ const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| {
let prefix = elem.prefix(engine, context, span)?;
let inner = elem.inner(engine, context, span)?;
let block = if elem.element.is::() {
+ // Equation has no body and no levels, so indenting makes no sense.
let body = prefix.unwrap_or_default() + inner;
BlockElem::new()
.with_body(Some(BlockBody::Content(body)))
diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs
index ac3676eea..0fa1ad818 100644
--- a/crates/typst-library/src/model/figure.rs
+++ b/crates/typst-library/src/model/figure.rs
@@ -19,7 +19,7 @@ use crate::layout::{
VAlignment,
};
use crate::model::{Numbering, NumberingPattern, Outlinable, Refable, Supplement};
-use crate::text::{Lang, Region, TextElem};
+use crate::text::{Lang, TextElem};
use crate::visualize::ImageElem;
/// A figure with an optional caption.
@@ -526,23 +526,23 @@ impl FigureCaption {
Ok(realized)
}
- /// Gets the default separator in the given language and (optionally)
- /// region.
- fn local_separator(lang: Lang, _: Option) -> &'static str {
- match lang {
- Lang::CHINESE => "\u{2003}",
- Lang::FRENCH => ".\u{a0}– ",
- Lang::RUSSIAN => ". ",
- Lang::ENGLISH | _ => ": ",
- }
+ /// Retrieves the locale separator.
+ pub fn get_separator(&self, styles: StyleChain) -> Content {
+ self.separator
+ .get_cloned(styles)
+ .unwrap_or_else(|| Self::local_separator_in(styles))
}
- fn get_separator(&self, styles: StyleChain) -> Content {
- self.separator.get_cloned(styles).unwrap_or_else(|| {
- TextElem::packed(Self::local_separator(
- styles.get(TextElem::lang),
- styles.get(TextElem::region),
- ))
+ /// Gets the default separator in the given language and (optionally)
+ /// region.
+ pub fn local_separator_in(styles: StyleChain) -> Content {
+ styles.get_cloned(Self::separator).unwrap_or_else(|| {
+ TextElem::packed(match styles.get(TextElem::lang) {
+ Lang::CHINESE => "\u{2003}",
+ Lang::FRENCH => ".\u{a0}– ",
+ Lang::RUSSIAN => ". ",
+ Lang::ENGLISH | _ => ": ",
+ })
})
}
}
diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs
index 4bda02ba3..754811df6 100644
--- a/crates/typst-library/src/model/outline.rs
+++ b/crates/typst-library/src/model/outline.rs
@@ -248,6 +248,53 @@ impl OutlineElem {
type OutlineEntry;
}
+impl Packed {
+ /// Realizes the title and entries.
+ pub fn realize(
+ &self,
+ engine: &mut Engine,
+ styles: StyleChain,
+ ) -> SourceResult<(Option, Vec)> {
+ let span = self.span();
+
+ // Build the outline title.
+ let title = self
+ .title
+ .get_cloned(styles)
+ .unwrap_or_else(|| {
+ Some(
+ TextElem::packed(Packed::::local_name_in(styles))
+ .spanned(span),
+ )
+ })
+ .map(|title| {
+ HeadingElem::new(title)
+ .with_depth(NonZeroUsize::ONE)
+ .pack()
+ .spanned(span)
+ });
+
+ let elems = engine.introspector.query(&self.target.get_ref(styles).0);
+ let depth = self.depth.get(styles).unwrap_or(NonZeroUsize::MAX);
+
+ // Build the outline entries.
+ let mut entries = vec![];
+ for elem in elems {
+ let Some(outlinable) = elem.with::() else {
+ bail!(span, "cannot outline {}", elem.func().name());
+ };
+
+ let level = outlinable.level();
+ if outlinable.outlined() && level <= depth {
+ let entry = OutlineEntry::new(level, elem);
+ entries.push(entry.pack().spanned(span));
+ }
+ }
+
+ Ok((title, entries))
+ }
+}
+
impl ShowSet for Packed {
fn show_set(&self, styles: StyleChain) -> Styles {
let mut out = Styles::new();
diff --git a/tests/ref/html/outline-figure-html.html b/tests/ref/html/outline-figure-html.html
new file mode 100644
index 000000000..37fb6a38c
--- /dev/null
+++ b/tests/ref/html/outline-figure-html.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+ Contents
+
+ - Figure 1: A
+ - Figure 2: B
+
+
+ An A
+ Figure 1: A
+
+
+ An B
+ Figure 2: B
+
+
+
diff --git a/tests/ref/html/outline-html.html b/tests/ref/html/outline-html.html
new file mode 100644
index 000000000..b7a3bc1b9
--- /dev/null
+++ b/tests/ref/html/outline-html.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+ Contents
+
+ - 1 A
+ - 2 B
+ - 3 C
+ - 4 D
+ - 4.1 E
+ - 4.2 F
+
+ 1 A
+ 2 B
+ 3 C
+ 4 D
+ 4.1 E
+ 4.2 F
+
+
diff --git a/tests/suite/model/outline.typ b/tests/suite/model/outline.typ
index 49fd7d7cb..d75961032 100644
--- a/tests/suite/model/outline.typ
+++ b/tests/suite/model/outline.typ
@@ -252,6 +252,23 @@ A
= B
= C
+--- outline-html html ---
+#set heading(numbering: "1.1")
+
+#outline()
+
+= A
+= B
+= C
+= D
+== E
+== F
+
+--- outline-figure-html html ---
+#outline(target: figure)
+#figure([An A], caption: [A])
+#figure([An B], caption: [B])
+
--- issue-2048-outline-multiline ---
// Without the word joiner between the dots and the page number,
// the page number would be alone in its line.