From 3ef0991fbb688c22c1d6d0a78c52a5bae3f56f7d Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 19 Jul 2024 13:47:51 +0200 Subject: [PATCH] Tune hyphenation (#4584) --- crates/typst/src/layout/inline/line.rs | 2 +- crates/typst/src/layout/inline/linebreak.rs | 135 +++++++++++--------- crates/typst/src/text/mod.rs | 5 - tests/ref/justify-avoid-runts.png | Bin 1879 -> 1885 bytes tests/ref/justify-chinese.png | Bin 6678 -> 6583 bytes 5 files changed, 74 insertions(+), 68 deletions(-) diff --git a/crates/typst/src/layout/inline/line.rs b/crates/typst/src/layout/inline/line.rs index 12162ab16..bf1662ce3 100644 --- a/crates/typst/src/layout/inline/line.rs +++ b/crates/typst/src/layout/inline/line.rs @@ -133,7 +133,7 @@ pub fn line<'a>( || (p.justify && breakpoint != Breakpoint::Mandatory); // Process dashes. - let dash = if breakpoint == Breakpoint::Hyphen || full.ends_with(SHY) { + let dash = if breakpoint.is_hyphen() || full.ends_with(SHY) { Some(Dash::Soft) } else if full.ends_with(HYPHEN) { Some(Dash::Hard) diff --git a/crates/typst/src/layout/inline/linebreak.rs b/crates/typst/src/layout/inline/linebreak.rs index 1f30bb732..defb5f81c 100644 --- a/crates/typst/src/layout/inline/linebreak.rs +++ b/crates/typst/src/layout/inline/linebreak.rs @@ -1,5 +1,6 @@ use std::ops::{Add, Sub}; +use az::SaturatingAs; use icu_properties::maps::{CodePointMapData, CodePointMapDataBorrowed}; use icu_properties::sets::CodePointSetData; use icu_properties::LineBreak; @@ -21,10 +22,15 @@ use crate::text::{Lang, TextElem}; type Cost = f64; // Cost parameters. -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; +// +// We choose higher costs than the Knuth-Plass paper (which would be 50) because +// it hyphenates way to eagerly in Typst otherwise. Could be related to the +// ratios coming out differently since Typst doesn't have the concept of glue, +// so things work a bit differently. +const DEFAULT_HYPH_COST: Cost = 135.0; +const DEFAULT_RUNT_COST: Cost = 100.0; + +// Other parameters. const MIN_RATIO: f64 = -1.0; const MIN_APPROX_RATIO: f64 = -0.5; const BOUND_EPS: f64 = 1e-3; @@ -65,8 +71,9 @@ pub enum Breakpoint { Normal, /// A mandatory breakpoint (after '\n' or at the end of the text). Mandatory, - /// An opportunity for hyphenating. - Hyphen, + /// An opportunity for hyphenating and how many chars are before/after it + /// in the word. + Hyphen(u8, u8), } impl Breakpoint { @@ -95,9 +102,14 @@ impl Breakpoint { } // Trim nothing further. - Self::Hyphen => line, + Self::Hyphen(..) => line, } } + + /// Whether this is a hyphen breakpoint. + pub fn is_hyphen(self) -> bool { + matches!(self, Self::Hyphen(..)) + } } /// Breaks the paragraph into lines. @@ -254,7 +266,6 @@ fn linebreak_optimized_bounded<'a>( width, &pred.line, &attempt, - end, breakpoint, unbreakable, ); @@ -374,8 +385,6 @@ fn linebreak_optimized_approximate( let mut prev_end = 0; breakpoints(p, |end, breakpoint| { - let at_end = end == p.text.len(); - // Find the optimal predecessor. let mut best: Option = None; for (pred_index, pred) in table.iter().enumerate().skip(active) { @@ -384,13 +393,12 @@ fn linebreak_optimized_approximate( // Whether the line is justified. This is not 100% accurate w.r.t // to line()'s behaviour, but good enough. - let justify = p.justify && !at_end && breakpoint != Breakpoint::Mandatory; + let justify = p.justify && breakpoint != Breakpoint::Mandatory; // We don't really know whether the line naturally ends with a dash // here, so we can miss that case, but it's ok, since all of this // just an estimate. - let consecutive_dash = - pred.breakpoint == Breakpoint::Hyphen && breakpoint == Breakpoint::Hyphen; + let consecutive_dash = pred.breakpoint.is_hyphen() && breakpoint.is_hyphen(); // Estimate how much the line's spaces would need to be stretched to // make it the desired width. We trim at the end to not take into @@ -401,7 +409,7 @@ fn linebreak_optimized_approximate( p, width, estimates.widths.estimate(start..trimmed_end) - + if breakpoint == Breakpoint::Hyphen { + + if breakpoint.is_hyphen() { metrics.approx_hyphen_width } else { Abs::zero() @@ -416,7 +424,6 @@ fn linebreak_optimized_approximate( metrics, breakpoint, line_ratio, - at_end, justify, unbreakable, consecutive_dash, @@ -474,17 +481,8 @@ fn linebreak_optimized_approximate( let Entry { end, breakpoint, unbreakable, .. } = table[idx]; let attempt = line(engine, p, start..end, breakpoint, Some(&pred)); - - let (_, line_cost) = ratio_and_cost( - p, - metrics, - width, - &pred, - &attempt, - end, - breakpoint, - unbreakable, - ); + let (_, line_cost) = + ratio_and_cost(p, metrics, width, &pred, &attempt, breakpoint, unbreakable); pred = attempt; start = end; @@ -502,7 +500,6 @@ fn ratio_and_cost( available_width: Abs, pred: &Line, attempt: &Line, - end: usize, breakpoint: Breakpoint, unbreakable: bool, ) -> (f64, Cost) { @@ -519,7 +516,6 @@ fn ratio_and_cost( metrics, breakpoint, ratio, - end == p.text.len(), attempt.justify, unbreakable, pred.dash.is_some() && attempt.dash.is_some(), @@ -569,57 +565,64 @@ fn raw_ratio( } /// Compute the cost of a line given raw metrics. -#[allow(clippy::too_many_arguments)] +/// +/// This mostly follows the formula in the Knuth-Plass paper, but there are some +/// adjustments. fn raw_cost( metrics: &CostMetrics, breakpoint: Breakpoint, ratio: f64, - at_end: bool, justify: bool, unbreakable: bool, consecutive_dash: bool, approx: bool, ) -> Cost { - // Determine the cost of the line. - let mut cost = if ratio < metrics.min_ratio(approx) { + // Determine the stretch/shrink cost of the line. + let badness = if ratio < metrics.min_ratio(approx) { // Overfull line always has maximum cost. - MAX_COST - } else if breakpoint == Breakpoint::Mandatory || at_end { - // - If ratio < 0, we always need to shrink the line (even the last one). - // - If ratio > 0, we need to stretch the line only when it is justified - // (last line is not justified by default even if `p.justify` is true). - if ratio < 0.0 || (ratio > 0.0 && justify) { - ratio.powi(3).abs() - } else { - 0.0 - } + 1_000_000.0 + } else if justify || ratio < 0.0 { + // If the line shall be justified or needs shrinking, it has normal + // badness with cost 100|ratio|^3. We limit the ratio to 10 as to not + // get to close to our maximum cost. + 100.0 * ratio.abs().min(10.0).powi(3) } else { - // Normal line with cost of |ratio^3|. - ratio.powi(3).abs() + // If the line shouldn't be justified and doesn't need shrink, we don't + // pay any cost. + 0.0 }; - // Penalize runts (lone words in the last line). - if unbreakable && at_end { - cost += metrics.runt_cost; + // Compute penalties. + let mut penalty = 0.0; + + // Penalize runts (lone words before a mandatory break / at the end). + if unbreakable && breakpoint == Breakpoint::Mandatory { + penalty += metrics.runt_cost; } // Penalize hyphenation. - if breakpoint == Breakpoint::Hyphen { - cost += metrics.hyph_cost; + if let Breakpoint::Hyphen(l, r) = breakpoint { + // We penalize hyphenations close to the edges of the word (< LIMIT + // chars) extra. For each step of distance from the limit, we add 15% + // to the cost. + const LIMIT: u8 = 5; + let steps = LIMIT.saturating_sub(l) + LIMIT.saturating_sub(r); + let extra = 0.15 * steps as f64; + penalty += (1.0 + extra) * metrics.hyph_cost; } - // In the Knuth paper, cost = (1 + 100|r|^3 + p)^2 + a, - // where r is the ratio, p=50 is the penalty, and a=3000 is - // consecutive the penalty. We divide the whole formula by 10, - // resulting (0.01 + |r|^3 + p)^2 + a, where p=0.5 and a=0.3 - let mut cost = (0.01 + cost).powi(2); - - // Penalize two consecutive dashes (not necessarily hyphens) extra. + // Penalize two consecutive dashes extra (not necessarily hyphens). + // Knuth-Plass does this separately after the squaring, with a higher cost, + // but I couldn't find any explanation as to why. if consecutive_dash { - cost += CONSECUTIVE_DASH_COST; + penalty += metrics.hyph_cost; } - cost + // From the Knuth-Plass Paper: $ (1 + beta_j + pi_j)^2 $. + // + // We add one to minimize the number of lines when everything else is more + // or less equal. + (1.0 + badness + penalty).powi(2) } /// Calls `f` for all possible points in the text where lines can broken. @@ -711,10 +714,13 @@ fn hyphenations( mut f: impl FnMut(usize, Breakpoint), ) { let Some(lang) = lang_at(p, offset) else { return }; + let count = word.chars().count(); let end = offset + word.len(); + let mut chars = 0; for syllable in hypher::hyphenate(word, lang) { offset += syllable.len(); + chars += syllable.chars().count(); // Don't hyphenate after the final syllable. if offset == end { @@ -735,8 +741,12 @@ fn hyphenations( continue; } + // Determine the number of codepoints before and after the hyphenation. + let l = chars.saturating_as::(); + let r = (count - chars).saturating_as::(); + // Call `f` for the word-internal hyphenation opportunity. - f(offset, Breakpoint::Hyphen); + f(offset, Breakpoint::Hyphen(l, r)); } } @@ -825,9 +835,9 @@ fn lang_at(p: &Preparation, offset: usize) -> Option { struct CostMetrics { min_ratio: f64, min_approx_ratio: f64, + approx_hyphen_width: Abs, hyph_cost: Cost, runt_cost: Cost, - approx_hyphen_width: Abs, } impl CostMetrics { @@ -837,10 +847,11 @@ impl CostMetrics { // When justifying, we may stretch spaces below their natural width. min_ratio: if p.justify { MIN_RATIO } else { 0.0 }, min_approx_ratio: if p.justify { MIN_APPROX_RATIO } else { 0.0 }, - hyph_cost: DEFAULT_HYPH_COST * p.costs.hyphenation().get(), - runt_cost: DEFAULT_RUNT_COST * p.costs.runt().get(), // Approximate hyphen width for estimates. approx_hyphen_width: Em::new(0.33).at(p.size), + // Costs. + hyph_cost: DEFAULT_HYPH_COST * p.costs.hyphenation().get(), + runt_cost: DEFAULT_RUNT_COST * p.costs.runt().get(), } } diff --git a/crates/typst/src/text/mod.rs b/crates/typst/src/text/mod.rs index d42e4df8b..76ea26c10 100644 --- a/crates/typst/src/text/mod.rs +++ b/crates/typst/src/text/mod.rs @@ -512,11 +512,6 @@ pub struct TextElem { /// 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) diff --git a/tests/ref/justify-avoid-runts.png b/tests/ref/justify-avoid-runts.png index 70513939cd3945839038297afe1b4f4ae78eb5b7..a0c84eec515b2b967baeebea86b06fbbe4ae2038 100644 GIT binary patch delta 1870 zcmV-U2eJ6q4&4rrB!47HL_t(|+U=L?Q`1)-#`|yV?9Oam*WGpMxJ)l<>)N_log%aj z^@b~5cXh=T)FKzr3I#;DnSk6P5Rw>@2#6(s2%1UJd-!@8qXVExmWI${|4oq27k`A(MnEeyJ6&{Z9j0r z!P@!BtAjY#OiOw49SxjR9?l7$zBNC2-)?ZuYF-%g9VawIa=-ry`HU{Ri^F2O|YcNt`eH7&kFLm_$o%FDh+_SRQim`{c}?1hr#1R9S) z<0cH;N@Vgwh>`==b@=`XFs{c20Pw-B&j`DEC#t#fu^hF(SbOae;N?m&Rq9r_gxXwx zC9Hi4fIP}Qm16CI4}sKdAsouh-Y3?sOa>lp6hhbeQhy(@cJup$zsvw!d0c&;ACm{+ zt6Ri3A36qrdxu;rS&8rAP81WhPjWOK-~sguN&Ck-uz!C566K{VCMHj z79()ww0#8tybHaAuvLW~uKeMd`sf~DBuR5!_!ug40<*E@k{SRR1sW$|>O|60f?Tdk zbpZMtg?|a`V{{{cJ85;to&jBHS_2Ee4^yx@<0?-z5l8bz^63c#MqoKaTD6B z^9A{Gb@C(;SCn}S&H9Ui{G);_J6C@9UiT3}esA_oD{;NGqmfYctnQp39~)jyaD0e{ zPGZq)UKIg-E5BQwO>ZEq23|L>VbE)?Rya&GaBa_dCqbF5ik=`I zE0Eg>(1fTN(&RY_4!{wrDh4~!!<7uj)hXG8!AQ+z?+fRZFFN`7b53aQ=RTS)PWa*_ zZ=IgaOYYt$<)jtsIbq)t&J0>V*Kw}Og61X1#Vk%Z{qejfC!_2a4GZ+DYiv@lD1WR> z9-7%mKvzK_L!Gjp$=_4U?ToT~6XA#Q20>n)Zer9a4FHC#)h;GKA!#9?MWF`Zs!^Dj zd{d#$#g*5bK?0%3tSV>nc-Mv&U_1bFBhalk|C>qQbxUH2fukK2gcJn0n0%<)hft)L zJ42AnrQBw6neCk)fv3A5u>gjq&VOho-;AhSuKZAq0JG|3qF8(41)%3>1Z`WhW4kXt;_kS1VDVMo& z%f6WFg!565m$6$_&CsBoVDir{T<6QbJ&ACFY$U&m$s=d~@qlyz zb7zNv$6Dk6n0%!*E}0nIf477X3x7fW_}=@3(kxpH;j}HWg~`)@@_!iuOxxWjEx_&T z_hXrSgZ+S#D?f5ofJOJ!DbKK|fE%8FBDJM!Jj3!iaj6(~YSx&<+LsV^2sL0_6ISRM zmO4O_AcQWx;#1GCgmUFcUt@|8G*atXwcTC7gSX+D1#|`Q$aQCle;i_0pKnK?xM~yY z31Ko8!f!@d;29PZP=D_Sn*)$mnLVo})o|q#ficyDl=RHqX5gFf;u5fGgJpA6+kuje z?ZD)xYN-Q2-A3sEFtbe;Tm<0Yxr~0o@aDLigs8mJA=5z7afL9^q5IjDs-xOXJwV@1 zO_ukC^U4>S-1!>i5ize(ej{$70x_YETxfaAQRiMpSVw~=-XFPvBIm3!r0`L8%X15QX#v~Nxz>;M1&07*qo IM6N<$g1>~Bt^fc4 delta 1864 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