Support for outline in HTML

This commit is contained in:
Laurenz 2025-07-15 20:49:34 +02:00
parent c58ef50a0a
commit f4be243396
8 changed files with 187 additions and 54 deletions

View File

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

View File

@ -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<QuoteElem> = |elem, _, styles| {
Ok(realized)
};
const OUTLINE_RULE: ShowFn<OutlineElem> = |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<OutlineEntry> = |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::<FigureElem>() {
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<RefElem> = |elem, engine, styles| elem.realize(engine, styles);
const CITE_GROUP_RULE: ShowFn<CiteGroup> = |elem, engine, _| elem.realize(engine);

View File

@ -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<FootnoteEntry> = |elem, engine, styles| {
};
const OUTLINE_RULE: ShowFn<OutlineElem> = |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::<OutlineElem>::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::<dyn Outlinable>() 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<OutlineEntry> = |elem, engine, styles| {
@ -454,6 +424,7 @@ const OUTLINE_ENTRY_RULE: ShowFn<OutlineEntry> = |elem, engine, styles| {
let prefix = elem.prefix(engine, context, span)?;
let inner = elem.inner(engine, context, span)?;
let block = if elem.element.is::<EquationElem>() {
// 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)))

View File

@ -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<Region>) -> &'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 | _ => ": ",
})
})
}
}

View File

@ -248,6 +248,53 @@ impl OutlineElem {
type OutlineEntry;
}
impl Packed<OutlineElem> {
/// Realizes the title and entries.
pub fn realize(
&self,
engine: &mut Engine,
styles: StyleChain,
) -> SourceResult<(Option<Content>, Vec<Content>)> {
let span = self.span();
// Build the outline title.
let title = self
.title
.get_cloned(styles)
.unwrap_or_else(|| {
Some(
TextElem::packed(Packed::<OutlineElem>::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::<dyn Outlinable>() 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<OutlineElem> {
fn show_set(&self, styles: StyleChain) -> Styles {
let mut out = Styles::new();

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h2>Contents</h2>
<ol class="outline" style="list-style: none">
<li><a href="#loc-1"><span class="prefix">Figure 1</span>: A</a></li>
<li><a href="#loc-2"><span class="prefix">Figure 2</span>: B</a></li>
</ol>
<figure id="loc-1">
<p>An A</p>
<figcaption>Figure 1: A</figcaption>
</figure>
<figure id="loc-2">
<p>An B</p>
<figcaption>Figure 2: B</figcaption>
</figure>
</body>
</html>

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h2>Contents</h2>
<ol class="outline" style="list-style: none">
<li><a href="#a"><span class="prefix">1</span> A</a></li>
<li><a href="#b"><span class="prefix">2</span> B</a></li>
<li><a href="#c"><span class="prefix">3</span> C</a></li>
<li><a href="#d"><span class="prefix">4</span> D</a></li>
<li><a href="#e"><span class="prefix">4.1</span> E</a></li>
<li><a href="#f"><span class="prefix">4.2</span> F</a></li>
</ol>
<h2 id="a">1 A</h2>
<h2 id="b">2 B</h2>
<h2 id="c">3 C</h2>
<h2 id="d">4 D</h2>
<h3 id="e">4.1 E</h3>
<h3 id="f">4.2 F</h3>
</body>
</html>

View File

@ -252,6 +252,23 @@ A
= B
= C
--- outline-html html ---
#set heading(numbering: "1.1")
#outline()
= A <a>
= B <b>
= C <c>
= D <d>
== E <e>
== F <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.