Type: right-to-left lines with tabs are measured as they are drawn

A right-to-left line draws the text before a tab after it, but its width
was measured in the order of its text. A right-aligned line such as
"אב גד<tab>הו זח" with a stop at 150 pt in a 300 pt frame ended 5.59 pt
past the frame's right edge, and right, center and decimal stops aligned
the text before the tab instead of the text drawn after it.

- The line's width is measured in drawing order, each tab up to its stop
  from where the glyphs drawn before it end. Right, Auto and centered
  lines are aligned by that width. Stops are still measured from the
  left edge.
- Right, center and decimal stops align the text drawn after the tab.
- Area type breaks a line with a tab where it fits as drawn, and wraps a
  line that fits only in the order of its text. Once the line holds a
  tab, it reads on while the least it can be drawn at fits, then ends at
  the latest break that fits as drawn: up to 256 breaks are measured
  from the latest back, and earlier breaks without a hyphen are
  bisected. Hyphenation points are measured as drawn the same way, so
  the line hyphenates at a point that fits.
- Only lines of horizontal paragraphs with right-to-left text keep a
  list of their break candidates.
- A tab that ends a wrapped right-to-left line is drawn before the line
  at its own width instead of pushing the line to the right.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 12:40:40 -08:00
parent c9b2119552
commit 25857b2bce
6 changed files with 540 additions and 73 deletions
+1 -1
View File
@@ -327,7 +327,7 @@ impl Ctx<'_> {
let pen = left + para.first_line_indent + marker.iter().map(|g| g.adv).sum::<f64>();
let to_indent = left - pen;
let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < left - EPS);
tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(&para.tabs, 0.0, pen, sg).0 };
tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(&para.tabs, 0.0, pen, sg.iter()).0 };
if para.list.is_some_and(|l| l.align == ListAlign::Right) {
marker.insert(0, tab);
} else {
+119 -28
View File
@@ -203,11 +203,17 @@ impl Pen<'_> {
/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then
/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal
/// stops align the text that follows (`rest`, up to the next tab) on the stop.
pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) {
/// stops align the text drawn after the tab (`rest`, in drawing order, up to the next tab) on the
/// stop.
pub(super) fn tab_advance<'t, 'g>(
tabs: &'t [vectorcraft_doc::TabStop],
origin: f64,
x: f64,
rest: impl IntoIterator<Item = &'g SGlyph>,
) -> (f64, &'t str) {
use vectorcraft_doc::TabAlign;
let rel = x - origin;
let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect();
let seg: Vec<&SGlyph> = rest.into_iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect();
let width: f64 = seg.iter().map(|g| g.adv).sum();
let target = |pos: f64, align: TabAlign, on: char| match align {
TabAlign::Left => pos,
@@ -227,17 +233,28 @@ pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64,
((at - rel).max(0.0), leader)
}
/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the
/// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their
/// glyph. The tab of a list marker (length 0) keeps its own width.
pub(super) fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 {
/// The glyphs of `sg` drawn after position `k` of `drawn` (indices into `sg`, in drawing order).
pub(super) fn drawn_after_tab<'g>(sg: &'g [SGlyph], drawn: &'g [usize], k: usize) -> impl Iterator<Item = &'g SGlyph> {
drawn.iter().skip(k + 1).filter_map(move |&j| sg.get(j))
}
/// The width of the glyphs of `sg` at `drawn` (indices into `sg`, in the order the line draws
/// them) set on a line whose content starts `x_start` from the tab origin: tabs of the text take
/// what [`tab_advance`] gives them from where the glyphs drawn before them end, aligning the glyphs
/// drawn after them, not the width of their glyph. The tab of a list marker (length 0) keeps its
/// own width.
pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 {
let mut x = x_start;
for (j, g) in sg.iter().enumerate().take(to).skip(from) {
x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv };
for (k, g) in drawn.iter().enumerate().filter_map(|(k, &j)| sg.get(j).map(|g| (k, g))) {
x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, drawn_after_tab(sg, drawn, k)).0 } else { g.adv };
}
x - x_start
}
/// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one from the latest
/// back before bisecting the earlier ones.
const MAX_DRAWN_CHECKS: usize = 256;
/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make
/// millions of outlines.
const MAX_LEADER_COPIES: f64 = 4096.0;
@@ -335,6 +352,12 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins
/// 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).
/// `fits`, for a line drawn in another order than its text's (right to left), is whether the line
/// from `i` ending before glyph `end` (with a hyphen when `hyphenated`) fits `width` as it is drawn:
/// once the line holds a tab, whose width depends on what is drawn before it, the line reads on
/// while the least it can be drawn at fits `width`, then ends at the latest legal break where it
/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, the earlier ones without a
/// hyphen are bisected); hyphenation points are measured the same way.
#[allow(clippy::too_many_arguments)]
pub(super) fn break_line(
text: &str,
@@ -350,7 +373,29 @@ pub(super) fn break_line(
hanging: bool,
vertical: bool,
line_end: &[f64],
fits: Option<&dyn Fn(usize, bool) -> bool>,
) -> (usize, bool) {
// The line ending at `chosen`, once it holds a tab, when it fits as drawn; otherwise the latest
// of the legal `breaks` before it that fits as drawn, measuring at most MAX_DRAWN_CHECKS of
// them from the latest back and bisecting the earlier ones without a hyphen; the first of them
// when none of those measured fits.
let settle = |tabbed: bool, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) {
let Some(fits) = fits.filter(|_| tabbed) else { return chosen };
if fits(chosen.0, chosen.1) {
return chosen;
}
let earlier = breaks.get(breaks.partition_point(|b| b.0 <= i)..breaks.partition_point(|b| b.0 < chosen.0)).unwrap_or_default();
if let Some(&b) = earlier.iter().rev().take(MAX_DRAWN_CHECKS).find(|b| fits(b.0, b.1)) {
return b;
}
// Without a hyphen, a line that ends later is drawn no narrower, so those breaks bisect.
let rest = earlier.get(..earlier.len().saturating_sub(MAX_DRAWN_CHECKS)).unwrap_or_default();
let plain: Vec<&(usize, bool)> = rest.iter().filter(|b| !b.1).collect();
let fitting = plain.partition_point(|b| fits(b.0, b.1));
fitting.checked_sub(1).and_then(|k| plain.get(k).copied()).or(earlier.first()).copied().unwrap_or(chosen)
};
let mut breaks: Vec<(usize, bool)> = Vec::new();
let mut tabbed = false;
if !width.is_finite() {
if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') {
return (i + pos + 1, false);
@@ -358,6 +403,27 @@ pub(super) fn break_line(
return (g.len(), false);
}
let mut x = 0.0;
// The width of the line's glyphs other than its tabs, how far those with a negative advance step
// back in all, and how many tabs it holds.
let mut ink = 0.0;
let mut back = 0.0;
let mut tabs_read = 0usize;
// The `n`th of the stops a tab can go to, in order: the explicit stops, then every default
// stop after the last of them (0 when `n` is 0).
let mut explicit: Vec<f64> = if fits.is_some() { tabs.iter().map(|t| t.position).filter(|p| p.is_finite()).collect() } else { Vec::new() };
explicit.sort_by(f64::total_cmp);
explicit.dedup();
let last_explicit = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0);
let nth_stop = |n: usize| -> f64 {
use vectorcraft_doc::text::DEFAULT_TAB_INTERVAL;
match n.checked_sub(1) {
None => 0.0,
Some(k) => explicit
.get(k)
.copied()
.unwrap_or_else(|| ((last_explicit / DEFAULT_TAB_INTERVAL).floor() + (n - explicit.len()) as f64) * DEFAULT_TAB_INTERVAL),
}
};
let mut last_break: Option<(usize, bool)> = None;
// A list marker (length 0) is a cluster of its own; the line holds it and at least the first
// cluster after it.
@@ -384,19 +450,32 @@ pub(super) fn break_line(
}
}
// A list marker's glyphs, its tab included, keep their own widths.
let cluster_width: f64 = if gl.ch == '\t' && gl.len > 0 {
let is_tab = gl.ch == '\t' && gl.len > 0;
let cluster_width: f64 = if is_tab {
tabbed = true;
tabs_read += 1;
tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0
} else {
g[j..cluster_end].iter().map(|glyph| glyph.adv).sum()
};
let cluster_ink = if is_tab { 0.0 } else { cluster_width };
let cluster_back: f64 = if is_tab { 0.0 } else { g[j..cluster_end].iter().map(|glyph| (-glyph.adv).max(0.0)).sum() };
if gl.ch == '\u{2028}' {
return (cluster_end, false);
return settle(tabbed, (cluster_end, false), &breaks);
}
// 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 {
// Once the line holds a tab and is measured as drawn, it reads on while the least it can be
// drawn at fits, and where it ends is settled then. That least is its glyphs other than the
// tabs and, while no glyph steps back, the stop its tabs reach: each tab then goes to a later
// stop than the one before and the text aligned on it ends at or past that stop, so the
// line's tabs, all before this cluster, reach at least the stop as many places along as
// there are tabs.
let stops_reached = if back + cluster_back > 0.0 { f64::NEG_INFINITY } else { nth_stop(tabs_read) - x_start };
let measured = if tabbed && fits.is_some() { (ink + cluster_ink).max(stops_reached) } else { x + cluster_width };
if j > first && !gl.is_space() && measured - 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;
@@ -413,20 +492,23 @@ pub(super) fn break_line(
break;
}
x += cluster_width;
ink += cluster_ink;
back += cluster_back;
let last = &g[cluster_end - 1];
if last.break_after() && last.len > 0 && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) {
if last.is_soft_hyphen() {
if x + hyphen_glyph(last).adv <= width + EPS {
last_break = Some((cluster_end, true));
// Once the line holds a tab, whether a break fits as drawn is settled when the line ends.
let hyphenated = last.is_soft_hyphen();
if !hyphenated || (tabbed && fits.is_some()) || x + hyphen_glyph(last).adv <= width + EPS {
last_break = Some((cluster_end, hyphenated));
if fits.is_some() {
breaks.push((cluster_end, hyphenated));
}
} else {
last_break = Some((cluster_end, false));
}
}
j = cluster_end;
}
if j >= g.len() {
return (g.len(), false);
return settle(tabbed, (g.len(), false), &breaks);
}
// Hyphenate the word that overflows. A list marker starting the line is not part of a word.
let word_start = last_break.map_or(first, |b| b.0);
@@ -450,22 +532,31 @@ pub(super) fn break_line(
.into_iter()
.filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k))
.collect();
for &k in pts.iter().rev() {
if k <= j && k > i {
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
if w <= width + EPS {
return (k, true);
}
// The points the line may end at, the latest first. Measured as drawn, at most
// MAX_DRAWN_CHECKS of them are measured one by one, and the earlier ones are bisected:
// each ends with a hyphen, so a later one is drawn no narrower.
let points: Vec<usize> = pts.iter().rev().copied().filter(|&k| k <= j && k > i).collect();
let drawn = fits.filter(|_| tabbed);
for &k in points.iter().take(if drawn.is_some() { MAX_DRAWN_CHECKS } else { usize::MAX }) {
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
if drawn.map_or(w <= width + EPS, |fits| fits(k, true)) {
return (k, true);
}
}
if let Some(fits) = drawn {
let earlier: Vec<usize> = points.iter().skip(MAX_DRAWN_CHECKS).rev().copied().collect();
let fitting = earlier.partition_point(|&k| fits(k, true));
if let Some(&k) = fitting.checked_sub(1).and_then(|n| earlier.get(n)) {
return (k, true);
}
}
}
// Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only
// cluster starts, so the resulting end always keeps each source cluster together.
let (end, hy) = match last_break {
Some(b) if b.0 > i => b,
match last_break {
Some(b) if b.0 > i => settle(tabbed, b, &breaks),
_ => (j, false),
};
(end, hy)
}
}
/// Can glyph `g` hang outside the line (burasagari)? An East Asian comma or full stop, full width
+59 -18
View File
@@ -1,7 +1,7 @@
use super::bidi::{is_rtl, para_bidi_with, visual_order};
use super::breaking::{
Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share,
roman_share, set_width, squeeze_em, tab_advance, word_width,
Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, drawn_after_tab, drawn_width, hangs, leader_outline, optical_hangs, optical_hangs_rtl,
optical_punct, optical_share, roman_share, squeeze_em, tab_advance, word_width,
};
use super::region::Region;
use super::vertical::{
@@ -342,6 +342,8 @@ fn prepare_paragraph<'a, 'text>(
struct PlacementContext<'a> {
para: &'a ParaStyle,
pr: &'a Range<usize>,
/// [`ParagraphData::bidi`].
bidi: Option<&'a BidiInfo<'a>>,
sg: &'a mut [SGlyph],
/// [`ParagraphData::line_end`].
line_end: &'a [f64],
@@ -376,6 +378,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
let PlacementContext {
para,
pr,
bidi,
sg,
line_end,
continued,
@@ -427,11 +430,32 @@ 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 start = *i;
let glyphs: &[SGlyph] = sg;
// A horizontal line with right-to-left text is drawn in another order than its
// text's, and its tabs take their stops in the order they are drawn: whether the line
// ending before glyph `end` fits is measured as it is drawn ([`drawn_width`]).
let drawn_fits = |end: usize, hyphenated: bool| {
let mut trimmed = end;
while trimmed > start && glyphs.get(trimmed - 1).is_some_and(|g| g.is_space() || g.ch == '\u{2028}') {
trimmed -= 1;
}
let bytes = glyphs.get(start).map_or(pr.start, |g| g.byte)..glyphs.get(end).map_or(pr.end, |g| g.byte);
let drawn: Vec<usize> = visual_order(*bidi, pr.start, bytes, glyphs.get(start..end).unwrap_or_default())
.into_iter()
.map(|offset| start + offset)
.filter(|&j| j < trimmed)
.collect();
let hyphen = if hyphenated { end.checked_sub(1).and_then(|k| glyphs.get(k)).map_or(0.0, |g| hyphen_glyph(g).adv) } else { 0.0 };
let credit = trimmed.checked_sub(1).filter(|&k| k >= start).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0);
drawn_width(glyphs, &drawn, &para.tabs, ind_l) + hyphen - credit <= width + EPS
};
let fits: Option<&dyn Fn(usize, bool) -> bool> = (!cx.vertical && bidi.is_some_and(|b| b.has_rtl())).then_some(&drawn_fits);
let line = |ty| {
break_line(
cx.text,
sg,
*i,
glyphs,
start,
width,
&para.tabs,
ind_l,
@@ -442,6 +466,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
*hanging,
cx.vertical,
line_end,
fits,
)
};
let (end, hyph) = line(para.kinsoku_type);
@@ -498,6 +523,14 @@ struct LineGeometry {
last_of_para: bool,
}
impl LineGeometry {
/// The line's glyphs before its trailing spaces (indices into the paragraph's glyphs), in the
/// order they are drawn: the glyphs its tabs are measured over.
fn measured(&self) -> Vec<usize> {
self.order.iter().map(|&offset| self.index + offset).filter(|&j| j < self.trimmed).collect()
}
}
struct LineJustification {
per_space: f64,
per_gap: f64,
@@ -538,6 +571,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
let mut placement = PlacementContext {
para: paragraph.para,
pr: &paragraph.pr,
bidi: paragraph.bidi.as_ref(),
sg: &mut paragraph.sg,
line_end: &paragraph.line_end,
continued: paragraph.continued,
@@ -609,10 +643,13 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
.checked_sub(1)
.filter(|&k| k >= index && rules.trims_line_end())
.map_or(0.0, |k| line_end_trim(sg, &paragraph.compressed, &paragraph.wakan, rules, 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);
let order = visual_order(paragraph.bidi.as_ref().filter(|_| !cx.vertical), paragraph.pr.start, line_start..line_end, &sg[index..position.end]);
// The line is as wide as it is drawn: its glyphs before its trailing spaces in drawing order,
// each tab up to its stop from where the glyphs drawn before it end.
let measured: Vec<usize> = order.iter().map(|&offset| index + offset).filter(|&j| j < trimmed).collect();
let measured_width = drawn_width(sg, &measured, &para.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim;
// The glyphs before `stop` in visual order, each with the advance it is set with. A list
// marker does not hang: it is counted with no width.
let visible = |stop: usize| -> (Vec<usize>, Vec<(&SGlyph, f64)>) {
@@ -847,15 +884,16 @@ fn is_tab_fixed(fixed: &[bool], index: usize, glyph: usize) -> bool {
// The natural width of justified area type `line` with a tab, as [`emit_flow_line`] draws it from
// the line's start: its glyphs in drawing order, each tab up to its stop from where the glyphs
// drawn before it end, then the line's hyphen.
// drawn before it end, aligning the glyphs drawn after it, then the line's hyphen.
fn tabbed_natural_width(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, fixed: &[bool]) -> f64 {
let sg = &paragraph.sg;
let start = line.ax0 - line.opt_l;
let mut x = start;
for &j in &line.drawn {
let measured = line.measured();
for (k, &j) in measured.iter().enumerate().filter(|&(_, &j)| j < line.body) {
let Some(g) = sg.get(j) else { continue };
x += if g.ch == '\t' && g.len > 0 && is_tab_fixed(fixed, line.index, j) {
tab_advance(&paragraph.para.tabs, line.position.x0, x + line.opt_l, sg.get(j + 1..line.trimmed).unwrap_or_default()).0
tab_advance(&paragraph.para.tabs, line.position.x0, x + line.opt_l, drawn_after_tab(sg, &measured, k)).0
} else if j + 1 == line.trimmed {
g.adv - line.end_trim
} else {
@@ -1027,8 +1065,9 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing, fixed }
}
/// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop
/// ([`tab_advance`], measured from `tab_origin` less `tab_shift`), or, for the last tab of
/// The advance and leader of tab `glyph_index` of `line`, drawn at `order_index` of
/// [`LineGeometry::order`], at pen position `x`: up to its stop ([`tab_advance`], measured from
/// `tab_origin` less `tab_shift`, aligning the glyphs drawn after it), or, for the last tab of
/// right-aligned area type ([`LineJustification::last_tab`]), up to where the rest of the line
/// ends on the right edge. The tab after a list marker keeps the width `prepend_marker` gave it
/// (`advance`).
@@ -1036,7 +1075,7 @@ fn tab_glyph<'p>(
paragraph: &'p ParagraphData<'_, '_>,
line: &LineGeometry,
justification: &LineJustification,
glyph_index: usize,
(glyph_index, order_index): (usize, usize),
x: f64,
(tab_origin, tab_shift): (f64, f64),
advance: f64,
@@ -1046,7 +1085,8 @@ fn tab_glyph<'p>(
if !sg.get(glyph_index).is_some_and(|g| g.len > 0) {
return (advance, "");
}
let (advance, leader) = tab_advance(&paragraph.para.tabs, tab_origin, x - tab_shift, sg.get(glyph_index + 1..line.trimmed).unwrap_or_default());
let rest = line.order.iter().skip(order_index + 1).map(|&offset| line.index + offset).filter(|&j| j < line.trimmed).filter_map(|j| sg.get(j));
let (advance, leader) = tab_advance(&paragraph.para.tabs, tab_origin, x - tab_shift, rest);
if justification.last_tab != Some(glyph_index) {
return (advance, leader);
}
@@ -1078,13 +1118,14 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
let line_em = sg.get(index..position.end).map_or(0.0, |glyphs| glyphs.iter().map(glyph_em).fold(0.0, f64::max));
// Tab stops are measured from the frame's left edge (point type: the origin).
let tab_origin = if paragraph.regions.is_some() { position.x0 } else { 0.0 };
// A line with tabs is measured as if it started at the left (see `set_width`), then
// A line with tabs is measured as if it started at the left (see `drawn_width`), then
// aligned as a whole: its tabs stop where they were measured, however it is aligned.
let tab_shift = start_x - if paragraph.regions.is_some() { ax0 } else { position.ind_l };
// RTL's logical trailing spaces precede the visible content. Keep them outside
// the aligned content extent, just as LTR trailing spaces extend to its right.
let leading_space_width: f64 =
order.iter().take_while(|&&offset| index + offset >= trimmed).filter_map(|&offset| sg.get(index + offset)).map(|glyph| glyph.adv).sum();
// the aligned content extent, just as LTR trailing spaces extend to its right. A tab among
// them keeps its own width.
let leading = order.iter().take_while(|&&offset| index + offset >= trimmed).count();
let leading_space_width: f64 = order.iter().take(leading).filter_map(|&offset| sg.get(index + offset)).map(|glyph| glyph.adv).sum();
let mut x = start_x - leading_space_width;
let mut x_end = start_x;
// The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph.
@@ -1100,8 +1141,8 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
// Glyph scaling a justified line adds to the glyph (1: none).
let mut scale = 1.0;
let mut leader = "";
if glyph.ch == '\t' {
(advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance);
if glyph.ch == '\t' && order_index >= leading {
(advance, leader) = tab_glyph(paragraph, line, justification, (glyph_index, order_index), x, (tab_origin, tab_shift), advance);
} else if glyph_index < body && !is_tab_fixed(&justification.fixed, index, glyph_index) {
let flex = flow_line_flex(glyph_index, &paragraph.flex, &line.held, &line.hung);
if is_word_space(glyph) {
+232 -26
View File
@@ -97,6 +97,7 @@ fn indic_hyphenation_reaches_both_composers_without_splitting_marks() {
false,
false,
&[],
None,
);
assert_eq!((end, hyphen), (4, true), "{tag}");
let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 };
@@ -169,7 +170,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, &[], None);
assert!(!hyphenated);
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
@@ -183,12 +184,26 @@ 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, &[], None);
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, &[]);
let (second_end, hyphenated) = break_line(
text,
&glyphs,
first_end,
3.0,
&[],
0.0,
None,
Burasagari::None,
Kinsoku::Hard,
KinsokuShoriType::PushOutOnly,
false,
false,
&[],
None,
);
assert!(!hyphenated);
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
@@ -244,7 +259,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, &[], None);
assert!(!hyphen);
assert_eq!(end, 2, "without the mark the line ends after the space");
@@ -253,7 +268,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, &[], None);
assert!(!hyphen);
assert_eq!(end, 3, "the marked words stay together even though they are wider than the line");
@@ -263,10 +278,10 @@ fn no_break_keeps_marked_characters_on_one_line() {
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, &[]);
break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None);
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, &[]);
break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None);
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);
@@ -336,7 +351,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, &[], None).0
};
let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false);
let text = "一二三四五六七八九十一二三四五」六";
@@ -402,7 +417,7 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
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;
|ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[], None).0;
assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」");
assert_eq!(mixed_end(PushOutOnly), 14);
@@ -414,7 +429,7 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
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;
|ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[], None).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
@@ -424,7 +439,7 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
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;
|width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false, &[], None).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");
}
@@ -438,12 +453,198 @@ fn kinsoku_shori_type_counts_no_gap_at_a_format_control() {
let mut glyphs = full_width(text);
glyphs[2].adv = 0.0;
let end = |width: f64| {
break_line(text, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[]).0
break_line(text, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[], None).0
};
assert_eq!(end(54.0), 5, "五 is pushed out with 」");
assert_eq!(end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in");
}
/// A line with a tab that does not fit as drawn ends at the latest break that does, even when an
/// earlier break does not fit.
#[test]
fn a_tabbed_line_ends_at_the_latest_break_that_fits_as_drawn() {
let text = "a\tb c d e";
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect();
let glyphs = synthetic_glyphs(&specs);
// Breaks after the spaces end the line before glyphs 4, 6 and 8; the paragraph ends at 9.
let drawn_fits = |end: usize, _hyphenated: bool| end == 4 || end == 8;
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
let ended =
break_line(text, &glyphs, 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits);
assert_eq!(ended, (8, false));
}
/// Past the breaks a tabbed line measures one by one, it ends at the latest break without a hyphen
/// that fits as drawn, whatever the breaks with a hyphen between them measure.
#[test]
fn a_long_tabbed_line_ends_at_the_latest_plain_break_that_fits_as_drawn() {
// a (0), tab (1), then b, a soft hyphen, b and a space for each u: breaks before 4 + 4u (with a
// hyphen) and 6 + 4u.
let text = format!("a\t{}", "b\u{AD}b ".repeat(400));
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect();
let glyphs = synthetic_glyphs(&specs);
let drawn_fits = |end: usize, hyphenated: bool| !hyphenated && end <= 606;
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
let ended =
break_line(&text, &glyphs, 0, 1e6, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits);
assert_eq!(ended, (606, false));
}
/// A line with a tab that overflows in the order of its text at many clusters stays whole when it
/// fits as drawn.
#[test]
fn a_tabbed_line_that_fits_as_drawn_stays_whole_past_many_clusters() {
use vectorcraft_doc::{TabAlign, TabStop};
let text = format!("a\t{}", "b ".repeat(600));
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 0.1, ch)).collect();
let glyphs = synthetic_glyphs(&specs);
let tabs = [TabStop { position: 500.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
// In the text's order the line is 500 pt and 120 pt of text wide; drawn, it fits.
let drawn_fits = |_end: usize, _hyphenated: bool| true;
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
let ended = break_line(
&text,
&glyphs,
0,
600.0,
&tabs,
0.0,
None,
Burasagari::None,
Kinsoku::None,
KinsokuShoriType::PushOutOnly,
false,
false,
&[],
fits,
);
assert_eq!(ended, (glyphs.len(), false));
}
/// A line of tabs and glyphs without width stops reading once its tabs reach past the frame: the
/// rest of the paragraph is not measured.
#[test]
fn a_tabbed_line_stops_reading_where_its_tabs_reach_past_the_frame() {
let text = format!("a{}", "\tx ".repeat(2000));
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if ch == 'a' { 1.0 } else { 0.0 }, ch)).collect();
let glyphs = synthetic_glyphs(&specs);
let furthest = std::cell::Cell::new(0);
let drawn_fits = |end: usize, _hyphenated: bool| {
furthest.set(furthest.get().max(end));
end <= 4
};
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
let ended =
break_line(&text, &glyphs, 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits);
assert_eq!(ended, (4, false));
assert!(furthest.get() < 100, "measured up to glyph {}", furthest.get());
}
/// A tab to an explicit stop past the frame stops a line from reading on: the rest of the paragraph
/// is not measured.
#[test]
fn a_tab_to_a_stop_past_the_frame_stops_the_line_reading() {
use vectorcraft_doc::{TabAlign, TabStop};
let text = format!("a b\t{}", "x ".repeat(2000));
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if ch == 'x' { 0.0 } else { 1.0 }, ch)).collect();
let glyphs = synthetic_glyphs(&specs);
let tabs = [TabStop { position: 500.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
let furthest = std::cell::Cell::new(0);
let drawn_fits = |end: usize, _hyphenated: bool| {
furthest.set(furthest.get().max(end));
end <= 2
};
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
let ended = break_line(
&text,
&glyphs,
0,
100.0,
&tabs,
0.0,
None,
Burasagari::None,
Kinsoku::None,
KinsokuShoriType::PushOutOnly,
false,
false,
&[],
fits,
);
assert_eq!(ended, (2, false));
assert!(furthest.get() < 100, "measured up to glyph {}", furthest.get());
}
/// A tabbed line that ends in a word with more hyphenation points than are measured one by one ends
/// at the latest point that fits as drawn.
#[test]
fn a_tabbed_line_ends_at_the_latest_hyphenation_point_that_fits_as_drawn() {
let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() };
// a (0), tab (1), then a word of 400 syllables of two glyphs from glyph 2: points before 4, 6, ...
let text = format!("a\t{}", "कि".repeat(400));
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect();
let mut glyphs = synthetic_glyphs(&specs);
for glyph in &mut glyphs {
glyph.hyphen_language = crate::hyphen::Language::from_tag(Some("hi"));
glyph.sx = 0.001;
}
let drawn_fits = |end: usize, _hyphenated: bool| end <= 50;
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
let ended = break_line(
&text,
&glyphs,
0,
600.0,
&[],
0.0,
Some((&opts, 0.0)),
Burasagari::None,
Kinsoku::None,
KinsokuShoriType::PushOutOnly,
false,
false,
&[],
fits,
);
assert_eq!(ended, (50, true));
}
/// A glyph with a negative advance after a tab draws the line short of the tab's stop: a line that
/// fits as drawn stays whole.
#[test]
fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() {
let text = "a\tb";
let glyphs = synthetic_glyphs(&[(0, 1, 1.0, 'a'), (1, 1, 1.0, '\t'), (2, 1, -20.0, 'b')]);
let drawn_fits = |_end: usize, _hyphenated: bool| true;
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
let ended =
break_line(text, &glyphs, 0, 30.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits);
assert_eq!(ended, (3, false));
// Two right tabs can align on the same stop when the text after the second steps back: the
// second tab does not reach the next stop.
use vectorcraft_doc::{TabAlign, TabStop};
let text = "a\tb\tc";
let glyphs = synthetic_glyphs(&[(0, 1, 1.0, 'a'), (1, 1, 1.0, '\t'), (2, 1, 1.0, 'b'), (3, 1, 1.0, '\t'), (4, 1, -10.0, 'c')]);
let tabs = [100.0, 500.0].map(|position| TabStop { position, align: TabAlign::Right, leader: String::new(), align_on: '.' });
let ended = break_line(
text,
&glyphs,
0,
200.0,
&tabs,
0.0,
None,
Burasagari::None,
Kinsoku::None,
KinsokuShoriType::PushOutOnly,
false,
false,
&[],
fits,
);
assert_eq!(ended, (5, false));
}
/// 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.
@@ -455,7 +656,7 @@ fn greedy_wrap_counts_the_narrower_line_end() {
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
break_line(text, glyphs, 0, width, &[], 0.0, None, burasagari, Kinsoku::Hard, PushOutOnly, false, false, line_end, None).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");
@@ -471,7 +672,7 @@ fn greedy_wrap_counts_the_narrower_line_end() {
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
break_line(comma, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, PushOutOnly, false, false, line_end, None).0
};
assert_eq!(end(&[0.0, 0.0, 5.0, 0.0]), 2);
@@ -480,7 +681,7 @@ fn greedy_wrap_counts_the_narrower_line_end() {
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
break_line(pushed, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, line_end, None).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");
@@ -490,7 +691,7 @@ fn greedy_wrap_counts_the_narrower_line_end() {
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
break_line(&pushed, &glyphs, 0, 130.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutFirst, false, false, line_end, None).0
};
let mut bracket = vec![0.0; glyphs.len()];
bracket[11] = 5.0;
@@ -503,8 +704,9 @@ fn greedy_wrap_counts_the_narrower_line_end() {
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;
let end = |line_end: &[f64]| {
break_line(text, &glyphs, 0, 26.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, PushOutOnly, false, false, line_end, None).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");
}
@@ -784,8 +986,9 @@ fn korean(text: &str, anywhere: bool) -> Vec<SGlyph> {
fn korean_words_break_at_spaces_unless_line_break_anywhere() {
use KinsokuShoriType::PushOutOnly;
let text = "가나다 라마바";
let greedy =
|g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0;
let greedy = |g: &[SGlyph], width: f64| {
break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[], None).0
};
let ends = |g: &[SGlyph]| candidates(text, g, None, Kinsoku::Hard).iter().map(|b| b.end).collect::<Vec<_>>();
// 50 pt holds "가나다 라" and not "가나다 라마".
assert_eq!(greedy(&korean(text, false), 50.0), 4, "off: the line ends at the space");
@@ -804,7 +1007,7 @@ fn korean_words_break_at_spaces_unless_line_break_anywhere() {
// A soft return still ends the line.
let soft = "가나\u{2028}다라";
assert_eq!(
break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0,
break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[], None).0,
3
);
}
@@ -825,7 +1028,8 @@ fn korean_words_keep_their_letters_and_break_after_punctuation() {
for g in &mut glyphs[1..3] {
g.no_break = true;
}
let end = break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]).0;
let end =
break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], None).0;
assert_eq!(end, 1, "No Break keeps 나다 together");
}
@@ -852,6 +1056,7 @@ fn korean_words_take_no_hyphen() {
false,
false,
&[],
None,
);
assert_eq!(line, (2, false));
assert!(candidates(text, &glyphs, Some(&opts), Kinsoku::Hard).iter().all(|b| b.hyphen == 0.0));
@@ -897,8 +1102,9 @@ fn bunri_kinshi_keeps_jlreq_pairs_in_both_composers() {
let marked = |text: &str| pairs(text, true).iter().filter(|g| g.keeps_next).count();
assert_eq!((marked("……‥‥〳〵〴〵"), marked("—\u{2028}—"), marked("—〵"), marked("〵〳")), (4, 0, 0, 0));
// 35 pt holds "あい—" and not "あい——".
let greedy =
|g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0;
let greedy = |g: &[SGlyph], width: f64| {
break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[], None).0
};
assert_eq!(greedy(&pairs(text, false), 35.0), 3, "off: the line ends between the dashes");
assert_eq!(greedy(&pairs(text, true), 35.0), 2, "on: both dashes go to the next line");
assert_eq!(breaking::word_width(&pairs("——あ", true), 0, &[], Kinsoku::Hard), 20.0);
@@ -916,7 +1122,7 @@ fn bunri_kinshi_keeps_jlreq_pairs_in_both_composers() {
fn bunri_kinshi_progresses_with_an_overlong_pair_and_no_break() {
use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly};
let line = |text: &str, g: &[SGlyph], i: usize, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| {
break_line(text, g, i, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, false, false, &[]).0
break_line(text, g, i, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, false, false, &[], None).0
};
let text = "——あ";
assert_eq!(line(text, &pairs(text, true), 0, 15.0, Kinsoku::Hard, PushOutOnly), 2, "the pair overflows the line whole");
+128
View File
@@ -540,6 +540,134 @@ fn a_justified_right_to_left_line_with_a_tab_spans_its_measure() {
assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "{}", l.lines[0].x1);
}
/// Right-to-left area type `text` in a frame `width` wide, aligned as `justify`, with tab stops at
/// `stops` (position and alignment).
fn rtl_tabbed(text: &str, width: f64, stops: &[(f64, TabAlign)], justify: Justify) -> TextLayout {
let mut t = area(text, width, justify, no_ranges());
t.para.tabs = stops.iter().map(|&(position, align)| TabStop { position, align, leader: String::new(), align_on: '.' }).collect();
layout(db(), &t)
}
/// Where the glyphs of line `k` that are not spaces start and end, as drawn.
fn drawn_extent(l: &TextLayout, text: &str, k: usize) -> (f64, f64) {
let ink = l.glyphs.iter().filter(|g| g.line == k && !text[g.byte..].starts_with(' '));
ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), g| (lo.min(g.origin.x), hi.max(g.origin.x + g.advance)))
}
/// A right-to-left line with a tab is measured as it is drawn: right aligned (and Auto, which
/// aligns it right) it ends on the frame's right edge, and centered it leaves as much room at
/// each side.
#[test]
fn a_right_to_left_line_with_a_tab_is_aligned_as_it_is_drawn() {
let text = "אב גד\tהו זח";
for justify in [Justify::Right, Justify::Auto, Justify::Center] {
let l = rtl_tabbed(text, 300.0, &[(150.0, TabAlign::Left)], justify);
assert_eq!(l.lines.len(), 1, "{justify:?}");
let (left, right) = drawn_extent(&l, text, 0);
if justify == Justify::Center {
assert!((left - (300.0 - right)).abs() < 1e-6, "{justify:?}: centered as drawn: {left} {right}");
} else {
assert!((right - 300.0).abs() < 1e-6, "{justify:?}: ends on the right edge: {right}");
}
assert!((l.lines[0].x1 - right).abs() < 1e-6, "{justify:?}: the line's record ends where it is drawn: {}", l.lines[0].x1);
}
}
/// Point type: a right-aligned right-to-left line with a tab ends at the origin as drawn.
#[test]
fn right_aligned_right_to_left_point_type_with_a_tab_ends_at_its_origin() {
let text = "אב גד\tהו זח";
let mut t = point(text, no_ranges());
t.para.justify = Justify::Right;
t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
let l = layout(db(), &t);
let (_, right) = drawn_extent(&l, text, 0);
assert!(right.abs() < 1e-6, "ends at the origin: {right}");
}
/// A right-to-left line with a tab breaks where it fits as drawn: in a frame it fits only in the
/// order of its text, it wraps, and every line stays inside the frame.
#[test]
fn a_right_to_left_line_with_a_tab_wraps_when_it_does_not_fit_as_drawn() {
// א (0), ב (2), space (4), ג (5), ד (7), tab (9), ה (10), ו (12), space (14), ז (15), ח (17).
let text = "אב גד\tהו זח";
let wide = rtl_tabbed(text, 1000.0, &[(150.0, TabAlign::Left)], Justify::Left);
let span = |from: usize, to: usize| -> f64 { wide.glyphs.iter().filter(|g| (from..to).contains(&g.byte)).map(|g| g.advance).sum() };
// Drawn, the text before the tab comes after it: the line is 150 pt and that text wide.
let (before, after) = (span(0, 9), span(10, 19));
assert!(before > after + 1.0, "the test needs the text before the tab wider: {before} {after}");
let width = 150.0 + (before + after) / 2.0;
let l = rtl_tabbed(text, width, &[(150.0, TabAlign::Left)], Justify::Right);
assert!(l.lines.len() >= 2, "wraps: {} line(s)", l.lines.len());
for k in 0..l.lines.len() {
let (left, right) = drawn_extent(&l, text, k);
assert!(left >= -1e-6 && right <= width + 1e-6, "line {k} stays in the frame: {left} {right} (frame {width})");
}
}
/// Right, center and decimal stops align the text drawn after the tab, which in a right-to-left
/// line is the text before it.
#[test]
fn right_to_left_tab_stops_align_the_text_drawn_after_them() {
// מ (0), מ (2), tab (4), ו (5), ו (7): drawn, וו comes first and ממ after the tab.
let text = "ממ\tוו";
let mem = |l: &TextLayout| -> (f64, f64) {
l.glyphs
.iter()
.filter(|g| g.byte < 4)
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), g| (lo.min(g.origin.x), hi.max(g.origin.x + g.advance)))
};
let right = rtl_tabbed(text, 300.0, &[(150.0, TabAlign::Right)], Justify::Left);
assert!((mem(&right).1 - 150.0).abs() < 1e-6, "right stop: ends on the stop: {:?}", mem(&right));
let center = rtl_tabbed(text, 300.0, &[(150.0, TabAlign::Center)], Justify::Left);
let (lo, hi) = mem(&center);
assert!(((lo + hi) / 2.0 - 150.0).abs() < 1e-6, "center stop: centered on the stop: {lo} {hi}");
// 1 (0), . (1), 5 (2), tab (3), מ (4), מ (6): drawn, ממ comes first and 1.5 after the tab.
let decimal = rtl_tabbed("1.5\tממ", 300.0, &[(150.0, TabAlign::Decimal)], Justify::Left);
assert!((at(&decimal, 1).origin.x - 150.0).abs() < 1e-6, "decimal stop: the point on the stop: {}", at(&decimal, 1).origin.x);
}
/// Two tabs around a left-to-right run in a right-to-left line: each takes its stop in drawing
/// order, and the right-aligned line ends on the right edge as drawn.
#[test]
fn a_right_to_left_line_with_two_tabs_and_a_latin_run_ends_on_the_right_edge() {
// מ (0), מ (2), tab (4), A B space C D (5..10), tab (10), ו (11), ו (13).
let text = "ממ\tAB CD\tוו";
let l = rtl_tabbed(text, 300.0, &[(80.0, TabAlign::Left), (160.0, TabAlign::Left)], Justify::Right);
assert_eq!(l.lines.len(), 1);
let (_, right) = drawn_extent(&l, text, 0);
assert!((right - 300.0).abs() < 1e-6, "ends on the right edge: {right}");
assert!(at(&l, 5).origin.x < at(&l, 6).origin.x, "the Latin run keeps its order");
}
/// A right-to-left line with a tab and more breaks than are measured as drawn stays one line when
/// it fits the frame.
#[test]
fn a_long_right_to_left_line_with_a_tab_that_fits_stays_one_line() {
let text = format!("א\t{}", "ב ".repeat(400));
let l = rtl_tabbed(&text, 100_000.0, &[(150.0, TabAlign::Left)], Justify::Right);
assert_eq!(l.lines.len(), 1, "one line");
}
/// A right-to-left line with a tab and more breaks than are measured one by one still wraps when
/// it fits only in the order of its text, and every line stays inside the frame.
#[test]
fn a_right_to_left_line_with_a_tab_and_many_breaks_wraps_when_it_does_not_fit_as_drawn() {
// אב גד, a tab, הו, 400 soft hyphens (each a break), a space and זח.
let text = format!("אב גד\tהו{} זח", "\u{AD}".repeat(400));
let wide = rtl_tabbed(&text, 10_000.0, &[(150.0, TabAlign::Left)], Justify::Left);
let span = |from: usize, to: usize| -> f64 { wide.glyphs.iter().filter(|g| (from..to).contains(&g.byte)).map(|g| g.advance).sum() };
let (before, after) = (span(0, 9), span(10, text.len()));
assert!(before > after + 1.0, "the test needs the text before the tab wider: {before} {after}");
let width = 150.0 + (before + after) / 2.0;
let l = rtl_tabbed(&text, width, &[(150.0, TabAlign::Left)], Justify::Right);
assert!(l.lines.len() >= 2, "wraps: {} line(s)", l.lines.len());
for k in 0..l.lines.len() {
let (left, right) = drawn_extent(&l, &text, k);
assert!(left >= -1e-6 && right <= width + 1e-6, "line {k} stays in the frame: {left} {right} (frame {width})");
}
}
/// Point type has no measure to fill: a justified line with a tab keeps its natural width.
#[test]
fn justified_point_type_with_a_tab_keeps_its_width() {
+1
View File
@@ -41,6 +41,7 @@ Window → Help Bar toggles the bottom tool-help strip independently of the stat
The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement.
- File formats and Type core (#6147): `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text before a line's last tab at its stops, from the left edge, and the text after it against the right edge. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`; scores and estimates unchanged
- Type core: a right-to-left line with tabs is measured in the order it is drawn, with tab stops still measured from the left edge. Each tab goes to its stop from where the glyphs drawn before it end, and right, center and decimal stops align the text drawn after the tab. Right, Auto and centered lines are aligned by that width, and area type wraps a line that is too wide when drawn. Evidence: `tests::justify::a_right_to_left_line_with_a_tab_is_aligned_as_it_is_drawn`, `tests::justify::a_right_to_left_line_with_a_tab_wraps_when_it_does_not_fit_as_drawn`, `tests::justify::right_to_left_tab_stops_align_the_text_drawn_after_them`, `tests::justify::a_right_to_left_line_with_two_tabs_and_a_latin_run_ends_on_the_right_edge`, `tests::justify::right_aligned_right_to_left_point_type_with_a_tab_ends_at_its_origin`; scores and estimates unchanged
- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged
- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged
- Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged)