Support first-line-indent for every paragraph (#5768)

This commit is contained in:
Laurenz 2025-01-27 14:15:20 +01:00 committed by GitHub
parent 176b070c77
commit 85d1778974
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 196 additions and 36 deletions

View File

@ -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`].

View File

@ -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,

View File

@ -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,
}

View File

@ -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,

View File

@ -107,8 +107,7 @@ fn layout_inline_text(
styles,
Size::splat(Abs::inf()),
false,
false,
false,
None,
)?
.into_frame();

View File

@ -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

View File

@ -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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -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)