Various text layout config improvements (#3787)

This commit is contained in:
Matt Fellenz 2024-04-30 05:18:19 -07:00 committed by GitHub
parent 0bb45b335f
commit 97de0a0595
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 201 additions and 21 deletions

View File

@ -117,6 +117,7 @@ struct Preparation<'a> {
spans: SpanMapper,
/// Whether to hyphenate if it's the same for all children.
hyphenate: Option<bool>,
costs: crate::text::Costs,
/// The text language if it's the same for all children.
lang: Option<Lang>,
/// 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::<SourceResult<_>>()?;
// 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))

View File

@ -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<Ratio>,
pub runt: Option<Ratio>,
pub widow: Option<Ratio>,
pub orphan: Option<Ratio>,
}
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
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

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