mirror of
https://github.com/typst/typst
synced 2025-05-14 17:15:28 +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,
|
spans: SpanMapper,
|
||||||
/// Whether to hyphenate if it's the same for all children.
|
/// Whether to hyphenate if it's the same for all children.
|
||||||
hyphenate: Option<bool>,
|
hyphenate: Option<bool>,
|
||||||
|
costs: crate::text::Costs,
|
||||||
/// The text language if it's the same for all children.
|
/// The text language if it's the same for all children.
|
||||||
lang: Option<Lang>,
|
lang: Option<Lang>,
|
||||||
/// The paragraph's resolved horizontal alignment.
|
/// The paragraph's resolved horizontal alignment.
|
||||||
@ -630,11 +631,14 @@ fn prepare<'a>(
|
|||||||
add_cjk_latin_spacing(&mut items);
|
add_cjk_latin_spacing(&mut items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let costs = TextElem::costs_in(styles);
|
||||||
|
|
||||||
Ok(Preparation {
|
Ok(Preparation {
|
||||||
bidi,
|
bidi,
|
||||||
items,
|
items,
|
||||||
spans,
|
spans,
|
||||||
hyphenate: shared_get(styles, children, TextElem::hyphenate_in),
|
hyphenate: shared_get(styles, children, TextElem::hyphenate_in),
|
||||||
|
costs,
|
||||||
lang: shared_get(styles, children, TextElem::lang_in),
|
lang: shared_get(styles, children, TextElem::lang_in),
|
||||||
align: AlignElem::alignment_in(styles).resolve(styles).x,
|
align: AlignElem::alignment_in(styles).resolve(styles).x,
|
||||||
justify: ParElem::justify_in(styles),
|
justify: ParElem::justify_in(styles),
|
||||||
@ -876,12 +880,15 @@ fn linebreak_optimized<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cost parameters.
|
// Cost parameters.
|
||||||
const HYPH_COST: Cost = 0.5;
|
const DEFAULT_HYPH_COST: Cost = 0.5;
|
||||||
const RUNT_COST: Cost = 0.5;
|
const DEFAULT_RUNT_COST: Cost = 0.5;
|
||||||
const CONSECUTIVE_DASH_COST: Cost = 0.3;
|
const CONSECUTIVE_DASH_COST: Cost = 0.3;
|
||||||
const MAX_COST: Cost = 1_000_000.0;
|
const MAX_COST: Cost = 1_000_000.0;
|
||||||
const MIN_RATIO: f64 = -1.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.
|
// Dynamic programming table.
|
||||||
let mut active = 0;
|
let mut active = 0;
|
||||||
let mut table = vec![Entry {
|
let mut table = vec![Entry {
|
||||||
@ -965,12 +972,12 @@ fn linebreak_optimized<'a>(
|
|||||||
|
|
||||||
// Penalize runts.
|
// Penalize runts.
|
||||||
if k == i + 1 && is_end {
|
if k == i + 1 && is_end {
|
||||||
cost += RUNT_COST;
|
cost += runt_cost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Penalize hyphens.
|
// Penalize hyphens.
|
||||||
if breakpoint == Breakpoint::Hyphen {
|
if breakpoint == Breakpoint::Hyphen {
|
||||||
cost += HYPH_COST;
|
cost += hyph_cost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In Knuth paper, cost = (1 + 100|r|^3 + p)^2 + a,
|
// 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))
|
.map(|line| commit(engine, p, line, width, region.y, shrink))
|
||||||
.collect::<SourceResult<_>>()?;
|
.collect::<SourceResult<_>>()?;
|
||||||
|
|
||||||
// Prevent orphans.
|
// Positive ratios enable prevention, while zero and negative ratios disable it.
|
||||||
if frames.len() >= 2 && !frames[1].is_empty() {
|
if p.costs.orphan().get() > 0.0 {
|
||||||
let second = frames.remove(1);
|
// Prevent orphans.
|
||||||
let first = &mut frames[0];
|
if frames.len() >= 2 && !frames[1].is_empty() {
|
||||||
merge(first, second, p.leading);
|
let second = frames.remove(1);
|
||||||
|
let first = &mut frames[0];
|
||||||
|
merge(first, second, p.leading);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if p.costs.widow().get() > 0.0 {
|
||||||
// Prevent widows.
|
// Prevent widows.
|
||||||
let len = frames.len();
|
let len = frames.len();
|
||||||
if len >= 2 && !frames[len - 2].is_empty() {
|
if len >= 2 && !frames[len - 2].is_empty() {
|
||||||
let second = frames.pop().unwrap();
|
let second = frames.pop().unwrap();
|
||||||
let first = frames.last_mut().unwrap();
|
let first = frames.last_mut().unwrap();
|
||||||
merge(first, second, p.leading);
|
merge(first, second, p.leading);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Fragment::frames(frames))
|
Ok(Fragment::frames(frames))
|
||||||
|
@ -37,13 +37,12 @@ use ttf_parser::Rect;
|
|||||||
|
|
||||||
use crate::diag::{bail, warning, SourceResult, StrResult};
|
use crate::diag::{bail, warning, SourceResult, StrResult};
|
||||||
use crate::engine::Engine;
|
use crate::engine::Engine;
|
||||||
use crate::foundations::Packed;
|
|
||||||
use crate::foundations::{
|
use crate::foundations::{
|
||||||
cast, category, elem, Args, Array, Cast, Category, Construct, Content, Dict, Fold,
|
cast, category, dict, elem, Args, Array, Cast, Category, Construct, Content, Dict,
|
||||||
NativeElement, Never, PlainText, Repr, Resolve, Scope, Set, Smart, StyleChain,
|
Fold, NativeElement, Never, Packed, PlainText, Repr, Resolve, Scope, Set, Smart,
|
||||||
|
StyleChain,
|
||||||
};
|
};
|
||||||
use crate::layout::Em;
|
use crate::layout::{Abs, Axis, Dir, Em, Length, Ratio, Rel};
|
||||||
use crate::layout::{Abs, Axis, Dir, Length, Rel};
|
|
||||||
use crate::model::ParElem;
|
use crate::model::ParElem;
|
||||||
use crate::syntax::Spanned;
|
use crate::syntax::Spanned;
|
||||||
use crate::visualize::{Color, Paint, RelativeTo, Stroke};
|
use crate::visualize::{Color, Paint, RelativeTo, Stroke};
|
||||||
@ -482,6 +481,52 @@ pub struct TextElem {
|
|||||||
#[ghost]
|
#[ghost]
|
||||||
pub hyphenate: Hyphenate,
|
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.
|
/// Whether to apply kerning.
|
||||||
///
|
///
|
||||||
/// When enabled, specific letter pairings move closer together or further
|
/// When enabled, specific letter pairings move closer together or further
|
||||||
@ -1184,3 +1229,71 @@ impl Fold for WeightDelta {
|
|||||||
Self(outer.0 + self.0)
|
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 page(width: 60pt)
|
||||||
#set text(hyphenate: true)
|
#set text(hyphenate: true)
|
||||||
#h(6pt) networks, the rest.
|
#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