diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs index f2c7ebd1e..34362a6c5 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>, - 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 cbc490ba1..14cf2e3b8 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, ) -> SourceResult<(String, Vec>, 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 83ca82bf2..f8a36368d 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 { 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 { 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, ) -> SourceResult { // 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`, `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 e26c9b147..0344d4331 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>, spans: SpanMapper, styles: StyleChain<'a>, - paragraph: bool, + situation: Option, ) -> SourceResult> { 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 5897c3c0c..9a64992aa 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 0bdbe4ea6..cf31b5195 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 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 9a2ed6aad..e197ff318 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>, + + /// Whether we are currently within a term list. + #[internal] + #[ghost] + pub within: bool, } #[scope] @@ -180,7 +185,8 @@ impl Show for Packed { .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 000000000..38cdea792 Binary files /dev/null and b/tests/ref/par-first-line-indent-all-enum.png 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 000000000..cf731e79f Binary files /dev/null and b/tests/ref/par-first-line-indent-all-list.png 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 000000000..4d5c8a69c Binary files /dev/null and b/tests/ref/par-first-line-indent-all-terms.png 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 000000000..f283d1a46 Binary files /dev/null and b/tests/ref/par-first-line-indent-all.png differ diff --git a/tests/suite/model/par.typ b/tests/suite/model/par.typ index fa230451d..e76690064 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)