mirror of
https://github.com/typst/typst
synced 2025-05-14 04:56:26 +08:00
Various text layout config improvements (#3787)
This commit is contained in:
parent
0bb45b335f
commit
97de0a0595
@ -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))
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
|
BIN
tests/ref/costs-hyphenation-avoid.png
Normal file
BIN
tests/ref/costs-hyphenation-avoid.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
tests/ref/costs-runt-allow.png
Normal file
BIN
tests/ref/costs-runt-allow.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 607 B |
BIN
tests/ref/costs-runt-avoid.png
Normal file
BIN
tests/ref/costs-runt-avoid.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
BIN
tests/ref/costs-widow-orphan.png
Normal file
BIN
tests/ref/costs-widow-orphan.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
@ -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%))
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user