diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index dbfa37431..bac0edbe9 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -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 = 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 }; diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index a753dba77..a259ce088 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -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 { 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::(); + 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::() - set); if short <= squeeze_em(before, vertical) + EPS { return None; } diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index da264d26e..0fd3c1275 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -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, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, ¶ms(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(¶ms(strict)).or_else(|| pass(¶ms(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, compressed: Vec, wakan: Vec, + /// 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, flex: Vec, 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, 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 { - 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, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging, cx.vertical); + let line = |ty| { + break_line( + cx.text, + sg, + *i, + width, + ¶.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: ¶graph.pr, sg: &mut paragraph.sg, + line_end: ¶graph.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, ¶graph.compressed, ¶graph.wakan, k)); let measured_width = set_width(sg, index, trimmed, ¶.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); diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 920e802b7..e077707a4 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -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, &[], ¶ms).expect("every-line composition"); + let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms).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 { 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 { 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, &[], ¶ms); + 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)])); +} diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index aab7f0e1f..1300920ad 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -414,6 +414,41 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option> { 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> { + 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"; diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 2bc1c2ff7..e76aee72f 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -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::>(); + let widest = |l: &TextLayout| { + l.lines.iter().map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::()).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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8247c1247..490dfb38d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1199,6 +1199,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). + With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them.