diff --git a/crates/typst/src/layout/inline/mod.rs b/crates/typst/src/layout/inline/mod.rs index fd1e60db9..77408c7a2 100644 --- a/crates/typst/src/layout/inline/mod.rs +++ b/crates/typst/src/layout/inline/mod.rs @@ -117,6 +117,7 @@ struct Preparation<'a> { spans: SpanMapper, /// Whether to hyphenate if it's the same for all children. hyphenate: Option, + costs: crate::text::Costs, /// The text language if it's the same for all children. lang: Option, /// The paragraph's resolved horizontal alignment. @@ -630,11 +631,14 @@ fn prepare<'a>( add_cjk_latin_spacing(&mut items); } + let costs = TextElem::costs_in(styles); + Ok(Preparation { bidi, items, spans, hyphenate: shared_get(styles, children, TextElem::hyphenate_in), + costs, lang: shared_get(styles, children, TextElem::lang_in), align: AlignElem::alignment_in(styles).resolve(styles).x, justify: ParElem::justify_in(styles), @@ -876,12 +880,15 @@ fn linebreak_optimized<'a>( } // Cost parameters. - const HYPH_COST: Cost = 0.5; - const RUNT_COST: Cost = 0.5; + const DEFAULT_HYPH_COST: Cost = 0.5; + const DEFAULT_RUNT_COST: Cost = 0.5; const CONSECUTIVE_DASH_COST: Cost = 0.3; const MAX_COST: Cost = 1_000_000.0; const MIN_RATIO: f64 = -1.0; + let hyph_cost = DEFAULT_HYPH_COST * p.costs.hyphenation().get(); + let runt_cost = DEFAULT_RUNT_COST * p.costs.runt().get(); + // Dynamic programming table. let mut active = 0; let mut table = vec![Entry { @@ -965,12 +972,12 @@ fn linebreak_optimized<'a>( // Penalize runts. if k == i + 1 && is_end { - cost += RUNT_COST; + cost += runt_cost; } // Penalize hyphens. if breakpoint == Breakpoint::Hyphen { - cost += HYPH_COST; + cost += hyph_cost; } // In Knuth paper, cost = (1 + 100|r|^3 + p)^2 + a, @@ -1212,19 +1219,23 @@ fn finalize( .map(|line| commit(engine, p, line, width, region.y, shrink)) .collect::>()?; - // Prevent orphans. - if frames.len() >= 2 && !frames[1].is_empty() { - let second = frames.remove(1); - let first = &mut frames[0]; - merge(first, second, p.leading); + // Positive ratios enable prevention, while zero and negative ratios disable it. + if p.costs.orphan().get() > 0.0 { + // Prevent orphans. + if frames.len() >= 2 && !frames[1].is_empty() { + let second = frames.remove(1); + let first = &mut frames[0]; + merge(first, second, p.leading); + } } - - // Prevent widows. - let len = frames.len(); - if len >= 2 && !frames[len - 2].is_empty() { - let second = frames.pop().unwrap(); - let first = frames.last_mut().unwrap(); - merge(first, second, p.leading); + if p.costs.widow().get() > 0.0 { + // Prevent widows. + let len = frames.len(); + if len >= 2 && !frames[len - 2].is_empty() { + let second = frames.pop().unwrap(); + let first = frames.last_mut().unwrap(); + merge(first, second, p.leading); + } } Ok(Fragment::frames(frames)) diff --git a/crates/typst/src/text/mod.rs b/crates/typst/src/text/mod.rs index ef621ee56..0b8818e80 100644 --- a/crates/typst/src/text/mod.rs +++ b/crates/typst/src/text/mod.rs @@ -37,13 +37,12 @@ use ttf_parser::Rect; use crate::diag::{bail, warning, SourceResult, StrResult}; use crate::engine::Engine; -use crate::foundations::Packed; use crate::foundations::{ - cast, category, elem, Args, Array, Cast, Category, Construct, Content, Dict, Fold, - NativeElement, Never, PlainText, Repr, Resolve, Scope, Set, Smart, StyleChain, + cast, category, dict, elem, Args, Array, Cast, Category, Construct, Content, Dict, + Fold, NativeElement, Never, Packed, PlainText, Repr, Resolve, Scope, Set, Smart, + StyleChain, }; -use crate::layout::Em; -use crate::layout::{Abs, Axis, Dir, Length, Rel}; +use crate::layout::{Abs, Axis, Dir, Em, Length, Ratio, Rel}; use crate::model::ParElem; use crate::syntax::Spanned; use crate::visualize::{Color, Paint, RelativeTo, Stroke}; @@ -482,6 +481,52 @@ pub struct TextElem { #[ghost] pub hyphenate: Hyphenate, + /// The "cost" of various choices when laying out text. A higher cost means + /// the layout engine will make the choice less often. Costs are specified + /// as a ratio of the default cost, so `50%` will make text layout twice as + /// eager to make a given choice, while `200%` will make it half as eager. + /// + /// Currently, the following costs can be customized: + /// - `hyphenation`: splitting a word across multiple lines + /// - `runt`: ending a paragraph with a line with a single word + /// - `widow`: leaving a single line of paragraph on the next page + /// - `orphan`: leaving single line of paragraph on the previous page + /// + /// Hyphenation is generally avoided by placing the whole word on the next + /// line, so a higher hyphenation cost can result in awkward justification + /// spacing. + /// + /// Runts are avoided by placing more or fewer words on previous lines, so a + /// higher runt cost can result in more awkward in justification spacing. + /// + /// Text layout prevents widows and orphans by default because they are + /// generally discouraged by style guides. However, in some contexts they + /// are allowed because the prevention method, which moves a line to the + /// next page, can result in an uneven number of lines between pages. + /// The `widow` and `orphan` costs allow disabling these modifications. + /// (Currently, 0% allows widows/orphans; anything else, including the + /// default of `auto`, prevents them. More nuanced cost specification for + /// these modifications is planned for the future.) + /// + /// The default costs are an acceptable balance, but some may find that it + /// hyphenates or avoids runs too eagerly, breaking the flow of dense prose. + /// A cost of 600% (six times the normal cost) may work better for such + /// contexts. + /// + /// ```example + /// #set text(hyphenate: true, size: 11.4pt) + /// #set par(justify: true) + /// + /// #lorem(10) + /// + /// // Set hyphenation to ten times the normal cost. + /// #set text(costs: (hyphenation: 1000%)) + /// + /// #lorem(10) + /// ``` + #[fold] + pub costs: Costs, + /// Whether to apply kerning. /// /// When enabled, specific letter pairings move closer together or further @@ -1184,3 +1229,71 @@ impl Fold for WeightDelta { Self(outer.0 + self.0) } } + +/// Costs that are updated (prioritizing the later value) when folded. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +#[non_exhaustive] // We may add more costs in the future. +pub struct Costs { + pub hyphenation: Option, + pub runt: Option, + pub widow: Option, + pub orphan: Option, +} + +impl Costs { + #[inline] + #[must_use] + pub fn hyphenation(&self) -> Ratio { + self.hyphenation.unwrap_or(Ratio::one()) + } + + #[inline] + #[must_use] + pub fn runt(&self) -> Ratio { + self.runt.unwrap_or(Ratio::one()) + } + + #[inline] + #[must_use] + pub fn widow(&self) -> Ratio { + self.widow.unwrap_or(Ratio::one()) + } + + #[inline] + #[must_use] + pub fn orphan(&self) -> Ratio { + self.orphan.unwrap_or(Ratio::one()) + } +} + +impl Fold for Costs { + #[inline] + fn fold(self, outer: Self) -> Self { + Self { + hyphenation: self.hyphenation.or(outer.hyphenation), + runt: self.runt.or(outer.runt), + widow: self.widow.or(outer.widow), + orphan: self.orphan.or(outer.orphan), + } + } +} + +cast! { + Costs, + self => dict![ + "hyphenation" => self.hyphenation(), + "runt" => self.runt(), + "widow" => self.widow(), + "orphan" => self.orphan(), + ].into_value(), + mut v: Dict => { + let ret = Self { + hyphenation: v.take("hyphenation").ok().map(|v| v.cast()).transpose()?, + runt: v.take("runt").ok().map(|v| v.cast()).transpose()?, + widow: v.take("widow").ok().map(|v| v.cast()).transpose()?, + orphan: v.take("orphan").ok().map(|v| v.cast()).transpose()?, + }; + v.finish(&["hyphenation", "runt", "widow", "orphan"])?; + ret + }, +} diff --git a/tests/ref/costs-hyphenation-avoid.png b/tests/ref/costs-hyphenation-avoid.png new file mode 100644 index 000000000..8efaef63c Binary files /dev/null and b/tests/ref/costs-hyphenation-avoid.png differ diff --git a/tests/ref/costs-runt-allow.png b/tests/ref/costs-runt-allow.png new file mode 100644 index 000000000..31a348ff6 Binary files /dev/null and b/tests/ref/costs-runt-allow.png differ diff --git a/tests/ref/costs-runt-avoid.png b/tests/ref/costs-runt-avoid.png new file mode 100644 index 000000000..e45de59ef Binary files /dev/null and b/tests/ref/costs-runt-avoid.png differ diff --git a/tests/ref/costs-widow-orphan.png b/tests/ref/costs-widow-orphan.png new file mode 100644 index 000000000..30e459dee Binary files /dev/null and b/tests/ref/costs-widow-orphan.png differ diff --git a/tests/suite/layout/inline/hyphenate.typ b/tests/suite/layout/inline/hyphenate.typ index aaabe8160..bcad4d93f 100644 --- a/tests/suite/layout/inline/hyphenate.typ +++ b/tests/suite/layout/inline/hyphenate.typ @@ -49,3 +49,59 @@ It's a #emph[Tree]beard. #set page(width: 60pt) #set text(hyphenate: true) #h(6pt) networks, the rest. + +--- costs-widow-orphan --- +#set page(height: 60pt) + +#let sample = lorem(12) + +#sample +#pagebreak() +#set text(costs: (widow: 0%, orphan: 0%)) +#sample + +--- costs-runt-avoid --- +#set par(justify: true) + +#let sample = [please avoid runts in this text.] + +#sample +#pagebreak() +#set text(costs: (runt: 10000%)) +#sample + +--- costs-runt-allow --- +#set par(justify: true) +#set text(size: 6pt) + +#let sample = [a a a a a a a a a a a a a a a a a a a a a a a a a] + +#sample +#pagebreak() +#set text(costs: (runt: 0%)) +#sample + +--- costs-hyphenation-avoid --- +#set par(justify: true) + +#let sample = [we've increased the hyphenation cost.] + +#sample +#pagebreak() +#set text(costs: (hyphenation: 10000%)) +#sample + +--- costs-invalid-type --- +// Error: 18-37 expected ratio, found auto +#set text(costs: (hyphenation: auto)) + +--- costs-invalid-key --- +// Error: 18-52 unexpected key "invalid-key", valid keys are "hyphenation", "runt", "widow", and "orphan" +#set text(costs: (hyphenation: 1%, invalid-key: 3%)) + +--- costs-access --- +#set text(costs: (hyphenation: 1%, runt: 2%)) +#set text(costs: (widow: 3%)) +#context { + assert.eq(text.costs, (hyphenation: 1%, runt: 2%, widow: 3%, orphan: 100%)) +}