mirror of
https://github.com/typst/typst
synced 2025-05-13 20:46:23 +08:00
Support first-line-indent for every paragraph (#5768)
This commit is contained in:
parent
176b070c77
commit
85d1778974
@ -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`].
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -107,8 +107,7 @@ fn layout_inline_text(
|
||||
styles,
|
||||
Size::splat(Abs::inf()),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
)?
|
||||
.into_frame();
|
||||
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
BIN
tests/ref/par-first-line-indent-all-enum.png
Normal file
BIN
tests/ref/par-first-line-indent-all-enum.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 425 B |
BIN
tests/ref/par-first-line-indent-all-list.png
Normal file
BIN
tests/ref/par-first-line-indent-all-list.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 383 B |
BIN
tests/ref/par-first-line-indent-all-terms.png
Normal file
BIN
tests/ref/par-first-line-indent-all-terms.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 755 B |
BIN
tests/ref/par-first-line-indent-all.png
Normal file
BIN
tests/ref/par-first-line-indent-all.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user