diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs
index 0f73ef8c..472e155b 100644
--- a/crates/typst-html/src/rules.rs
+++ b/crates/typst-html/src/rules.rs
@@ -11,7 +11,7 @@ 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,
+ RefElem, StrongElem, TableCell, TableElem, TermsElem, TitleElem,
};
use typst_library::text::{
HighlightElem, LinebreakElem, OverlineElem, RawElem, RawLine, SmallcapsElem,
@@ -32,6 +32,7 @@ pub fn register(rules: &mut NativeRuleMap) {
rules.register(Html, ENUM_RULE);
rules.register(Html, TERMS_RULE);
rules.register(Html, LINK_RULE);
+ rules.register(Html, TITLE_RULE);
rules.register(Html, HEADING_RULE);
rules.register(Html, FIGURE_RULE);
rules.register(Html, FIGURE_CAPTION_RULE);
@@ -161,6 +162,12 @@ const LINK_RULE: ShowFn = |elem, engine, _| {
.pack())
};
+const TITLE_RULE: ShowFn = |elem, _, styles| {
+ Ok(HtmlElem::new(tag::h1)
+ .with_body(Some(elem.resolve_body(styles).at(elem.span())?))
+ .pack())
+};
+
const HEADING_RULE: ShowFn = |elem, engine, styles| {
let span = elem.span();
diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs
index a3616f96..089b4e67 100644
--- a/crates/typst-layout/src/rules.rs
+++ b/crates/typst-layout/src/rules.rs
@@ -21,7 +21,7 @@ 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,
+ QuoteElem, RefElem, StrongElem, TableCell, TableElem, TermsElem, TitleElem, Works,
};
use typst_library::pdf::EmbedElem;
use typst_library::text::{
@@ -47,6 +47,7 @@ pub fn register(rules: &mut NativeRuleMap) {
rules.register(Paged, ENUM_RULE);
rules.register(Paged, TERMS_RULE);
rules.register(Paged, LINK_RULE);
+ rules.register(Paged, TITLE_RULE);
rules.register(Paged, HEADING_RULE);
rules.register(Paged, FIGURE_RULE);
rules.register(Paged, FIGURE_CAPTION_RULE);
@@ -216,6 +217,12 @@ const LINK_RULE: ShowFn = |elem, engine, _| {
Ok(body.linked(dest))
};
+const TITLE_RULE: ShowFn = |elem, _, styles| {
+ Ok(BlockElem::new()
+ .with_body(Some(BlockBody::Content(elem.resolve_body(styles).at(elem.span())?)))
+ .pack())
+};
+
const HEADING_RULE: ShowFn = |elem, engine, styles| {
const SPACING_TO_NUMBERING: Em = Em::new(0.3);
diff --git a/crates/typst-library/src/model/mod.rs b/crates/typst-library/src/model/mod.rs
index a0f7e11a..015fdd27 100644
--- a/crates/typst-library/src/model/mod.rs
+++ b/crates/typst-library/src/model/mod.rs
@@ -20,6 +20,7 @@ mod reference;
mod strong;
mod table;
mod terms;
+mod title;
pub use self::bibliography::*;
pub use self::cite::*;
@@ -39,6 +40,7 @@ pub use self::reference::*;
pub use self::strong::*;
pub use self::table::*;
pub use self::terms::*;
+pub use self::title::*;
use crate::foundations::Scope;
@@ -54,6 +56,7 @@ pub fn define(global: &mut Scope) {
global.define_elem::();
global.define_elem::();
global.define_elem::();
+ global.define_elem::();
global.define_elem::();
global.define_elem::();
global.define_elem::();
diff --git a/crates/typst-library/src/model/title.rs b/crates/typst-library/src/model/title.rs
new file mode 100644
index 00000000..d1c8b48f
--- /dev/null
+++ b/crates/typst-library/src/model/title.rs
@@ -0,0 +1,77 @@
+use crate::diag::{Hint, HintedStrResult};
+use crate::foundations::{Content, Packed, ShowSet, Smart, StyleChain, Styles, elem};
+use crate::introspection::Locatable;
+use crate::layout::{BlockElem, Em};
+use crate::model::DocumentElem;
+use crate::text::{FontWeight, TextElem, TextSize};
+
+/// A document title.
+///
+/// This should be used to display the main title of the whole document and
+/// should occur only once per document. In contrast, level 1
+/// [headings]($heading) are intended to be used for the top-level sections of
+/// the document.
+///
+/// Note that additional frontmatter (like an author list) that should appear
+/// together with the title does not belong in its body.
+///
+/// In HTML export, this shows as a `h1` element while level 1 headings show
+/// as `h2` elements.
+///
+/// # Example
+/// ```example
+/// #set document(
+/// title: [Interstellar Mail Delivery]
+/// )
+///
+/// #title()
+///
+/// = Introduction
+/// In recent years, ...
+/// ```
+#[elem(Locatable, ShowSet)]
+pub struct TitleElem {
+ /// The content of the title.
+ ///
+ /// When omitted (or `{auto}`), this will default to [`document.title`]. In
+ /// this case, a document title must have been previously set with
+ /// `{set document(title: [..])}`.
+ ///
+ /// ```example
+ /// #set document(title: "Course ABC, Homework 1")
+ /// #title[Homework 1]
+ ///
+ /// ...
+ /// ```
+ #[positional]
+ pub body: Smart,
+}
+
+impl TitleElem {
+ pub fn resolve_body(&self, styles: StyleChain) -> HintedStrResult {
+ match self.body.get_cloned(styles) {
+ Smart::Auto => styles
+ .get_cloned(DocumentElem::title)
+ .ok_or("document title was not set")
+ .hint("set the title with `set document(title: [...])`")
+ .hint("or provide an explicit body with `title[..]`"),
+ Smart::Custom(body) => Ok(body),
+ }
+ }
+}
+
+impl ShowSet for Packed {
+ fn show_set(&self, _styles: StyleChain) -> Styles {
+ const SIZE: Em = Em::new(1.7);
+ const ABOVE: Em = Em::new(1.125);
+ const BELOW: Em = Em::new(0.75);
+
+ let mut out = Styles::new();
+ out.set(TextElem::size, TextSize(SIZE.into()));
+ out.set(TextElem::weight, FontWeight::BOLD);
+ out.set(BlockElem::above, Smart::Custom(ABOVE.into()));
+ out.set(BlockElem::below, Smart::Custom(BELOW.into()));
+ out.set(BlockElem::sticky, true);
+ out
+ }
+}
diff --git a/tests/ref/html/title-and-heading.html b/tests/ref/html/title-and-heading.html
new file mode 100644
index 00000000..c09062ec
--- /dev/null
+++ b/tests/ref/html/title-and-heading.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ A cool title
+ Some level one heading
+
+
diff --git a/tests/ref/html/title-basic.html b/tests/ref/html/title-basic.html
new file mode 100644
index 00000000..c9995358
--- /dev/null
+++ b/tests/ref/html/title-basic.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+ Some Title
+
+
diff --git a/tests/ref/html/title-with-body.html b/tests/ref/html/title-with-body.html
new file mode 100644
index 00000000..7b58813b
--- /dev/null
+++ b/tests/ref/html/title-with-body.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ My title
+
+
+ My display title
+
+
diff --git a/tests/ref/html/title.html b/tests/ref/html/title.html
new file mode 100644
index 00000000..b1a59cdf
--- /dev/null
+++ b/tests/ref/html/title.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ My title
+
+
+ My title
+ A level one heading
+
+
diff --git a/tests/ref/title-show-set.png b/tests/ref/title-show-set.png
new file mode 100644
index 00000000..4f7431b9
Binary files /dev/null and b/tests/ref/title-show-set.png differ
diff --git a/tests/ref/title-with-body-auto.png b/tests/ref/title-with-body-auto.png
new file mode 100644
index 00000000..3c861186
Binary files /dev/null and b/tests/ref/title-with-body-auto.png differ
diff --git a/tests/ref/title-with-body.png b/tests/ref/title-with-body.png
new file mode 100644
index 00000000..d71df8b1
Binary files /dev/null and b/tests/ref/title-with-body.png differ
diff --git a/tests/ref/title.png b/tests/ref/title.png
new file mode 100644
index 00000000..9495e96b
Binary files /dev/null and b/tests/ref/title.png differ
diff --git a/tests/suite/model/title.typ b/tests/suite/model/title.typ
new file mode 100644
index 00000000..6a7ff131
--- /dev/null
+++ b/tests/suite/model/title.typ
@@ -0,0 +1,24 @@
+// Test title element.
+
+--- title render html ---
+#set document(title: "My title")
+#title()
+= A level one heading
+
+--- title-with-body render html ---
+#set document(title: "My title")
+#title[My display title]
+
+--- title-with-body-auto render ---
+#set document(title: "My title")
+#title(auto)
+
+--- title-show-set ---
+#show title: set text(blue)
+#title[A blue title]
+
+--- title-unset ---
+// Error: 2-9 document title was not set
+// Hint: 2-9 set the title with `set document(title: [...])`
+// Hint: 2-9 or provide an explicit body with `title[..]`
+#title()