summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2025-01-27 14:15:20 +0100
committerGitHub <noreply@github.com>2025-01-27 13:15:20 +0000
commit85d177897468165b93056947a80086b2f84d815d (patch)
treea86ccf46ad27ef1ea2c8f5d9010a44eb0e5efde8
parent176b070c779ef8aa4515c8ff062b17ca9114fd3f (diff)
Support first-line-indent for every paragraph (#5768)
-rw-r--r--crates/typst-layout/src/flow/collect.rs14
-rw-r--r--crates/typst-layout/src/inline/collect.rs32
-rw-r--r--crates/typst-layout/src/inline/mod.rs30
-rw-r--r--crates/typst-layout/src/inline/prepare.rs8
-rw-r--r--crates/typst-layout/src/math/text.rs3
-rw-r--r--crates/typst-library/src/model/par.rs86
-rw-r--r--crates/typst-library/src/model/terms.rs8
-rw-r--r--tests/ref/par-first-line-indent-all-enum.pngbin0 -> 425 bytes
-rw-r--r--tests/ref/par-first-line-indent-all-list.pngbin0 -> 383 bytes
-rw-r--r--tests/ref/par-first-line-indent-all-terms.pngbin0 -> 755 bytes
-rw-r--r--tests/ref/par-first-line-indent-all.pngbin0 -> 1335 bytes
-rw-r--r--tests/suite/model/par.typ51
12 files changed, 196 insertions, 36 deletions
diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs
index f2c7ebd1..34362a6c 100644
--- a/crates/typst-layout/src/flow/collect.rs
+++ b/crates/typst-layout/src/flow/collect.rs
@@ -23,6 +23,7 @@ use typst_library::World;
use typst_utils::SliceExt;
use super::{layout_multi_block, layout_single_block, FlowMode};
+use crate::inline::ParSituation;
use crate::modifiers::layout_and_modify;
/// Collects all elements of the flow into prepared children. These are much
@@ -46,7 +47,7 @@ pub fn collect<'a>(
base,
expand,
output: Vec::with_capacity(children.len()),
- last_was_par: false,
+ par_situation: ParSituation::First,
}
.run(mode)
}
@@ -60,7 +61,7 @@ struct Collector<'a, 'x, 'y> {
expand: bool,
locator: SplitLocator<'a>,
output: Vec<Child<'a>>,
- last_was_par: bool,
+ par_situation: ParSituation,
}
impl<'a> Collector<'a, '_, '_> {
@@ -123,8 +124,7 @@ impl<'a> Collector<'a, '_, '_> {
styles,
self.base,
self.expand,
- false,
- false,
+ None,
)?
.into_frames();
@@ -165,7 +165,7 @@ impl<'a> Collector<'a, '_, '_> {
styles,
self.base,
self.expand,
- self.last_was_par,
+ self.par_situation,
)?
.into_frames();
@@ -175,7 +175,7 @@ impl<'a> Collector<'a, '_, '_> {
self.lines(lines, styles);
self.output.push(Child::Rel(spacing.into(), 4));
- self.last_was_par = true;
+ self.par_situation = ParSituation::Consecutive;
Ok(())
}
@@ -272,7 +272,7 @@ impl<'a> Collector<'a, '_, '_> {
};
self.output.push(spacing(elem.below(styles)));
- self.last_was_par = false;
+ self.par_situation = ParSituation::Other;
}
/// Collects a placed element into a [`PlacedChild`].
diff --git a/crates/typst-layout/src/inline/collect.rs b/crates/typst-layout/src/inline/collect.rs
index cbc490ba..14cf2e3b 100644
--- a/crates/typst-layout/src/inline/collect.rs
+++ b/crates/typst-layout/src/inline/collect.rs
@@ -5,6 +5,7 @@ use typst_library::layout::{
Abs, AlignElem, BoxElem, Dir, Fr, Frame, HElem, InlineElem, InlineItem, Sizing,
Spacing,
};
+use typst_library::model::{EnumElem, ListElem, TermsElem};
use typst_library::routines::Pair;
use typst_library::text::{
is_default_ignorable, LinebreakElem, SmartQuoteElem, SmartQuoter, SmartQuotes,
@@ -124,26 +125,33 @@ pub fn collect<'a>(
locator: &mut SplitLocator<'a>,
styles: StyleChain<'a>,
region: Size,
- consecutive: bool,
- paragraph: bool,
+ situation: Option<ParSituation>,
) -> SourceResult<(String, Vec<Segment<'a>>, SpanMapper)> {
let mut collector = Collector::new(2 + children.len());
let mut quoter = SmartQuoter::new();
let outer_dir = TextElem::dir_in(styles);
- if paragraph && consecutive {
+ if let Some(situation) = situation {
let first_line_indent = ParElem::first_line_indent_in(styles);
- if !first_line_indent.is_zero()
+ if !first_line_indent.amount.is_zero()
+ && match situation {
+ // First-line indent for the first paragraph after a list bullet
+ // just looks bad.
+ ParSituation::First => first_line_indent.all && !in_list(styles),
+ ParSituation::Consecutive => true,
+ ParSituation::Other => first_line_indent.all,
+ }
&& AlignElem::alignment_in(styles).resolve(styles).x
== outer_dir.start().into()
{
- collector.push_item(Item::Absolute(first_line_indent.resolve(styles), false));
+ collector.push_item(Item::Absolute(
+ first_line_indent.amount.resolve(styles),
+ false,
+ ));
collector.spans.push(1, Span::detached());
}
- }
- if paragraph {
let hang = ParElem::hanging_indent_in(styles);
if !hang.is_zero() {
collector.push_item(Item::Absolute(-hang, false));
@@ -257,6 +265,16 @@ pub fn collect<'a>(
Ok((collector.full, collector.segments, collector.spans))
}
+/// Whether we have a list ancestor.
+///
+/// When we support some kind of more general ancestry mechanism, this can
+/// become more elegant.
+fn in_list(styles: StyleChain) -> bool {
+ ListElem::depth_in(styles).0 > 0
+ || !EnumElem::parents_in(styles).is_empty()
+ || TermsElem::within_in(styles)
+}
+
/// Collects segments.
struct Collector<'a> {
full: String,
diff --git a/crates/typst-layout/src/inline/mod.rs b/crates/typst-layout/src/inline/mod.rs
index 83ca82bf..f8a36368 100644
--- a/crates/typst-layout/src/inline/mod.rs
+++ b/crates/typst-layout/src/inline/mod.rs
@@ -42,7 +42,7 @@ pub fn layout_par(
styles: StyleChain,
region: Size,
expand: bool,
- consecutive: bool,
+ situation: ParSituation,
) -> SourceResult<Fragment> {
layout_par_impl(
elem,
@@ -56,7 +56,7 @@ pub fn layout_par(
styles,
region,
expand,
- consecutive,
+ situation,
)
}
@@ -75,7 +75,7 @@ fn layout_par_impl(
styles: StyleChain,
region: Size,
expand: bool,
- consecutive: bool,
+ situation: ParSituation,
) -> SourceResult<Fragment> {
let link = LocatorLink::new(locator);
let mut locator = Locator::link(&link).split();
@@ -105,8 +105,7 @@ fn layout_par_impl(
styles,
region,
expand,
- true,
- consecutive,
+ Some(situation),
)
}
@@ -119,16 +118,15 @@ pub fn layout_inline<'a>(
styles: StyleChain<'a>,
region: Size,
expand: bool,
- paragraph: bool,
- consecutive: bool,
+ par: Option<ParSituation>,
) -> SourceResult<Fragment> {
// Collect all text into one string for BiDi analysis.
let (text, segments, spans) =
- collect(children, engine, locator, styles, region, consecutive, paragraph)?;
+ collect(children, engine, locator, styles, region, par)?;
// Perform BiDi analysis and performs some preparation steps before we
// proceed to line breaking.
- let p = prepare(engine, children, &text, segments, spans, styles, paragraph)?;
+ let p = prepare(engine, children, &text, segments, spans, styles, par)?;
// Break the text into lines.
let lines = linebreak(engine, &p, region.x - p.hang);
@@ -136,3 +134,17 @@ pub fn layout_inline<'a>(
// Turn the selected lines into frames.
finalize(engine, &p, &lines, styles, region, expand, locator)
}
+
+/// Distinguishes between a few different kinds of paragraphs.
+///
+/// In the form `Option<ParSituation>`, `None` implies that we are creating an
+/// inline layout that isn't a semantic paragraph.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum ParSituation {
+ /// The paragraph is the first thing in the flow.
+ First,
+ /// The paragraph follows another paragraph.
+ Consecutive,
+ /// Any other kind of paragraph.
+ Other,
+}
diff --git a/crates/typst-layout/src/inline/prepare.rs b/crates/typst-layout/src/inline/prepare.rs
index e26c9b14..0344d433 100644
--- a/crates/typst-layout/src/inline/prepare.rs
+++ b/crates/typst-layout/src/inline/prepare.rs
@@ -85,7 +85,7 @@ pub fn prepare<'a>(
segments: Vec<Segment<'a>>,
spans: SpanMapper,
styles: StyleChain<'a>,
- paragraph: bool,
+ situation: Option<ParSituation>,
) -> SourceResult<Preparation<'a>> {
let dir = TextElem::dir_in(styles);
let default_level = match dir {
@@ -130,7 +130,11 @@ pub fn prepare<'a>(
}
// Only apply hanging indent to real paragraphs.
- let hang = if paragraph { ParElem::hanging_indent_in(styles) } else { Abs::zero() };
+ let hang = if situation.is_some() {
+ ParElem::hanging_indent_in(styles)
+ } else {
+ Abs::zero()
+ };
Ok(Preparation {
text,
diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs
index 5897c3c0..9a64992a 100644
--- a/crates/typst-layout/src/math/text.rs
+++ b/crates/typst-layout/src/math/text.rs
@@ -107,8 +107,7 @@ fn layout_inline_text(
styles,
Size::splat(Abs::inf()),
false,
- false,
- false,
+ None,
)?
.into_frame();
diff --git a/crates/typst-library/src/model/par.rs b/crates/typst-library/src/model/par.rs
index 0bdbe4ea..cf31b519 100644
--- a/crates/typst-library/src/model/par.rs
+++ b/crates/typst-library/src/model/par.rs
@@ -3,8 +3,8 @@ use typst_utils::singleton;
use crate::diag::{bail, SourceResult};
use crate::engine::Engine;
use crate::foundations::{
- elem, scope, Args, Cast, Construct, Content, NativeElement, Packed, Smart,
- Unlabellable,
+ cast, dict, elem, scope, Args, Cast, Construct, Content, Dict, NativeElement, Packed,
+ Smart, Unlabellable, Value,
};
use crate::introspection::{Count, CounterUpdate, Locatable};
use crate::layout::{Em, HAlignment, Length, OuterHAlignment};
@@ -163,16 +163,56 @@ pub struct ParElem {
/// The indent the first line of a paragraph should have.
///
- /// Only the first line of a consecutive paragraph will be indented (not
- /// the first one in a block or on the page).
+ /// By default, only the first line of a consecutive paragraph will be
+ /// indented (not the first one in the document or container, and not
+ /// paragraphs immediately following other block-level elements).
+ ///
+ /// If you want to indent all paragraphs instead, you can pass a dictionary
+ /// containing the `amount` of indent as a length and the pair
+ /// `{all: true}`. When `all` is omitted from the dictionary, it defaults to
+ /// `{false}`.
///
/// By typographic convention, paragraph breaks are indicated either by some
- /// 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)]`).
- pub first_line_indent: Length,
+ /// space between paragraphs or by indented first lines. Consider
+ /// - reducing the [paragraph `spacing`]($par.spacing) to the
+ /// [`leading`]($par.leading) using `{set par(spacing: 0.65em)}`
+ /// - increasing the [block `spacing`]($block.spacing) (which inherits the
+ /// paragraph spacing by default) to the original paragraph spacing using
+ /// `{set block(spacing: 1.2em)}`
+ ///
+ /// ```example
+ /// #set block(spacing: 1.2em)
+ /// #set par(
+ /// first-line-indent: 1.5em,
+ /// spacing: 0.65em,
+ /// )
+ ///
+ /// The first paragraph is not affected
+ /// by the indent.
+ ///
+ /// But the second paragraph is.
+ ///
+ /// #line(length: 100%)
+ ///
+ /// #set par(first-line-indent: (
+ /// amount: 1.5em,
+ /// all: true,
+ /// ))
+ ///
+ /// Now all paragraphs are affected
+ /// by the first line indent.
+ ///
+ /// Even the first one.
+ /// ```
+ pub first_line_indent: FirstLineIndent,
/// The indent that all but the first line of a paragraph should have.
+ ///
+ /// ```example
+ /// #set par(hanging-indent: 1em)
+ ///
+ /// #lorem(15)
+ /// ```
#[resolve]
pub hanging_indent: Length,
@@ -199,6 +239,36 @@ pub enum Linebreaks {
Optimized,
}
+/// Configuration for first line indent.
+#[derive(Debug, Default, Copy, Clone, PartialEq, Hash)]
+pub struct FirstLineIndent {
+ /// The amount of indent.
+ pub amount: Length,
+ /// Whether to indent all paragraphs, not just consecutive ones.
+ pub all: bool,
+}
+
+cast! {
+ FirstLineIndent,
+ self => Value::Dict(self.into()),
+ amount: Length => Self { amount, all: false },
+ mut dict: Dict => {
+ let amount = dict.take("amount")?.cast()?;
+ let all = dict.take("all").ok().map(|v| v.cast()).transpose()?.unwrap_or(false);
+ dict.finish(&["amount", "all"])?;
+ Self { amount, all }
+ },
+}
+
+impl From<FirstLineIndent> for Dict {
+ fn from(indent: FirstLineIndent) -> Self {
+ dict! {
+ "amount" => indent.amount,
+ "all" => indent.all,
+ }
+ }
+}
+
/// A paragraph break.
///
/// This starts a new paragraph. Especially useful when used within code like
diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs
index 9a2ed6aa..e197ff31 100644
--- a/crates/typst-library/src/model/terms.rs
+++ b/crates/typst-library/src/model/terms.rs
@@ -105,6 +105,11 @@ pub struct TermsElem {
/// ```
#[variadic]
pub children: Vec<Packed<TermItem>>,
+
+ /// Whether we are currently within a term list.
+ #[internal]
+ #[ghost]
+ pub within: bool,
}
#[scope]
@@ -180,7 +185,8 @@ impl Show for Packed<TermsElem> {
.with_spacing(Some(gutter.into()))
.pack()
.spanned(span)
- .padded(padding);
+ .padded(padding)
+ .styled(TermsElem::set_within(true));
if tight {
let leading = ParElem::leading_in(styles);
diff --git a/tests/ref/par-first-line-indent-all-enum.png b/tests/ref/par-first-line-indent-all-enum.png
new file mode 100644
index 00000000..38cdea79
--- /dev/null
+++ b/tests/ref/par-first-line-indent-all-enum.png
Binary files differ
diff --git a/tests/ref/par-first-line-indent-all-list.png b/tests/ref/par-first-line-indent-all-list.png
new file mode 100644
index 00000000..cf731e79
--- /dev/null
+++ b/tests/ref/par-first-line-indent-all-list.png
Binary files differ
diff --git a/tests/ref/par-first-line-indent-all-terms.png b/tests/ref/par-first-line-indent-all-terms.png
new file mode 100644
index 00000000..4d5c8a69
--- /dev/null
+++ b/tests/ref/par-first-line-indent-all-terms.png
Binary files differ
diff --git a/tests/ref/par-first-line-indent-all.png b/tests/ref/par-first-line-indent-all.png
new file mode 100644
index 00000000..f283d1a4
--- /dev/null
+++ b/tests/ref/par-first-line-indent-all.png
Binary files differ
diff --git a/tests/suite/model/par.typ b/tests/suite/model/par.typ
index fa230451..e7669006 100644
--- a/tests/suite/model/par.typ
+++ b/tests/suite/model/par.typ
@@ -156,6 +156,57 @@ starts a paragraph, also with indent.
ثم يصبح النص رطبًا وقابل للطرق ويبدو المستند رائعًا.
+--- par-first-line-indent-all ---
+#set par(
+ first-line-indent: (amount: 12pt, all: true),
+ spacing: 5pt,
+ leading: 5pt,
+)
+#set block(spacing: 1.2em)
+#show heading: set text(size: 10pt)
+
+= Heading
+All paragraphs are indented.
+
+Even the first.
+
+--- par-first-line-indent-all-list ---
+#show list.where(tight: false): set list(spacing: 1.2em)
+#set par(
+ first-line-indent: (amount: 12pt, all: true),
+ spacing: 5pt,
+ leading: 5pt,
+)
+
+- A #parbreak() B #line(length: 100%) C
+
+- D
+
+--- par-first-line-indent-all-enum ---
+#show enum.where(tight: false): set enum(spacing: 1.2em)
+#set par(
+ first-line-indent: (amount: 12pt, all: true),
+ spacing: 5pt,
+ leading: 5pt,
+)
+
++ A #parbreak() B #line(length: 100%) C
+
++ D
+
+--- par-first-line-indent-all-terms ---
+#show terms.where(tight: false): set terms(spacing: 1.2em)
+#set terms(hanging-indent: 10pt)
+#set par(
+ first-line-indent: (amount: 12pt, all: true),
+ spacing: 5pt,
+ leading: 5pt,
+)
+
+/ Term A: B \ C #parbreak() D #line(length: 100%) E
+
+/ Term F: G
+
--- par-spacing-and-first-line-indent ---
// This is madness.
#set par(first-line-indent: 12pt)