summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/model
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2025-01-24 13:11:26 +0100
committerGitHub <noreply@github.com>2025-01-24 12:11:26 +0000
commit26e65bfef5b1da7f6c72e1409237cf03fb5d6069 (patch)
treedae6f71efead43736202dd6aea933b95b1bc7a14 /crates/typst-library/src/model
parent467968af0788a3059e1bed47f9daee846f5b3904 (diff)
Semantic paragraphs (#5746)
Diffstat (limited to 'crates/typst-library/src/model')
-rw-r--r--crates/typst-library/src/model/bibliography.rs44
-rw-r--r--crates/typst-library/src/model/enum.rs15
-rw-r--r--crates/typst-library/src/model/figure.rs33
-rw-r--r--crates/typst-library/src/model/footnote.rs6
-rw-r--r--crates/typst-library/src/model/list.rs13
-rw-r--r--crates/typst-library/src/model/outline.rs1
-rw-r--r--crates/typst-library/src/model/par.rs110
-rw-r--r--crates/typst-library/src/model/quote.rs19
-rw-r--r--crates/typst-library/src/model/terms.rs22
9 files changed, 166 insertions, 97 deletions
diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs
index 762a97fd..a391e580 100644
--- a/crates/typst-library/src/model/bibliography.rs
+++ b/crates/typst-library/src/model/bibliography.rs
@@ -17,7 +17,7 @@ use hayagriva::{
use indexmap::IndexMap;
use smallvec::{smallvec, SmallVec};
use typst_syntax::{Span, Spanned};
-use typst_utils::{ManuallyHash, NonZeroExt, PicoStr};
+use typst_utils::{Get, ManuallyHash, NonZeroExt, PicoStr};
use crate::diag::{bail, error, At, FileError, HintedStrResult, SourceResult, StrResult};
use crate::engine::Engine;
@@ -29,7 +29,7 @@ use crate::foundations::{
use crate::introspection::{Introspector, Locatable, Location};
use crate::layout::{
BlockBody, BlockElem, Em, GridCell, GridChild, GridElem, GridItem, HElem, PadElem,
- Sizing, TrackSizings, VElem,
+ Sides, Sizing, TrackSizings,
};
use crate::loading::{DataSource, Load};
use crate::model::{
@@ -206,19 +206,20 @@ impl Show for Packed<BibliographyElem> {
const COLUMN_GUTTER: Em = Em::new(0.65);
const INDENT: Em = Em::new(1.5);
+ let span = self.span();
+
let mut seq = vec![];
if let Some(title) = self.title(styles).unwrap_or_else(|| {
- Some(TextElem::packed(Self::local_name_in(styles)).spanned(self.span()))
+ Some(TextElem::packed(Self::local_name_in(styles)).spanned(span))
}) {
seq.push(
HeadingElem::new(title)
.with_depth(NonZeroUsize::ONE)
.pack()
- .spanned(self.span()),
+ .spanned(span),
);
}
- let span = self.span();
let works = Works::generate(engine).at(span)?;
let references = works
.references
@@ -226,10 +227,9 @@ impl Show for Packed<BibliographyElem> {
.ok_or("CSL style is not suitable for bibliographies")
.at(span)?;
- let row_gutter = ParElem::spacing_in(styles);
- let row_gutter_elem = VElem::new(row_gutter.into()).with_weak(true).pack();
-
if references.iter().any(|(prefix, _)| prefix.is_some()) {
+ let row_gutter = ParElem::spacing_in(styles);
+
let mut cells = vec![];
for (prefix, reference) in references {
cells.push(GridChild::Item(GridItem::Cell(
@@ -246,23 +246,27 @@ impl Show for Packed<BibliographyElem> {
.with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()]))
.with_row_gutter(TrackSizings(smallvec![row_gutter.into()]))
.pack()
- .spanned(self.span()),
+ .spanned(span),
);
} else {
- for (i, (_, reference)) in references.iter().enumerate() {
- if i > 0 {
- seq.push(row_gutter_elem.clone());
- }
- seq.push(reference.clone());
- }
- }
+ for (_, reference) in references {
+ let realized = reference.clone();
+ let block = if works.hanging_indent {
+ let body = HElem::new((-INDENT).into()).pack() + realized;
+ let inset = Sides::default()
+ .with(TextElem::dir_in(styles).start(), Some(INDENT.into()));
+ BlockElem::new()
+ .with_body(Some(BlockBody::Content(body)))
+ .with_inset(inset)
+ } else {
+ BlockElem::new().with_body(Some(BlockBody::Content(realized)))
+ };
- let mut content = Content::sequence(seq);
- if works.hanging_indent {
- content = content.styled(ParElem::set_hanging_indent(INDENT.into()));
+ seq.push(block.pack().spanned(span));
+ }
}
- Ok(content)
+ Ok(Content::sequence(seq))
}
}
diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs
index 4dc834ab..a4126e72 100644
--- a/crates/typst-library/src/model/enum.rs
+++ b/crates/typst-library/src/model/enum.rs
@@ -11,7 +11,9 @@ use crate::foundations::{
};
use crate::html::{attr, tag, HtmlElem};
use crate::layout::{Alignment, BlockElem, Em, HAlignment, Length, VAlignment, VElem};
-use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern, ParElem};
+use crate::model::{
+ ListItemLike, ListLike, Numbering, NumberingPattern, ParElem, ParbreakElem,
+};
/// A numbered list.
///
@@ -226,6 +228,8 @@ impl EnumElem {
impl Show for Packed<EnumElem> {
fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let tight = self.tight(styles);
+
if TargetElem::target_in(styles).is_html() {
let mut elem = HtmlElem::new(tag::ol);
if self.reversed(styles) {
@@ -239,7 +243,12 @@ impl Show for Packed<EnumElem> {
if let Some(nr) = item.number(styles) {
li = li.with_attr(attr::value, eco_format!("{nr}"));
}
- li.with_body(Some(item.body.clone())).pack().spanned(item.span())
+ // Text in wide enums shall always turn into paragraphs.
+ let mut body = item.body.clone();
+ if !tight {
+ body += ParbreakElem::shared();
+ }
+ li.with_body(Some(body)).pack().spanned(item.span())
}));
return Ok(elem.with_body(Some(body)).pack().spanned(self.span()));
}
@@ -249,7 +258,7 @@ impl Show for Packed<EnumElem> {
.pack()
.spanned(self.span());
- if self.tight(styles) {
+ if tight {
let leading = ParElem::leading_in(styles);
let spacing =
VElem::new(leading.into()).with_weak(true).with_attach(true).pack();
diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs
index ce7460c9..78a79a8e 100644
--- a/crates/typst-library/src/model/figure.rs
+++ b/crates/typst-library/src/model/figure.rs
@@ -19,7 +19,9 @@ use crate::layout::{
AlignElem, Alignment, BlockBody, BlockElem, Em, HAlignment, Length, OuterVAlignment,
PlaceElem, PlacementScope, VAlignment, VElem,
};
-use crate::model::{Numbering, NumberingPattern, Outlinable, Refable, Supplement};
+use crate::model::{
+ Numbering, NumberingPattern, Outlinable, ParbreakElem, Refable, Supplement,
+};
use crate::text::{Lang, Region, TextElem};
use crate::visualize::ImageElem;
@@ -328,6 +330,7 @@ impl Synthesize for Packed<FigureElem> {
impl Show for Packed<FigureElem> {
#[typst_macros::time(name = "figure", span = self.span())]
fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let span = self.span();
let target = TargetElem::target_in(styles);
let mut realized = self.body.clone();
@@ -341,24 +344,27 @@ impl Show for Packed<FigureElem> {
seq.push(first);
if !target.is_html() {
let v = VElem::new(self.gap(styles).into()).with_weak(true);
- seq.push(v.pack().spanned(self.span()))
+ seq.push(v.pack().spanned(span))
}
seq.push(second);
realized = Content::sequence(seq)
}
+ // Ensure that the body is considered a paragraph.
+ realized += ParbreakElem::shared().clone().spanned(span);
+
if target.is_html() {
return Ok(HtmlElem::new(tag::figure)
.with_body(Some(realized))
.pack()
- .spanned(self.span()));
+ .spanned(span));
}
// Wrap the contents in a block.
realized = BlockElem::new()
.with_body(Some(BlockBody::Content(realized)))
.pack()
- .spanned(self.span());
+ .spanned(span);
// Wrap in a float.
if let Some(align) = self.placement(styles) {
@@ -367,10 +373,10 @@ impl Show for Packed<FigureElem> {
.with_scope(self.scope(styles))
.with_float(true)
.pack()
- .spanned(self.span());
+ .spanned(span);
} else if self.scope(styles) == PlacementScope::Parent {
bail!(
- self.span(),
+ span,
"parent-scoped placement is only available for floating figures";
hint: "you can enable floating placement with `figure(placement: auto, ..)`"
);
@@ -604,14 +610,17 @@ impl Show for Packed<FigureCaption> {
realized = supplement + numbers + self.get_separator(styles) + realized;
}
- if TargetElem::target_in(styles).is_html() {
- return Ok(HtmlElem::new(tag::figcaption)
+ Ok(if TargetElem::target_in(styles).is_html() {
+ HtmlElem::new(tag::figcaption)
.with_body(Some(realized))
.pack()
- .spanned(self.span()));
- }
-
- Ok(realized)
+ .spanned(self.span())
+ } else {
+ BlockElem::new()
+ .with_body(Some(BlockBody::Content(realized)))
+ .pack()
+ .spanned(self.span())
+ })
}
}
diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs
index f3b2a19e..dfa3933b 100644
--- a/crates/typst-library/src/model/footnote.rs
+++ b/crates/typst-library/src/model/footnote.rs
@@ -310,11 +310,9 @@ impl Show for Packed<FootnoteEntry> {
impl ShowSet for Packed<FootnoteEntry> {
fn show_set(&self, _: StyleChain) -> Styles {
- let text_size = Em::new(0.85);
- let leading = Em::new(0.5);
let mut out = Styles::new();
- out.set(ParElem::set_leading(leading.into()));
- out.set(TextElem::set_size(TextSize(text_size.into())));
+ out.set(ParElem::set_leading(Em::new(0.5).into()));
+ out.set(TextElem::set_size(TextSize(Em::new(0.85).into())));
out
}
}
diff --git a/crates/typst-library/src/model/list.rs b/crates/typst-library/src/model/list.rs
index 1e369d54..d93ec917 100644
--- a/crates/typst-library/src/model/list.rs
+++ b/crates/typst-library/src/model/list.rs
@@ -8,7 +8,7 @@ use crate::foundations::{
};
use crate::html::{tag, HtmlElem};
use crate::layout::{BlockElem, Em, Length, VElem};
-use crate::model::ParElem;
+use crate::model::{ParElem, ParbreakElem};
use crate::text::TextElem;
/// A bullet list.
@@ -141,11 +141,18 @@ impl ListElem {
impl Show for Packed<ListElem> {
fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
+ let tight = self.tight(styles);
+
if TargetElem::target_in(styles).is_html() {
return Ok(HtmlElem::new(tag::ul)
.with_body(Some(Content::sequence(self.children.iter().map(|item| {
+ // Text in wide lists shall always turn into paragraphs.
+ let mut body = item.body.clone();
+ if !tight {
+ body += ParbreakElem::shared();
+ }
HtmlElem::new(tag::li)
- .with_body(Some(item.body.clone()))
+ .with_body(Some(body))
.pack()
.spanned(item.span())
}))))
@@ -158,7 +165,7 @@ impl Show for Packed<ListElem> {
.pack()
.spanned(self.span());
- if self.tight(styles) {
+ if tight {
let leading = ParElem::leading_in(styles);
let spacing =
VElem::new(leading.into()).with_weak(true).with_attach(true).pack();
diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs
index 0db056e4..1214f2b0 100644
--- a/crates/typst-library/src/model/outline.rs
+++ b/crates/typst-library/src/model/outline.rs
@@ -297,7 +297,6 @@ impl ShowSet for Packed<OutlineElem> {
let mut out = Styles::new();
out.set(HeadingElem::set_outlined(false));
out.set(HeadingElem::set_numbering(None));
- out.set(ParElem::set_first_line_indent(Em::new(0.0).into()));
out.set(ParElem::set_justify(false));
out.set(BlockElem::set_above(Smart::Custom(ParElem::leading_in(styles).into())));
// Makes the outline itself available to its entries. Should be
diff --git a/crates/typst-library/src/model/par.rs b/crates/typst-library/src/model/par.rs
index 8b82abdf..0bdbe4ea 100644
--- a/crates/typst-library/src/model/par.rs
+++ b/crates/typst-library/src/model/par.rs
@@ -1,22 +1,78 @@
-use std::fmt::{self, Debug, Formatter};
-
use typst_utils::singleton;
use crate::diag::{bail, SourceResult};
use crate::engine::Engine;
use crate::foundations::{
- elem, scope, Args, Cast, Construct, Content, NativeElement, Packed, Set, Smart,
- StyleVec, Unlabellable,
+ elem, scope, Args, Cast, Construct, Content, NativeElement, Packed, Smart,
+ Unlabellable,
};
use crate::introspection::{Count, CounterUpdate, Locatable};
use crate::layout::{Em, HAlignment, Length, OuterHAlignment};
use crate::model::Numbering;
-/// Arranges text, spacing and inline-level elements into a paragraph.
+/// A logical subdivison of textual content.
+///
+/// Typst automatically collects _inline-level_ elements into paragraphs.
+/// Inline-level elements include [text], [horizontal spacing]($h),
+/// [boxes]($box), and [inline equations]($math.equation).
+///
+/// To separate paragraphs, use a blank line (or an explicit [`parbreak`]).
+/// Paragraphs are also automatically interrupted by any block-level element
+/// (like [`block`], [`place`], or anything that shows itself as one of these).
+///
+/// The `par` element is primarily used in set rules to affect paragraph
+/// properties, but it can also be used to explicitly display its argument as a
+/// paragraph of its own. Then, the paragraph's body may not contain any
+/// block-level content.
+///
+/// # Boxes and blocks
+/// As explained above, usually paragraphs only contain inline-level content.
+/// However, you can integrate any kind of block-level content into a paragraph
+/// by wrapping it in a [`box`].
+///
+/// Conversely, you can separate inline-level content from a paragraph by
+/// wrapping it in a [`block`]. In this case, it will not become part of any
+/// paragraph at all. Read the following section for an explanation of why that
+/// matters and how it differs from just adding paragraph breaks around the
+/// content.
+///
+/// # What becomes a paragraph?
+/// When you add inline-level content to your document, Typst will automatically
+/// wrap it in paragraphs. However, a typical document also contains some text
+/// that is not semantically part of a paragraph, for example in a heading or
+/// caption.
+///
+/// The rules for when Typst wraps inline-level content in a paragraph are as
+/// follows:
+///
+/// - All text at the root of a document is wrapped in paragraphs.
+///
+/// - Text in a container (like a `block`) is only wrapped in a paragraph if the
+/// container holds any block-level content. If all of the contents are
+/// inline-level, no paragraph is created.
+///
+/// In the laid-out document, it's not immediately visible whether text became
+/// part of a paragraph. However, it is still important for various reasons:
+///
+/// - Certain paragraph styling like `first-line-indent` will only apply to
+/// proper paragraphs, not any text. Similarly, `par` show rules of course
+/// only trigger on paragraphs.
+///
+/// - A proper distinction between paragraphs and other text helps people who
+/// rely on assistive technologies (such as screen readers) navigate and
+/// understand the document properly. Currently, this only applies to HTML
+/// export since Typst does not yet output accessible PDFs, but support for
+/// this is planned for the near future.
+///
+/// - HTML export will generate a `<p>` tag only for paragraphs.
///
-/// Although this function is primarily used in set rules to affect paragraph
-/// properties, it can also be used to explicitly render its argument onto a
-/// paragraph of its own.
+/// When creating custom reusable components, you can and should take charge
+/// over whether Typst creates paragraphs. By wrapping text in a [`block`]
+/// instead of just adding paragraph breaks around it, you can force the absence
+/// of a paragraph. Conversely, by adding a [`parbreak`] after some content in a
+/// container, you can force it to become a paragraph even if it's just one
+/// word. This is, for example, what [non-`tight`]($list.tight) lists do to
+/// force their items to become paragraphs.
///
/// # Example
/// ```example
@@ -37,7 +93,7 @@ use crate::model::Numbering;
/// let $a$ be the smallest of the
/// three integers. Then, we ...
/// ```
-#[elem(scope, title = "Paragraph", Debug, Construct)]
+#[elem(scope, title = "Paragraph")]
pub struct ParElem {
/// The spacing between lines.
///
@@ -53,7 +109,6 @@ pub struct ParElem {
/// distribution of the top- and bottom-edge values affects the bounds of
/// the first and last line.
#[resolve]
- #[ghost]
#[default(Em::new(0.65).into())]
pub leading: Length,
@@ -68,7 +123,6 @@ pub struct ParElem {
/// takes precedence over the paragraph spacing. Headings, for instance,
/// reduce the spacing below them by default for a better look.
#[resolve]
- #[ghost]
#[default(Em::new(1.2).into())]
pub spacing: Length,
@@ -81,7 +135,6 @@ pub struct ParElem {
/// Note that the current [alignment]($align.alignment) still has an effect
/// on the placement of the last line except if it ends with a
/// [justified line break]($linebreak.justify).
- #[ghost]
#[default(false)]
pub justify: bool,
@@ -106,7 +159,6 @@ pub struct ParElem {
/// challenging to break in a visually
/// pleasing way.
/// ```
- #[ghost]
pub linebreaks: Smart<Linebreaks>,
/// The indent the first line of a paragraph should have.
@@ -118,23 +170,15 @@ pub struct ParElem {
/// space between paragraphs or by indented first lines. Consider reducing
/// the [paragraph spacing]($block.spacing) to the [`leading`]($par.leading)
/// when using this property (e.g. using `[#set par(spacing: 0.65em)]`).
- #[ghost]
pub first_line_indent: Length,
- /// The indent all but the first line of a paragraph should have.
- #[ghost]
+ /// The indent that all but the first line of a paragraph should have.
#[resolve]
pub hanging_indent: Length,
/// The contents of the paragraph.
- #[external]
#[required]
pub body: Content,
-
- /// The paragraph's children.
- #[internal]
- #[variadic]
- pub children: StyleVec,
}
#[scope]
@@ -143,28 +187,6 @@ impl ParElem {
type ParLine;
}
-impl Construct for ParElem {
- fn construct(engine: &mut Engine, args: &mut Args) -> SourceResult<Content> {
- // The paragraph constructor is special: It doesn't create a paragraph
- // element. Instead, it just ensures that the passed content lives in a
- // separate paragraph and styles it.
- let styles = Self::set(engine, args)?;
- let body = args.expect::<Content>("body")?;
- Ok(Content::sequence([
- ParbreakElem::shared().clone(),
- body.styled_with_map(styles),
- ParbreakElem::shared().clone(),
- ]))
- }
-}
-
-impl Debug for ParElem {
- fn fmt(&self, f: &mut Formatter) -> fmt::Result {
- write!(f, "Par ")?;
- self.children.fmt(f)
- }
-}
-
/// How to determine line breaks in a paragraph.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
pub enum Linebreaks {
diff --git a/crates/typst-library/src/model/quote.rs b/crates/typst-library/src/model/quote.rs
index 79e9b4e3..919ab12c 100644
--- a/crates/typst-library/src/model/quote.rs
+++ b/crates/typst-library/src/model/quote.rs
@@ -212,17 +212,24 @@ impl Show for Packed<QuoteElem> {
.pack()
.spanned(self.span()),
};
- let attribution =
- [TextElem::packed('—'), SpaceElem::shared().clone(), attribution];
+ let attribution = Content::sequence([
+ TextElem::packed('—'),
+ SpaceElem::shared().clone(),
+ attribution,
+ ]);
- if !html {
- // Use v(0.9em, weak: true) to bring the attribution closer
- // to the quote.
+ if html {
+ realized += attribution;
+ } else {
+ // Bring the attribution a bit closer to the quote.
let gap = Spacing::Rel(Em::new(0.9).into());
let v = VElem::new(gap).with_weak(true).pack();
realized += v;
+ realized += BlockElem::new()
+ .with_body(Some(BlockBody::Content(attribution)))
+ .pack()
+ .aligned(Alignment::END);
}
- realized += Content::sequence(attribution).aligned(Alignment::END);
}
if !html {
diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs
index c91eeb17..9a2ed6aa 100644
--- a/crates/typst-library/src/model/terms.rs
+++ b/crates/typst-library/src/model/terms.rs
@@ -8,7 +8,7 @@ use crate::foundations::{
};
use crate::html::{tag, HtmlElem};
use crate::layout::{Em, HElem, Length, Sides, StackChild, StackElem, VElem};
-use crate::model::{ListItemLike, ListLike, ParElem};
+use crate::model::{ListItemLike, ListLike, ParElem, ParbreakElem};
use crate::text::TextElem;
/// A list of terms and their descriptions.
@@ -116,17 +116,25 @@ impl TermsElem {
impl Show for Packed<TermsElem> {
fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
let span = self.span();
+ let tight = self.tight(styles);
+
if TargetElem::target_in(styles).is_html() {
return Ok(HtmlElem::new(tag::dl)
.with_body(Some(Content::sequence(self.children.iter().flat_map(
|item| {
+ // Text in wide term lists shall always turn into paragraphs.
+ let mut description = item.description.clone();
+ if !tight {
+ description += ParbreakElem::shared();
+ }
+
[
HtmlElem::new(tag::dt)
.with_body(Some(item.term.clone()))
.pack()
.spanned(item.term.span()),
HtmlElem::new(tag::dd)
- .with_body(Some(item.description.clone()))
+ .with_body(Some(description))
.pack()
.spanned(item.description.span()),
]
@@ -139,7 +147,7 @@ impl Show for Packed<TermsElem> {
let indent = self.indent(styles);
let hanging_indent = self.hanging_indent(styles);
let gutter = self.spacing(styles).unwrap_or_else(|| {
- if self.tight(styles) {
+ if tight {
ParElem::leading_in(styles).into()
} else {
ParElem::spacing_in(styles).into()
@@ -157,6 +165,12 @@ impl Show for Packed<TermsElem> {
seq.push(child.term.clone().strong());
seq.push((*separator).clone());
seq.push(child.description.clone());
+
+ // Text in wide term lists shall always turn into paragraphs.
+ if !tight {
+ seq.push(ParbreakElem::shared().clone());
+ }
+
children.push(StackChild::Block(Content::sequence(seq)));
}
@@ -168,7 +182,7 @@ impl Show for Packed<TermsElem> {
.spanned(span)
.padded(padding);
- if self.tight(styles) {
+ if tight {
let leading = ParElem::leading_in(styles);
let spacing = VElem::new(leading.into())
.with_weak(true)