Merge pull request #6708 from dotCooCoo/feat/mojikumi-line-end-fit

Type: Mojikumi spacing at line ends counts when lines break
This commit is contained in:
BFlat
2026-11-12 09:32:21 +07:00
committed by GitHub
7 changed files with 528 additions and 55 deletions
+13 -2
View File
@@ -116,7 +116,9 @@ fn adjustment_ratio(natural: f64, width: f64, stretch: f64, shrink: f64, last: b
/// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`;
/// `start_credit(i)` is the width a wrapped line starting at glyph `i` gives back (Mojikumi sets
/// an opening bracket there flush with the line's start); `cands` are the break opportunities
/// an opening bracket there flush with the line's start); `end_credit(t)` is the width a line whose
/// last character is glyph `t - 1` gives back (Mojikumi sets a closing mark there half width), at
/// most that glyph's advance, and nothing at a hyphenation point; `cands` are the break opportunities
/// in increasing order (the paragraph end is added); `flex` holds how far each glyph may stretch
/// and shrink in a justified line.
/// Returns `(end, hyphen)` per line, or `None` if no set of breaks fits within the tolerance
@@ -125,6 +127,7 @@ pub(crate) fn compose(
g: &[SGlyph],
width: &dyn Fn(usize) -> f64,
start_credit: &dyn Fn(usize) -> f64,
end_credit: &dyn Fn(usize) -> f64,
cands: &[Breakpoint],
flex: &[Flex],
p: &Params,
@@ -153,6 +156,11 @@ pub(crate) fn compose(
}
b
};
let line_end_credit = |t: usize| {
let credit = end_credit(t);
let most = t.checked_sub(1).and_then(|k| g.get(k)).map_or(0.0, |gl| gl.adv.max(0.0));
if credit.is_finite() { credit.clamp(0.0, most) } else { 0.0 }
};
let mut all: Vec<Breakpoint> = cands.iter().copied().filter(|c| c.end > 0 && c.end < n).collect();
all.push(Breakpoint { end: n, hyphen: 0.0 });
let (adj_demerits, hyphen_penalty) =
@@ -170,7 +178,10 @@ pub(crate) fn compose(
let Some(a) = nodes.get(ai) else { continue };
let t = trim(bp.end, a.pos);
let credit = if a.pos > 0 { start_credit(a.pos) } else { 0.0 };
let natural = w[t] - w[a.pos] - credit + bp.hyphen;
// A line that ends with a hyphen ends with it, not with its last character. At most the
// last glyph's advance, so a longer line from the same node is never narrower.
let end = if hyphen || t <= a.pos { 0.0 } else { line_end_credit(t) };
let natural = w[t] - w[a.pos] - credit - end + bp.hyphen;
let lw = width(a.line);
// Adjustment ratio, badness and whether the line ends the active node's reach.
let Some(ratio) = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p) else { continue };
+74 -17
View File
@@ -253,29 +253,52 @@ pub(super) fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64,
out
}
/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces
/// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it.
/// A list marker at `i` (glyphs of length 0) counts whole, with the word after it.
pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 {
/// Width of the word starting at glyph `i`: its advances up to the first place a line may break
/// (spaces left out, the hyphen of a soft hyphen in), the least room a line needs not to break
/// inside it. As in [`break_line`], kinsoku keeps a character that may not end a line with the one
/// after it, a No Break run (its spaces inside it included) is one word up to a forced line break,
/// and a cluster is never split. A soft hyphen just before a forced line break adds no hyphen. A list marker at `i` (glyphs of length 0) counts whole, with the word after it. A word
/// that ends where a line may end is measured as the line is set: without the spaces at its end,
/// and with its last glyph as narrow as `line_end` sets it; one that ends at a soft hyphen or past
/// the measured glyphs is not.
pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kinsoku) -> f64 {
/// Glyphs measured at most: a word wrapped over many lines is measured again from each line's
/// start, and one this long is broken inside whatever cell it meets.
const MAX_GLYPHS: usize = 1024;
let rest = g.get(i..).unwrap_or_default();
let marker = rest.iter().take_while(|gl| gl.len == 0).count();
let mut w: f64 = rest.iter().take(marker).map(|gl| gl.adv).sum();
for gl in rest.iter().skip(marker).take(MAX_GLYPHS) {
if gl.is_space() {
break;
let start = i + marker;
let mut end = start;
// The spaces at the end of what is measured so far (inside a No Break run), and the end of its
// last glyph that is not a space.
let (mut trailing, mut ink) = (0.0, start);
let set = |w: f64, trailing: f64, ink: usize| w - trailing - line_end_credit(g, line_end, ink);
while let Some(gl) = g.get(end).filter(|_| end - start < MAX_GLYPHS) {
// A forced line break ends the line, inside a No Break run too, as in [`break_line`].
if gl.ch == '\u{2028}' {
return set(w, trailing, ink);
}
if gl.is_soft_hyphen() {
if gl.is_space() && (end == start || !splits_no_break(g, end)) {
return set(w, trailing, ink);
}
let forced_next = g.get(end + 1).is_some_and(|n| n.ch == '\u{2028}');
if gl.is_soft_hyphen() && !forced_next && kinsoku_allows(g, end, kinsoku) && !splits_no_break(g, end + 1) {
return w + hyphen_glyph(gl).adv;
}
w += gl.adv;
if gl.break_after() {
break;
end += 1;
if gl.is_space() {
trailing += gl.adv;
} else {
(trailing, ink) = (0.0, end);
}
let cluster_ends = g.get(end).is_none_or(|next| next.byte != gl.byte);
if gl.break_after() && cluster_ends && kinsoku_allows(g, end - 1, kinsoku) && !splits_no_break(g, end) {
return set(w, trailing, ink);
}
}
w
if end >= g.len() { set(w, trailing, ink) } else { w }
}
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
@@ -287,6 +310,8 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 {
/// keeps the line from breaking before the character that overflows, `kinsoku_type` decides
/// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical
/// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type.
/// `line_end[k]` is how much narrower a line ending with glyph `k` is set (Mojikumi); a cluster
/// that fits only that narrower stays on the line when the line may end after it (empty: none).
#[allow(clippy::too_many_arguments)]
pub(super) fn break_line(
text: &str,
@@ -301,6 +326,7 @@ pub(super) fn break_line(
kinsoku_type: KinsokuShoriType,
hanging: bool,
vertical: bool,
line_end: &[f64],
) -> (usize, bool) {
if !width.is_finite() {
if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') {
@@ -342,7 +368,11 @@ pub(super) fn break_line(
if gl.ch == '\u{2028}' {
return (cluster_end, false);
}
if j > first && !gl.is_space() && x + cluster_width > width + EPS {
// Measured as the line is set when it ends with this cluster: only the cluster's last glyph
// is narrowed, and only where the line may end.
let end_credit =
Some(line_end_credit(g, line_end, cluster_end)).filter(|&c| c > 0.0 && may_end_after(g, cluster_end, kinsoku)).unwrap_or(0.0);
if j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS {
// It ends the line with the spaces after it.
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) {
let mut end = cluster_end;
@@ -351,8 +381,8 @@ pub(super) fn break_line(
}
return (end, false);
}
if let Some(end) =
push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging, vertical)
let pushed_out = last_break.map(|b| b.0);
if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, pushed_out, kinsoku, kinsoku_type, hanging, vertical, line_end)
{
return (end, false);
}
@@ -495,6 +525,30 @@ pub(super) fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f
(left, right, left_glyphs, right_glyphs)
}
/// How much narrower a line whose last character is glyph `end - 1` is set: `line_end` of that
/// glyph ([`break_line`]), at most its advance, and 0 when it is not finite.
fn line_end_credit(g: &[SGlyph], line_end: &[f64], end: usize) -> f64 {
let Some(k) = end.checked_sub(1) else { return 0.0 };
match (line_end.get(k), g.get(k)) {
(Some(&credit), Some(gl)) if credit.is_finite() && credit > 0.0 => credit.min(gl.adv.max(0.0)),
_ => 0.0,
}
}
/// May [`break_line`] end a line after glyph `end - 1` (and the spaces after it)? At the paragraph's
/// end or a forced line break, and where it records a break without a hyphen.
fn may_end_after(g: &[SGlyph], end: usize, kinsoku: Kinsoku) -> bool {
let mut e = end;
while g.get(e).is_some_and(SGlyph::is_space) {
e += 1;
}
if e >= g.len() || g[e].ch == '\u{2028}' {
return true;
}
let Some(last) = e.checked_sub(1).and_then(|k| g.get(k)) else { return false };
last.break_after() && last.len > 0 && !last.is_soft_hyphen() && kinsoku_allows(g, e - 1, kinsoku) && !splits_no_break(g, e)
}
/// Would a line ending at glyph index `end` split a No Break run?
fn splits_no_break(g: &[SGlyph], end: usize) -> bool {
end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break)
@@ -539,7 +593,8 @@ const PUSH_IN_RUN: usize = 16;
/// neither does a line holding a tab or right-to-left text, nor one where the characters taken in
/// reach a space after a character that may not end a line. With `hanging` (Optical Margin
/// Alignment or Roman Hanging Punctuation), the gaps that hanging punctuation keeps open are not
/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]).
/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). Both lines are
/// measured with their last character set as narrow as `line_end` sets it ([`break_line`]).
#[allow(clippy::too_many_arguments)]
fn push_in(
g: &[SGlyph],
@@ -553,6 +608,7 @@ fn push_in(
kinsoku_type: KinsokuShoriType,
hanging: bool,
vertical: bool,
line_end: &[f64],
) -> Option<usize> {
if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly {
return None;
@@ -604,7 +660,7 @@ fn push_in(
|| (a + 3 == last && punct(last - 2) && quote(last - 1)))
};
let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum();
if x - width > room + EPS {
if x - line_end_credit(g, line_end, last) - width > room + EPS {
return None;
}
if kinsoku_type == KinsokuShoriType::PushOutFirst {
@@ -612,7 +668,8 @@ fn push_in(
while kept > i && g[kept - 1].is_space() {
kept -= 1;
}
let short = width - g[i..kept].iter().map(|h| h.adv).sum::<f64>();
let set = if kept > i { line_end_credit(g, line_end, kept) } else { 0.0 };
let short = width - (g[i..kept].iter().map(|h| h.adv).sum::<f64>() - set);
if short <= squeeze_em(before, vertical) + EPS {
return None;
}
+84 -23
View File
@@ -23,11 +23,14 @@ const MAX_COMPOSE_GLYPHS: usize = 200_000;
/// Every-line composition of paragraph glyphs `sg` if applicable (area text, justified or ragged,
/// with uniform line metrics or in rectangular cells without text wrap); `None` falls back to the
/// greedy single-line composer. With `continued`, the paragraph continues from the frame before,
/// and its first composed line is not its first line.
/// and its first composed line is not its first line. `line_end` is how much narrower a line
/// ending with each glyph is set ([`ParagraphData::line_end`]).
#[allow(clippy::too_many_arguments)]
fn compose_para(
cx: &Ctx<'_>,
sg: &[SGlyph],
flex: &[Flex],
line_end: &[f64],
para: &ParaStyle,
pen: &Pen<'_>,
rtl: bool,
@@ -68,6 +71,12 @@ fn compose_para(
return None;
}
let total: f64 = sg.iter().map(|g| g.adv).sum();
// The same rule as the line loop below: an opening bracket starting a wrapped line gives up
// the space before it.
let start_credit = |i: usize| {
let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf;
sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0)
};
let mut sim = pen.clone();
let mut widths = vec![];
let mut acc = 0.0;
@@ -77,8 +86,9 @@ fn compose_para(
let first = widths.is_empty();
let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 };
// Only the first line's first word is known here. Later lines are placed as the composed
// lines need: each fits its width, so its first word does too.
let word = if first { word_width(sg, 0) } else { 0.0 };
// lines need: each fits its width, so its first word does too. It is measured as the line
// loop measures it, with Mojikumi's narrower start and end.
let word = if first { word_width(sg, 0, line_end, para.kinsoku) - start_credit(0) } else { 0.0 };
let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent, word) else { break };
let w = x1 - x0 - ind_l - para.right_indent;
widths.push(w);
@@ -103,13 +113,10 @@ fn compose_para(
// stop at four times its stretch and fall back to the single-line composer; the documented
// default range (word spacing up to 133%) is narrow enough that most paragraphs would.
let (strict, loose) = (1.0, f64::INFINITY);
// The same rule as the line loop below: an opening bracket starting a wrapped line gives up
// the space before it.
let start_credit = |i: usize| {
let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf;
sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0)
};
compose(sg, &width, &start_credit, &cands, flex, &params(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, &params(loose)))
// A line's last character is set narrower, as the line loop sets it.
let end_credit = |end: usize| end.checked_sub(1).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0);
let pass = |p: &Params| compose(sg, &width, &start_credit, &end_credit, &cands, flex, p);
pass(&params(strict)).or_else(|| pass(&params(loose)))
}
/// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers
@@ -184,6 +191,10 @@ struct ParagraphData<'a, 'text> {
sg: Vec<SGlyph>,
compressed: Vec<bool>,
wakan: Vec<f64>,
/// How much narrower a line ending with each glyph is set ([`line_end_trim`]), for measuring
/// where lines break; empty when the paragraph's lines are measured at full width (Mojikumi
/// None, or right-to-left text).
line_end: Vec<f64>,
flex: Vec<Flex>,
marker: usize,
pm: Metrics,
@@ -255,7 +266,13 @@ fn prepare_paragraph<'a, 'text>(
if pi > 0 {
pen.pending += para.space_before;
}
let composed = compose_para(cx, &sg, &flex, para, pen, rtl, continued);
// A line fits when it fits as it is set: with its last character narrowed by Mojikumi.
let line_end = if para.mojikumi == Mojikumi::LineEndHalf && !rtl {
(0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, k)).collect()
} else {
vec![]
};
let composed = compose_para(cx, &sg, &flex, &line_end, para, pen, rtl, continued);
// Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the
// Hyphen Limit counts the lines in a row that end with a hyphen.
let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 };
@@ -271,6 +288,7 @@ fn prepare_paragraph<'a, 'text>(
sg,
compressed,
wakan,
line_end,
flex,
marker,
pm,
@@ -286,6 +304,8 @@ struct PlacementContext<'a> {
para: &'a ParaStyle,
pr: &'a Range<usize>,
sg: &'a mut [SGlyph],
/// [`ParagraphData::line_end`].
line_end: &'a [f64],
continued: bool,
rtl: bool,
mirror: bool,
@@ -314,14 +334,27 @@ struct LinePosition {
}
fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option<LinePosition> {
let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line, hanging } =
context;
let PlacementContext {
para,
pr,
sg,
line_end,
continued,
rtl,
mirror,
pm,
composed,
hyphen_run,
hyphen_limit,
zone,
index: i,
para_line,
hanging,
} = context;
let n = sg.len();
let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm };
let first_line = *para_line == 0 && !*continued;
let (ind_l, ind_r) = line_indents(para, first_line, *mirror);
// Measured before Mojikumi narrows an opening bracket, as the composer measures it.
let word = word_width(sg, *i);
// Mojikumi: an opening bracket starting a line is set flush with the line's start (the
// space before it goes), and the line has that much more room. At a paragraph's start
// that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①).
@@ -334,6 +367,8 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
glyph.adv -= half;
glyph.lead += half;
}
// The line's first word, as narrow as the line sets it at both ends.
let word = word_width(sg, *i, line_end, para.kinsoku);
// Place, break, then settle the baseline on the line's real metrics (moving on to the
// next row/column if it no longer fits).
loop {
@@ -347,8 +382,23 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
Some(&(end, hyph)) if end > *i => (end, hyph, false),
_ if *i < n => {
let line =
|ty| break_line(cx.text, sg, *i, width, &para.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging, cx.vertical);
let line = |ty| {
break_line(
cx.text,
sg,
*i,
width,
&para.tabs,
ind_l,
hyphenate,
para.burasagari,
para.kinsoku,
ty,
*hanging,
cx.vertical,
line_end,
)
};
let (end, hyph) = line(para.kinsoku_type);
// The line pushed characters in where it ends after the line that pushes out.
let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0;
@@ -441,6 +491,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
para: paragraph.para,
pr: &paragraph.pr,
sg: &mut paragraph.sg,
line_end: &paragraph.line_end,
continued: paragraph.continued,
rtl: paragraph.rtl,
mirror: paragraph.mirror,
@@ -475,6 +526,18 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
true
}
/// Mojikumi (Line-end Punctuation Half Width): how much narrower a line ending with glyph `k` of
/// `sg` is set. A closing bracket, comma or full stop ending the line is set half width, unless it
/// already lost that half to the punctuation after it (`compressed`), and no Japanese–Latin space
/// (`wakan`) is left after the line's last character. Never more than the glyph's advance, and 0
/// for an advance or a space that is not finite.
pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], k: usize) -> f64 {
let Some(g) = sg.get(k) else { return 0.0 };
let half = if compressed.get(k).copied().unwrap_or(false) { None } else { punct_half(g, Punct::Closing) };
let trim = half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0);
if trim.is_finite() && g.adv.is_finite() { trim.min(g.adv).max(0.0) } else { 0.0 }
}
/// The geometry of the line of `paragraph` from glyph `index`, placed at `position`: where it
/// ends with its trailing spaces trimmed, its hyphen, its room and margins, the glyphs that hang or
/// stay out of justification, and whether it is the paragraph's last line.
@@ -490,12 +553,10 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
trimmed -= 1;
}
let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1]));
// Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and
// no Japanese–Latin space is left at the end of a line.
let end_trim = trimmed.checked_sub(1).filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf).map_or(0.0, |k| {
let half = if paragraph.compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) };
half.unwrap_or(0.0) + paragraph.wakan.get(k).copied().unwrap_or(0.0)
});
let end_trim = trimmed
.checked_sub(1)
.filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf)
.map_or(0.0, |k| line_end_trim(sg, &paragraph.compressed, &paragraph.wakan, k));
let measured_width = set_width(sg, index, trimmed, &para.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim;
let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte);
let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte);
+151 -13
View File
@@ -92,14 +92,15 @@ fn indic_hyphenation_reaches_both_composers_without_splitting_marks() {
KinsokuShoriType::PushOutOnly,
false,
false,
&[],
);
assert_eq!((end, hyphen), (4, true), "{tag}");
let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 };
let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], &params).expect("every-line composition");
let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &|_| 0.0, &cands, &[], &params).expect("every-line composition");
assert!(lines.iter().any(|(_, hyphen)| *hyphen), "{tag}");
assert!(lines.iter().all(|(end, _)| end % 2 == 0), "{tag}: {lines:?}");
let limited = Params { hyphen_limit: 1, ..params };
if let Some(lines) = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], &limited) {
if let Some(lines) = compose(&glyphs, &|_| 4.5, &|_| 0.0, &|_| 0.0, &cands, &[], &limited) {
assert!(lines.windows(2).all(|pair| !pair[0].1 || !pair[1].1), "{tag}: {lines:?}");
}
}
@@ -164,7 +165,7 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() {
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
let (end, hyphenated) =
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert!(!hyphenated);
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
@@ -178,12 +179,12 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() {
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
let (first_end, _) =
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert_eq!(first_end, 1);
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
let (second_end, hyphenated) =
break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert!(!hyphenated);
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
@@ -239,7 +240,7 @@ fn no_break_keeps_marked_characters_on_one_line() {
let text = "A B";
let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]);
let (end, hyphen) =
break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert!(!hyphen);
assert_eq!(end, 2, "without the mark the line ends after the space");
@@ -248,7 +249,7 @@ fn no_break_keeps_marked_characters_on_one_line() {
g.no_break = true;
}
let (end, hyphen) =
break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert!(!hyphen);
assert_eq!(end, 3, "the marked words stay together even though they are wider than the line");
@@ -257,15 +258,60 @@ fn no_break_keeps_marked_characters_on_one_line() {
let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]);
glyphs[2].no_break = true;
glyphs[3].no_break = true;
let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
let (end, _) =
break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert_eq!(end, 2, "the marked pair moves to the next line together");
let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
let (end, _) =
break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line");
let breaks = candidates(text, &glyphs, None, Kinsoku::None);
assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}");
}
/// A soft hyphen inside a No Break run is not a place a line may break, so the word is measured
/// across it, as wide as [`break_line`] keeps it.
#[test]
fn word_width_measures_across_a_soft_hyphen_inside_a_no_break_run() {
let mut glyphs = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 2, 0.0, '\u{00ad}'), (3, 1, 10.0, 'B'), (4, 1, 10.0, 'C')]);
// The hyphen a soft hyphen adds comes from the face, scaled to well under 10 pt.
for g in &mut glyphs {
g.sx = 0.001;
}
let plain = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!(plain < 30.0, "without the mark the word ends at the soft hyphen: {plain}");
for g in &mut glyphs {
g.no_break = true;
}
let marked = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!((marked - 30.0).abs() < 1e-9, "the marked run is measured whole: {marked}");
}
/// A forced line break ends the word [`breaking::word_width`] measures, inside a No Break run too,
/// and a soft hyphen just before it adds no hyphen, as [`break_line`] sets the line.
#[test]
fn word_width_stops_at_a_forced_line_break() {
let mut glyphs = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 3, 5.0, '\u{2028}'), (4, 1, 10.0, 'B'), (5, 1, 10.0, 'C')]);
for g in &mut glyphs {
g.no_break = true;
}
let marked = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!((marked - 10.0).abs() < 1e-9, "the marked run ends at the forced line break: {marked}");
let mut glyphs = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 2, 0.0, '\u{00ad}'), (3, 3, 5.0, '\u{2028}'), (6, 1, 10.0, 'B')]);
// The hyphen a soft hyphen adds comes from the face, scaled to well under 10 pt.
for g in &mut glyphs {
g.sx = 0.001;
}
let plain = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!((plain - 10.0).abs() < 1e-9, "no hyphen before the forced line break: {plain}");
for g in &mut glyphs {
g.no_break = true;
}
let marked = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!((marked - 10.0).abs() < 1e-9, "the marked run ends at the forced line break: {marked}");
}
/// Synthetic full-width characters `text`, each 10 pt wide on a 10 pt em.
fn full_width(text: &str) -> Vec<SGlyph> {
let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect();
@@ -286,7 +332,7 @@ fn full_width(text: &str) -> Vec<SGlyph> {
fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() {
use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly};
let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| {
break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false).0
break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false, &[]).0
};
let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false);
let text = "一二三四五六七八九十一二三四五」六";
@@ -351,7 +397,8 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
let mixed = "一二三א五六七八九十一二三四五」六";
let mut glyphs = full_width(mixed);
glyphs[3].level = unicode_bidi::Level::rtl();
let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0;
let mixed_end =
|ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0;
assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」");
assert_eq!(mixed_end(PushOutOnly), 14);
@@ -362,7 +409,8 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
for g in &mut glyphs {
g.sy *= 2.0;
}
let tall_end = |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0;
let tall_end =
|ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0;
assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」");
// No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to
@@ -371,7 +419,97 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
glyphs[1].adv = 5.0;
let half = glyphs[1].clone();
glyphs.insert(2, half);
let split_end = |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false).0;
let split_end =
|width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false, &[]).0;
assert_eq!(split_end(54.0), 5, "五 is pushed out with 」");
assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in");
}
/// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed
/// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last
/// glyph of the last cluster counts, and only where the line may end.
#[test]
fn greedy_wrap_counts_the_narrower_line_end() {
use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly};
let text = "一二三」四五六";
let glyphs = full_width(text);
let mut credit = vec![0.0; glyphs.len()];
credit[3] = 5.0;
let end = |glyphs: &[SGlyph], width: f64, burasagari: Burasagari, line_end: &[f64]| {
break_line(text, glyphs, 0, width, &[], 0.0, None, burasagari, Kinsoku::Hard, PushOutOnly, false, false, line_end).0
};
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &credit), 4, "」 half width fits");
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[]), 2, "」 full width does not: 三 goes with it");
assert_eq!(end(&glyphs, 34.0, Burasagari::None, &credit), 2, "half width is still too wide");
// A credit that isn't finite counts for nothing, and one larger than the glyph for its advance.
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[0.0, 0.0, 0.0, f64::INFINITY]), 2);
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[0.0, 0.0, 0.0, f64::NAN]), 2);
assert_eq!(end(&glyphs, 30.0, Burasagari::None, &[0.0, 0.0, 0.0, 100.0]), 4, "at most 10 pt off");
assert_eq!(end(&glyphs, 29.0, Burasagari::None, &[0.0, 0.0, 0.0, 100.0]), 2);
// The line may not end after 三 (、 follows it): its credit does not keep it on the line, so 、
// does not hang after a line already wider than the measure.
let comma = "一二三、";
let glyphs = full_width(comma);
let end = |line_end: &[f64]| {
break_line(comma, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, PushOutOnly, false, false, line_end).0
};
assert_eq!(end(&[0.0, 0.0, 5.0, 0.0]), 2);
// Kinsoku Shori Type measures both lines as they are set. Pushing 。 in leaves the line 5 pt
// wider than the measure with 。 half width, which its five gaps close.
let pushed = "一二三四」。六";
let glyphs = full_width(pushed);
let end = |ty: KinsokuShoriType, line_end: &[f64]| {
break_line(pushed, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, line_end).0
};
let stop = [0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 0.0];
assert_eq!(end(PushInFirst, &stop), 6, "。 is pushed in");
assert_eq!(end(PushInFirst, &[]), 3, "full width, the gaps can't close enough");
// Push Out First: pushing out to after 」, set half width, leaves the line 15 pt short, more
// than an em, so 四 is pushed in.
let pushed = format!("{}二」「四", "一".repeat(10));
let glyphs = full_width(&pushed);
let end = |line_end: &[f64]| {
break_line(&pushed, &glyphs, 0, 130.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutFirst, false, false, line_end).0
};
let mut bracket = vec![0.0; glyphs.len()];
bracket[11] = 5.0;
assert_eq!(end(&bracket), 14, "四 is pushed in");
assert_eq!(end(&[]), 12, "full width, pushing out leaves the line one em short: 「四 go on");
// 三 is two glyphs of one cluster: the credit of its first glyph does not count.
let text = "一二三";
let mut glyphs = full_width(text);
glyphs[2].adv = 5.0;
let half = glyphs[2].clone();
glyphs.insert(3, half);
let end =
|line_end: &[f64]| break_line(text, &glyphs, 0, 26.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, PushOutOnly, false, false, line_end).0;
assert_eq!(end(&[0.0, 0.0, 4.0, 0.0]), 2, "the cluster stays whole and goes to the next line");
assert_eq!(end(&[0.0, 0.0, 0.0, 4.0]), 4, "its last glyph's credit keeps it on the line");
}
/// Mojikumi: the every-line composer measures each candidate line with its last character narrowed
/// (`end_credit`), never by more than that glyph's advance, and a line ending in a hyphen ends with
/// the hyphen.
#[test]
fn composition_counts_the_narrower_line_end() {
let text = "一二三」四五六";
let glyphs = full_width(text);
let cands = candidates(text, &glyphs, None, Kinsoku::Hard);
let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 };
let lines =
|width: f64, end_credit: &dyn Fn(usize) -> f64, cands: &[Breakpoint]| compose(&glyphs, &|_| width, &|_| 0.0, end_credit, cands, &[], &params);
let half = |t: usize| if t == 4 { 5.0 } else { 0.0 };
assert_eq!(lines(35.0, &half, &cands), Some(vec![(4, false), (7, false)]));
let solid = lines(35.0, &|_| 0.0, &cands).expect("composed");
assert!(solid.iter().all(|&(end, _)| end != 4), "」 full width does not end the first line: {solid:?}");
// At most the glyph's advance; a credit that isn't finite counts for nothing.
assert_eq!(lines(30.0, &|t| if t == 4 { 100.0 } else { 0.0 }, &cands).and_then(|l| l.first().copied()), Some((4, false)));
assert!(lines(35.0, &|_| f64::NAN, &cands).is_some_and(|l| l.iter().all(|&(end, _)| end != 4)));
// No credit for a line ending in a hyphen.
let hyphenated = [Breakpoint { end: 4, hyphen: 1.0 }];
assert_eq!(lines(38.0, &half, &hyphenated), None);
assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)]));
}
+35
View File
@@ -414,6 +414,41 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option<Vec<u8>> {
Some(builder.build())
}
/// The family of [`full_width_font`].
pub const FULL_WIDTH_FAMILY: &str = "Fullwidthtest Sans";
/// The ideographs and Japanese punctuation [`full_width_font`] sets one em wide.
pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。";
/// A test-only cmap over bundled Source Sans 3: [`FULL_WIDTH_CHARS`] use the one-em-wide glyph of
/// U+2318, so Japanese composition is tested without OS fonts; Latin letters and the space keep
/// their own glyphs.
pub fn full_width_font() -> Option<Vec<u8>> {
use write_fonts::tables::cmap::Cmap;
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let charmap = font.charmap();
let wide = write_fonts::types::GlyphId::new(charmap.map('\u{2318}')?.to_u32());
let latin = ('A'..='Z').chain('a'..='z').chain([' ']);
let mut mappings: Vec<(char, write_fonts::types::GlyphId)> = FULL_WIDTH_CHARS.chars().map(|c| (c, wide)).collect();
for c in latin {
mappings.push((c, write_fonts::types::GlyphId::new(charmap.map(c)?.to_u32())));
}
let cmap = Cmap::from_mappings(mappings).ok()?;
let name = name_table(&[(1, FULL_WIDTH_FAMILY), (2, "Regular"), (4, "Fullwidthtest Sans Regular"), (6, "FullwidthtestSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_table(&cmap).ok()?;
builder.add_raw(Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if tag != Tag::new(b"GSUB") && !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`half_form_font`].
pub const HALF_FORM_FAMILY: &str = "Halftest Sans";
+170
View File
@@ -1075,6 +1075,176 @@ fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() {
}
}
/// Mojikumi: with Line-end Punctuation Half Width, a line is measured as it is set. A closing
/// bracket, comma or full stop that fits only at half width ends the line in a measure of three
/// and a half ems, with both composers, horizontal and vertical; with Mojikumi None it goes to the
/// next line with the character before it. A closing mark that already lost its half to the
/// punctuation after it takes nothing more off, and the space between Japanese and Latin text
/// counts only where the line ends. Uses a test font whose ideographs and punctuation are one em.
#[test]
fn mojikumi_line_end_half_width_counts_when_a_line_breaks() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
let lay = |text: &str, m: Mojikumi, composer: Composer, burasagari: Burasagari, vertical_type: bool, measure: f64| {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.mojikumi = m;
t.para.composer = composer;
t.para.burasagari = burasagari;
t.para.kinsoku = Kinsoku::Hard;
// Push Out Only lets the every-line composer take the paragraph.
if composer == Composer::EveryLine {
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
}
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, measure) } else { Rect::new(0.0, 0.0, measure, 200.0) };
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
layout(&db, &t)
};
let ends = |l: &TextLayout, text: &str| l.lines.iter().map(|line| text[line.start..line.end].to_string()).collect::<Vec<_>>();
let widest = |l: &TextLayout| {
l.lines.iter().map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::<f64>()).fold(0.0, f64::max)
};
assert!((lay("一", Mojikumi::None, Composer::SingleLine, Burasagari::None, false, 70.0).glyphs[0].advance - 20.0).abs() < 1e-9, "one em");
for vertical_type in [false, true] {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let at = format!("vertical {vertical_type} {composer:?}");
let marks = [("一二三」四五六", "一二三」"), ("一二三。四五六", "一二三。"), ("一二三、四五六", "一二三、")];
for (text, first) in marks {
let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0);
assert_eq!(ends(&l, text), [first, "四五六"], "{at}: {text}");
assert!((l.glyphs[3].advance - 10.0).abs() < 1e-9, "{at}: {text}: the mark ends the line half width");
assert!(widest(&l) <= 70.0 + 1e-9, "{at}: {text}: {}", widest(&l));
// Standard burasagari: the mark fits inside, so it doesn't hang.
let standard = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::Standard, vertical_type, 70.0);
assert_eq!(ends(&standard, text), [first, "四五六"], "{at}: {text} standard");
assert!(widest(&standard) <= 70.0 + 1e-9, "{at}: {text} standard");
// Full width it doesn't fit: kinsoku takes 三 to the next line with it.
let solid = lay(text, Mojikumi::None, composer, Burasagari::None, vertical_type, 70.0);
assert_eq!(ends(&solid, text).first().map(String::as_str), Some("一二"), "{at}: {text} none");
}
// 」 lost its half to 。 after it: the line would be four ems, so 三」。 go on.
let text = "一二三」。四五";
let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0);
assert_eq!(ends(&l, text).first().map(String::as_str), Some("一二"), "{at}: compressed");
assert!(widest(&l) <= 70.0 + 1e-9, "{at}: compressed");
}
}
// The quarter em between 三 and A goes where the line ends there: 一二三 fits three ems and a
// tenth, with or without the space at its end.
for composer in [Composer::SingleLine, Composer::EveryLine] {
let text = "一二三ABC";
let on = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 62.0);
assert_eq!(ends(&on, text).first().map(String::as_str), Some("一二三"), "{composer:?}");
assert!((on.glyphs[2].advance - 20.0).abs() < 1e-9, "{composer:?}: no space at the line's end");
let wide = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 200.0);
assert!((wide.glyphs[2].advance - 25.0).abs() < 1e-9, "{composer:?}: the space inside the line");
}
}
/// Mojikumi: in a frame that is narrow at the top, and in a frame a wrap object narrows at the top,
/// a line's first word is measured as the line sets it. A word ending with a closing bracket that
/// fits the narrow lines only with the bracket half width, and an opening bracket that fits them
/// only flush with the line's start, start on the first line, with both composers. An opening
/// bracket that kinsoku keeps with the character after it, and a No Break run, are measured whole.
#[test]
fn mojikumi_narrows_a_lines_first_word_when_looking_for_room() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::{Composer, Kinsoku, KinsokuShoriType, Mojikumi, TextWrap, WrapShape};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
// The top 40 pt of the frame are `top` wide, the rest 200: an L-shaped frame, or a rectangle
// with a wrap object over the top right.
let lay = |text: &str, styled: fn(&mut TextObject), composer: Composer, top: f64, wrapped: bool| {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
styled(&mut t);
t.para.mojikumi = Mojikumi::LineEndHalf;
t.para.composer = composer;
t.para.kinsoku = Kinsoku::Hard;
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
let frame = if wrapped {
t.wrap = vec![WrapShape {
path: PathData::from_bezpath(&Rect::new(top, -10.0, 210.0, 40.0).to_path(0.1)),
wrap: TextWrap { offset: 0.0, invert: false },
}];
Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1)
} else {
let mut p = kurbo::BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((top, 0.0));
p.line_to((top, 40.0));
p.line_to((200.0, 40.0));
p.line_to((200.0, 200.0));
p.line_to((0.0, 200.0));
p.close_path();
p
};
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) };
layout(&db, &t)
};
let plain: fn(&mut TextObject) = |_| {};
let wide_bracket: fn(&mut TextObject) = |t| edit::style_range(&mut t.runs, 0, '「'.len_utf8(), |s| s.h_scale = 250.0);
let no_break: fn(&mut TextObject) = |t| {
let n = t.plain_text().len();
edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true);
};
let no_break_but_the_last_word: fn(&mut TextObject) = |t| {
let n = t.plain_text().rfind(' ').map_or(0, |k| k + 1);
edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true);
};
// "ABCD」" is `full` wide inside a line; ending one, 」 is half width.
let closing = "ABCD」EFGH";
let open = lay(closing, plain, Composer::SingleLine, 200.0, false);
let full: f64 = open.glyphs[..5].iter().map(|g| g.advance).sum();
assert!((open.glyphs[4].advance - 20.0).abs() < 1e-9, "」 is one em inside a line");
for wrapped in [false, true] {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let at = format!("wrapped {wrapped} {composer:?}");
let top = full - 5.0;
let l = lay(closing, plain, composer, top, wrapped);
let first = l.lines.first().expect("a line");
assert_eq!(&closing[first.start..first.end], "ABCD」", "{at}");
assert!(first.baseline < 40.0, "{at}: the word starts on the narrow lines: {}", first.baseline);
let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum();
assert!(set <= top + 1e-9, "{at}: {set} in {top}");
// 「 is 50 pt at 250%, 25 pt flush with the line's start: 「一 is 45 pt.
let opening = "「一二三四五六";
let l = lay(opening, wide_bracket, composer, 47.0, wrapped);
let first = l.lines.first().expect("a line");
assert_eq!(&opening[first.start..first.end], "「一", "{at}");
assert!(first.baseline < 40.0, "{at}: 「 starts on the narrow lines: {}", first.baseline);
let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum();
assert!(set <= 47.0 + 1e-9, "{at}: {set}");
// Kinsoku keeps 「 with 一: in 40 pt they don't fit, and the line starts below, whole.
let l = lay(opening, wide_bracket, composer, 40.0, wrapped);
let first = l.lines.first().expect("a line");
assert!(first.baseline > 40.0 && &opening[first.start..first.end] != "「", "{at}: kinsoku: {:?}", placed_lines(&l, opening));
// A No Break run is one word, its space included: it doesn't fit the narrow lines.
let run_text = "ABCD EFGH";
let run: f64 = lay(run_text, no_break, composer, 200.0, false).glyphs.iter().map(|g| g.advance).sum();
let l = lay(run_text, no_break, composer, run - 5.0, wrapped);
let first = l.lines.first().expect("a line");
assert!(first.baseline > 40.0 && &run_text[first.start..first.end] == run_text, "{at}: no break: {:?}", placed_lines(&l, run_text));
// …but the space ending a No Break run is not set at the line's end: the run fits a
// line 2 pt wider than it is without that space.
let ending = "ABCD EFGH IJ";
let l = lay(ending, no_break_but_the_last_word, composer, run + 2.0, wrapped);
let first = l.lines.first().expect("a line");
assert!(first.baseline < 40.0 && ending[first.start..first.end].trim_end() == run_text, "{at}: {:?}", placed_lines(&l, ending));
}
}
}
/// Each line's baseline and text.
fn placed_lines(l: &TextLayout, text: &str) -> Vec<(f64, String)> {
l.lines.iter().map(|line| (line.baseline, text[line.start..line.end].to_string())).collect()
}
/// Burasagari: in a measure of exactly five ems, a comma that would be the sixth character goes to
/// the next line with the character before it (None) or hangs outside the line (Standard, Forced);
/// a full stop that is the fifth character stays inside (None, Standard) or hangs while the four
File diff suppressed because one or more lines are too long