typst_library/model/heading.rs
1use std::num::NonZeroUsize;
2
3use ecow::eco_format;
4use typst_utils::{Get, NonZeroExt};
5
6use crate::diag::{warning, SourceResult};
7use crate::engine::Engine;
8use crate::foundations::{
9 elem, Content, NativeElement, Packed, Resolve, Show, ShowSet, Smart, StyleChain,
10 Styles, Synthesize, TargetElem,
11};
12use crate::html::{attr, tag, HtmlElem};
13use crate::introspection::{
14 Count, Counter, CounterUpdate, Locatable, Locator, LocatorLink,
15};
16use crate::layout::{Abs, Axes, BlockBody, BlockElem, Em, HElem, Length, Region, Sides};
17use crate::model::{Numbering, Outlinable, Refable, Supplement};
18use crate::text::{FontWeight, LocalName, SpaceElem, TextElem, TextSize};
19
20/// A section heading.
21///
22/// With headings, you can structure your document into sections. Each heading
23/// has a _level,_ which starts at one and is unbounded upwards. This level
24/// indicates the logical role of the following content (section, subsection,
25/// etc.) A top-level heading indicates a top-level section of the document
26/// (not the document's title).
27///
28/// Typst can automatically number your headings for you. To enable numbering,
29/// specify how you want your headings to be numbered with a
30/// [numbering pattern or function]($numbering).
31///
32/// Independently of the numbering, Typst can also automatically generate an
33/// [outline] of all headings for you. To exclude one or more headings from this
34/// outline, you can set the `outlined` parameter to `{false}`.
35///
36/// # Example
37/// ```example
38/// #set heading(numbering: "1.a)")
39///
40/// = Introduction
41/// In recent years, ...
42///
43/// == Preliminaries
44/// To start, ...
45/// ```
46///
47/// # Syntax
48/// Headings have dedicated syntax: They can be created by starting a line with
49/// one or multiple equals signs, followed by a space. The number of equals
50/// signs determines the heading's logical nesting depth. The `{offset}` field
51/// can be set to configure the starting depth.
52#[elem(Locatable, Synthesize, Count, Show, ShowSet, LocalName, Refable, Outlinable)]
53pub struct HeadingElem {
54 /// The absolute nesting depth of the heading, starting from one. If set
55 /// to `{auto}`, it is computed from `{offset + depth}`.
56 ///
57 /// This is primarily useful for usage in [show rules]($styling/#show-rules)
58 /// (either with [`where`]($function.where) selectors or by accessing the
59 /// level directly on a shown heading).
60 ///
61 /// ```example
62 /// #show heading.where(level: 2): set text(red)
63 ///
64 /// = Level 1
65 /// == Level 2
66 ///
67 /// #set heading(offset: 1)
68 /// = Also level 2
69 /// == Level 3
70 /// ```
71 pub level: Smart<NonZeroUsize>,
72
73 /// The relative nesting depth of the heading, starting from one. This is
74 /// combined with `{offset}` to compute the actual `{level}`.
75 ///
76 /// This is set by the heading syntax, such that `[== Heading]` creates a
77 /// heading with logical depth of 2, but actual level `{offset + 2}`. If you
78 /// construct a heading manually, you should typically prefer this over
79 /// setting the absolute level.
80 #[default(NonZeroUsize::ONE)]
81 pub depth: NonZeroUsize,
82
83 /// The starting offset of each heading's `{level}`, used to turn its
84 /// relative `{depth}` into its absolute `{level}`.
85 ///
86 /// ```example
87 /// = Level 1
88 ///
89 /// #set heading(offset: 1, numbering: "1.1")
90 /// = Level 2
91 ///
92 /// #heading(offset: 2, depth: 2)[
93 /// I'm level 4
94 /// ]
95 /// ```
96 #[default(0)]
97 pub offset: usize,
98
99 /// How to number the heading. Accepts a
100 /// [numbering pattern or function]($numbering).
101 ///
102 /// ```example
103 /// #set heading(numbering: "1.a.")
104 ///
105 /// = A section
106 /// == A subsection
107 /// === A sub-subsection
108 /// ```
109 #[borrowed]
110 pub numbering: Option<Numbering>,
111
112 /// A supplement for the heading.
113 ///
114 /// For references to headings, this is added before the referenced number.
115 ///
116 /// If a function is specified, it is passed the referenced heading and
117 /// should return content.
118 ///
119 /// ```example
120 /// #set heading(numbering: "1.", supplement: [Chapter])
121 ///
122 /// = Introduction <intro>
123 /// In @intro, we see how to turn
124 /// Sections into Chapters. And
125 /// in @intro[Part], it is done
126 /// manually.
127 /// ```
128 pub supplement: Smart<Option<Supplement>>,
129
130 /// Whether the heading should appear in the [outline].
131 ///
132 /// Note that this property, if set to `{true}`, ensures the heading is also
133 /// shown as a bookmark in the exported PDF's outline (when exporting to
134 /// PDF). To change that behavior, use the `bookmarked` property.
135 ///
136 /// ```example
137 /// #outline()
138 ///
139 /// #heading[Normal]
140 /// This is a normal heading.
141 ///
142 /// #heading(outlined: false)[Hidden]
143 /// This heading does not appear
144 /// in the outline.
145 /// ```
146 #[default(true)]
147 pub outlined: bool,
148
149 /// Whether the heading should appear as a bookmark in the exported PDF's
150 /// outline. Doesn't affect other export formats, such as PNG.
151 ///
152 /// The default value of `{auto}` indicates that the heading will only
153 /// appear in the exported PDF's outline if its `outlined` property is set
154 /// to `{true}`, that is, if it would also be listed in Typst's [outline].
155 /// Setting this property to either `{true}` (bookmark) or `{false}` (don't
156 /// bookmark) bypasses that behavior.
157 ///
158 /// ```example
159 /// #heading[Normal heading]
160 /// This heading will be shown in
161 /// the PDF's bookmark outline.
162 ///
163 /// #heading(bookmarked: false)[Not bookmarked]
164 /// This heading won't be
165 /// bookmarked in the resulting
166 /// PDF.
167 /// ```
168 #[default(Smart::Auto)]
169 pub bookmarked: Smart<bool>,
170
171 /// The indent all but the first line of a heading should have.
172 ///
173 /// The default value of `{auto}` indicates that the subsequent heading
174 /// lines will be indented based on the width of the numbering.
175 ///
176 /// ```example
177 /// #set heading(numbering: "1.")
178 /// #heading[A very, very, very, very, very, very long heading]
179 /// ```
180 #[default(Smart::Auto)]
181 pub hanging_indent: Smart<Length>,
182
183 /// The heading's title.
184 #[required]
185 pub body: Content,
186}
187
188impl HeadingElem {
189 pub fn resolve_level(&self, styles: StyleChain) -> NonZeroUsize {
190 self.level(styles).unwrap_or_else(|| {
191 NonZeroUsize::new(self.offset(styles) + self.depth(styles).get())
192 .expect("overflow to 0 on NoneZeroUsize + usize")
193 })
194 }
195}
196
197impl Synthesize for Packed<HeadingElem> {
198 fn synthesize(
199 &mut self,
200 engine: &mut Engine,
201 styles: StyleChain,
202 ) -> SourceResult<()> {
203 let supplement = match (**self).supplement(styles) {
204 Smart::Auto => TextElem::packed(Self::local_name_in(styles)),
205 Smart::Custom(None) => Content::empty(),
206 Smart::Custom(Some(supplement)) => {
207 supplement.resolve(engine, styles, [self.clone().pack()])?
208 }
209 };
210
211 let elem = self.as_mut();
212 elem.push_level(Smart::Custom(elem.resolve_level(styles)));
213 elem.push_supplement(Smart::Custom(Some(Supplement::Content(supplement))));
214 Ok(())
215 }
216}
217
218impl Show for Packed<HeadingElem> {
219 #[typst_macros::time(name = "heading", span = self.span())]
220 fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
221 let html = TargetElem::target_in(styles).is_html();
222
223 const SPACING_TO_NUMBERING: Em = Em::new(0.3);
224
225 let span = self.span();
226 let mut realized = self.body.clone();
227
228 let hanging_indent = self.hanging_indent(styles);
229 let mut indent = match hanging_indent {
230 Smart::Custom(length) => length.resolve(styles),
231 Smart::Auto => Abs::zero(),
232 };
233
234 if let Some(numbering) = (**self).numbering(styles).as_ref() {
235 let location = self.location().unwrap();
236 let numbering = Counter::of(HeadingElem::elem())
237 .display_at_loc(engine, location, styles, numbering)?
238 .spanned(span);
239
240 if hanging_indent.is_auto() && !html {
241 let pod = Region::new(Axes::splat(Abs::inf()), Axes::splat(false));
242
243 // We don't have a locator for the numbering here, so we just
244 // use the measurement infrastructure for now.
245 let link = LocatorLink::measure(location);
246 let size = (engine.routines.layout_frame)(
247 engine,
248 &numbering,
249 Locator::link(&link),
250 styles,
251 pod,
252 )?
253 .size();
254
255 indent = size.x + SPACING_TO_NUMBERING.resolve(styles);
256 }
257
258 let spacing = if html {
259 SpaceElem::shared().clone()
260 } else {
261 HElem::new(SPACING_TO_NUMBERING.into()).with_weak(true).pack()
262 };
263
264 realized = numbering + spacing + realized;
265 }
266
267 Ok(if html {
268 // HTML's h1 is closer to a title element. There should only be one.
269 // Meanwhile, a level 1 Typst heading is a section heading. For this
270 // reason, levels are offset by one: A Typst level 1 heading becomes
271 // a `<h2>`.
272 let level = self.resolve_level(styles).get();
273 if level >= 6 {
274 engine.sink.warn(warning!(span,
275 "heading of level {} was transformed to \
276 <div role=\"heading\" aria-level=\"{}\">, which is not \
277 supported by all assistive technology",
278 level, level + 1;
279 hint: "HTML only supports <h1> to <h6>, not <h{}>", level + 1;
280 hint: "you may want to restructure your document so that \
281 it doesn't contain deep headings"));
282 HtmlElem::new(tag::div)
283 .with_body(Some(realized))
284 .with_attr(attr::role, "heading")
285 .with_attr(attr::aria_level, eco_format!("{}", level + 1))
286 .pack()
287 .spanned(span)
288 } else {
289 let t = [tag::h2, tag::h3, tag::h4, tag::h5, tag::h6][level - 1];
290 HtmlElem::new(t).with_body(Some(realized)).pack().spanned(span)
291 }
292 } else {
293 let block = if indent != Abs::zero() {
294 let body = HElem::new((-indent).into()).pack() + realized;
295 let inset = Sides::default()
296 .with(TextElem::dir_in(styles).start(), Some(indent.into()));
297 BlockElem::new()
298 .with_body(Some(BlockBody::Content(body)))
299 .with_inset(inset)
300 } else {
301 BlockElem::new().with_body(Some(BlockBody::Content(realized)))
302 };
303 block.pack().spanned(span)
304 })
305 }
306}
307
308impl ShowSet for Packed<HeadingElem> {
309 fn show_set(&self, styles: StyleChain) -> Styles {
310 let level = (**self).resolve_level(styles).get();
311 let scale = match level {
312 1 => 1.4,
313 2 => 1.2,
314 _ => 1.0,
315 };
316
317 let size = Em::new(scale);
318 let above = Em::new(if level == 1 { 1.8 } else { 1.44 }) / scale;
319 let below = Em::new(0.75) / scale;
320
321 let mut out = Styles::new();
322 out.set(TextElem::set_size(TextSize(size.into())));
323 out.set(TextElem::set_weight(FontWeight::BOLD));
324 out.set(BlockElem::set_above(Smart::Custom(above.into())));
325 out.set(BlockElem::set_below(Smart::Custom(below.into())));
326 out.set(BlockElem::set_sticky(true));
327 out
328 }
329}
330
331impl Count for Packed<HeadingElem> {
332 fn update(&self) -> Option<CounterUpdate> {
333 (**self)
334 .numbering(StyleChain::default())
335 .is_some()
336 .then(|| CounterUpdate::Step((**self).resolve_level(StyleChain::default())))
337 }
338}
339
340impl Refable for Packed<HeadingElem> {
341 fn supplement(&self) -> Content {
342 // After synthesis, this should always be custom content.
343 match (**self).supplement(StyleChain::default()) {
344 Smart::Custom(Some(Supplement::Content(content))) => content,
345 _ => Content::empty(),
346 }
347 }
348
349 fn counter(&self) -> Counter {
350 Counter::of(HeadingElem::elem())
351 }
352
353 fn numbering(&self) -> Option<&Numbering> {
354 (**self).numbering(StyleChain::default()).as_ref()
355 }
356}
357
358impl Outlinable for Packed<HeadingElem> {
359 fn outlined(&self) -> bool {
360 (**self).outlined(StyleChain::default())
361 }
362
363 fn level(&self) -> NonZeroUsize {
364 (**self).resolve_level(StyleChain::default())
365 }
366
367 fn prefix(&self, numbers: Content) -> Content {
368 numbers
369 }
370
371 fn body(&self) -> Content {
372 self.body.clone()
373 }
374}
375
376impl LocalName for Packed<HeadingElem> {
377 const KEY: &'static str = "heading";
378}