Type: Optical Margin Alignment hangs punctuation in right-to-left paragraphs

Optical Margin Alignment and Roman Hanging Punctuation left every
right-to-left paragraph of point and area type, and type on a path
whose first paragraph runs right to left, as it was: the quotation
marks, full stops and commas at the ends of its lines stayed inside
the margins.

A right-to-left line now hangs at the margins its alignment places it
against, as a left-to-right line does. Auto aligns a right-to-left
line at its right, so it hangs there. The line is read from right to
left: where it hangs at its left margin, a full stop and the quotation
mark after it that end the line hang together. In vertical type,
where the characters of a right-to-left paragraph run from the top
down, the pair is read from the top.

Type on a path whose first paragraph runs right to left hangs at its
brackets by the same rule. In horizontal type, Auto places it at the
end bracket, so it hangs there. On a closed path, its hang at the end
bracket gets the space between the end and start brackets before its
hang at the start bracket does, as in right-aligned type.

A justified right-to-left line of one word is spread between its
letters as they are drawn. The gap after each letter went to the
letter that follows it in the text, so the two leftmost letters
touched and the word ended one gap short of the right margin.

Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-21 23:15:07 +07:00
committed by bflatastic
parent 963922beaf
commit 7d6eb3246e
8 changed files with 202 additions and 63 deletions
+51 -31
View File
@@ -1434,6 +1434,15 @@ fn optical_hangs(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f6
(left, right, taken, right_glyphs)
}
/// Works as [`optical_hangs`] does for a right-to-left line, whose `line` is also in visual order.
/// The line is read from right to left: `."` ending it hangs both its glyphs at the left, `'"` starting it hangs both at
/// the right, and the right end takes its glyphs first.
fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f64, usize, usize) {
let reversed: Vec<(&SGlyph, f64)> = line.iter().rev().copied().collect();
let (right, left, right_glyphs, left_glyphs) = optical_hangs(&reversed, share);
(left, right, left_glyphs, right_glyphs)
}
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
/// with the one before it).
@@ -1899,29 +1908,33 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]);
// Optical Margin Alignment: the glyphs at the ends of a left-to-right line hang
// outside its margins by `opt_l` and `opt_r`. The line is aligned and justified in a
// measure that much wider at each end. A left-aligned line then hangs only at its
// left, a right-aligned line hangs only at its right, and centered and justified lines
// hang at both. A line that ends in a burasagari mark hangs only at its left.
// Justification adds no space after a punctuation mark hanging at the left, nor before
// one hanging at the right (`held`). A letter that hangs by part of its width keeps its
// gaps. Roman Hanging Punctuation (a paragraph attribute) hangs the same punctuation
// by the same amounts, and no letters; with Optical Margin Alignment on as well, the
// letters hang too.
let (opt_l, opt_r, held) = if (t.optical_margin || para.roman_hanging) && !rtl {
// The glyphs before the line's trailing spaces (and its burasagari mark), in the order
// they are drawn.
let drawn: Vec<usize> = order.iter().map(|&offset| i + offset).filter(|&j| j < body).collect();
// Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins
// by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much
// wider at each end. A left-aligned line then hangs only at its left, a right-aligned
// line hangs only at its right, and centered and justified lines hang at both; Auto
// aligns a right-to-left line at its right. A right-to-left line is read from right to
// left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its
// left. Justification adds no space after a punctuation mark hanging at the left, nor
// before one hanging at the right (`held`). A letter that hangs by part of its width
// keeps its gaps. Roman Hanging Punctuation (a paragraph attribute) hangs the same
// punctuation by the same amounts, and no letters; with Optical Margin Alignment on as
// well, the letters hang too.
let (opt_l, opt_r, held) = if t.optical_margin || para.roman_hanging {
let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share };
let at: Vec<usize> = order.iter().map(|&offset| i + offset).filter(|&j| j < body).collect();
let mut visible: Vec<(&SGlyph, f64)> =
at.iter().filter_map(|&j| sg.get(j).map(|g| (g, if j + 1 == trimmed { g.adv - end_trim } else { g.adv }))).collect();
drawn.iter().filter_map(|&j| sg.get(j).map(|g| (g, if j + 1 == trimmed { g.adv - end_trim } else { g.adv }))).collect();
if let Some(h) = &hyphen {
visible.push((h, h.adv));
}
let (l, r, left_glyphs, right_glyphs) = optical_hangs(&visible, share);
let (l, r, left_glyphs, right_glyphs) =
if rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) };
let (r, right_glyphs) = if hang.is_some() { (0.0, 0) } else { (r, right_glyphs) };
let n_visible = visible.len();
let punct = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g));
let held: Vec<usize> = at
let held: Vec<usize> = drawn
.iter()
.enumerate()
.filter(|&(k, _)| (k < left_glyphs && punct(k)) || (right_glyphs > 0 && k + 1 + right_glyphs >= n_visible && punct(k + 1)))
@@ -1946,14 +1959,22 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0);
let mut fit = LineFit::default();
let spaces = sg[i..body].iter().filter(|g| g.is_space()).count();
// A gap that justification adds between two glyphs goes into the advance of the one
// drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a
// right-to-left line comes before it in the text.
let mut after: Vec<Option<usize>> = vec![None; body.saturating_sub(i)];
for (&a, &b) in drawn.iter().zip(drawn.iter().skip(1)) {
if let Some(slot) = after.get_mut(a - i) {
*slot = Some(b);
}
}
let drawn_after = |j: usize| j.checked_sub(i).and_then(|k| after.get(k).copied().flatten());
// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the
// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block.
let cjk_gap = |j: usize| {
j + 1 < body
&& !held.contains(&j)
&& sg
.get(j)
.zip(sg.get(j + 1))
!held.contains(&j)
&& drawn_after(j)
.and_then(|k| sg.get(j).zip(sg.get(k)))
.is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)))
};
let cjk_gaps = (i..body).filter(|&j| cjk_gap(j)).count();
@@ -1989,8 +2010,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
fit = fit_line(&line, room - w, 0, 0.0);
let used: f64 = line.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum();
let rest = room - w - used;
let gaps =
(i..body).filter(|&j| j + 1 < body && !held.contains(&j) && sg.get(j + 1).is_some_and(|next| !next.continues_tcy())).count();
let gaps = (i..body)
.filter(|&j| !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy()))
.count();
if gaps > 0 && rest > EPS {
per_gap = rest / gaps as f64;
}
@@ -2052,7 +2074,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
if cjk_gap(j) {
adv += per_cjk;
}
} else if j + 1 < body && !held.contains(&j) && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
} else if !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy()) {
// Between glyphs, never inside a tate-chu-yoko block (one cell).
adv += per_gap;
}
@@ -2152,14 +2174,11 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
// Type on a path is one line: the first paragraph's attributes align it.
let para = t.para_at(0);
let mut sg = Vec::new();
// The first paragraph's direction aligns the line (Auto) and sets the caret's. `rtl_para` is that
// direction in vertical type too: a right-to-left first paragraph does not hang.
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
let mut rtl = None;
let mut rtl_para = None;
for pr in paras {
let text = cx.text;
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
rtl_para.get_or_insert(is_rtl(bidi.as_ref()));
rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical);
let shaped = cx.shape_para(pr.clone(), bidi.as_ref());
for i in visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, pr.clone(), &shaped) {
@@ -2209,19 +2228,20 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
// stops at the path's end. On a closed path the two hangs together take at most the stretch
// between the end and start brackets: the end where the line is placed takes its share first.
// A centered line's ends each get up to half of it, and either end takes what the other leaves.
// The hangs are measured on the steps the glyphs take where the line is placed.
let hanging = (t.optical_margin || para.roman_hanging) && rtl_para == Some(false);
// The hangs are measured on the steps the glyphs take where the line is placed. A right-to-left
// line is read from right to left ([`optical_hangs_rtl`]); in vertical type `sg` is in text order.
let hanging = t.optical_margin || para.roman_hanging;
let hangs = |steps: &[f64]| {
if !hanging {
return (0.0, 0.0, 0);
}
let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share };
let line: Vec<(&SGlyph, f64)> = sg.iter().zip(steps).map(|(g, &step)| (g, step)).collect();
let (l, r, _, hung_r) = optical_hangs(&line, share);
let (l, r, _, hung_r) = if rtl { optical_hangs_rtl(&line, share) } else { optical_hangs(&line, share) };
let (l, r) = if ap.is_closed() {
let gap = (ap.len() - avail).max(0.0);
match para.justify {
Justify::Right | Justify::JustifyRight => {
j if matches!(j, Justify::Right | Justify::JustifyRight) || (rtl && j == Justify::Auto) => {
let r = r.min(gap);
(l.min(gap - r), r)
}
+113 -14
View File
@@ -935,10 +935,90 @@ fn optical_margin_alignment_hangs_punctuation_and_letter_edges_outside_the_margi
};
let (on, off) = (vertical(true), vertical(false));
assert!((on[1].origin - off[0].origin).hypot() < 1e-6, "{:?} {:?}", on[1].origin, off[0].origin);
// Right-to-left paragraphs are placed as before: a full stop ending one, set at the left of a
// left-aligned line, doesn't hang.
let rtl = |on: bool| lay("שלום, עולם.", Justify::Left, on).iter().map(|g| g.origin).collect::<Vec<_>>();
assert_eq!(rtl(true), rtl(false));
}
/// Right-to-left paragraphs hang at the margins their alignment places their lines against: Auto
/// and right-aligned lines hang at the right, left-aligned lines hang at the left, and justified
/// lines hang at both. The line is read from right to left, so `."` ending it hangs both glyphs at its left, and `".`
/// starting it hangs only the quotation mark at its right. Roman Hanging Punctuation hangs them
/// the same way. In vertical type the characters run from the top down, and the pair is read
/// from the top.
#[test]
fn optical_margin_alignment_hangs_right_to_left_paragraphs() {
let frame = Rect::new(0.0, 0.0, 300.0, 400.0);
let object = |text: &str, justify: Justify, on: bool| {
let mut t = area(text, style(20.0), frame, justify);
t.optical_margin = on;
t
};
let lay = |text: &str, justify: Justify, on: bool| layout(db(), &object(text, justify, on)).glyphs;
let at = |glyphs: &[PositionedGlyph], b: usize| glyphs.iter().find(|g| g.byte == b).cloned().unwrap();
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let end = |g: &PositionedGlyph| g.origin.x + g.advance;
let alef = 'א'.len_utf8();
// Auto aligns the line at the right: the opening quotation mark (logically first, drawn at the
// right end) hangs wholly past the right edge, and the letter after it ends at the edge.
let text = "\"שלום\" עולם";
let (on, off) = (lay(text, Justify::Auto, true), lay(text, Justify::Auto, false));
assert!(off.iter().all(|g| g.rtl) && near(end(&at(&off, 0)), 300.0), "{off:?}");
assert!(near(at(&on, 0).origin.x, 300.0) && at(&on, 0).advance > 1.0, "{:?}", at(&on, 0).origin);
assert!(near(end(&at(&on, 1)), 300.0), "{}", end(&at(&on, 1)));
// Left aligned: the full stop ending the line is drawn at its left and hangs there.
let text = "שלום, עולם.";
let stop = text.len() - 1;
let (on, off) = (lay(text, Justify::Left, true), lay(text, Justify::Left, false));
assert!(near(off[0].origin.x, 0.0) && near(at(&on, stop).origin.x, -at(&on, stop).advance), "{:?}", at(&on, stop).origin);
// Justified: `."` ending the line hangs both glyphs at the left; the right end doesn't hang.
let text = "שלום עולם.\"";
let (quote, stop) = (text.len() - 1, text.len() - 2);
let on = lay(text, Justify::JustifyAll, true);
let (q, s) = (at(&on, quote), at(&on, stop));
assert!(near(q.origin.x, -q.advance - s.advance) && near(s.origin.x, -s.advance), "{:?} {:?}", q.origin, s.origin);
assert!(near(end(&at(&on, 0)), 300.0), "{}", end(&at(&on, 0)));
// A justified line of one word, spread between its letters: the opening quotation mark hangs
// wholly past the right edge, the letter after it ends at the edge, and the last letter starts
// at the left edge.
let on = lay("\"שלום", Justify::JustifyAll, true);
let mem = 1 + 3 * alef;
assert!(near(at(&on, 0).origin.x, 300.0) && near(end(&at(&on, 1)), 300.0), "{:?} {:?}", at(&on, 0).origin, at(&on, 1).origin);
assert!(near(at(&on, mem).origin.x, 0.0), "{:?}", at(&on, mem).origin);
// `".` starting a right-aligned line: the quotation mark hangs and the full stop doesn't.
let on = lay("\".שלום", Justify::Right, true);
assert!(near(at(&on, 0).origin.x, 300.0) && near(end(&at(&on, 1)), 300.0), "{:?} {:?}", at(&on, 0).origin, at(&on, 1).origin);
// A wrapped left-aligned line: the comma ending it hangs, and the space after it stays to its left.
let first = width(&point("אבגד, ", style(20.0)));
let mut t = area("אבגד, הוזח", style(20.0), Rect::new(0.0, 0.0, first + 1.0, 400.0), Justify::Left);
t.optical_margin = true;
let l = layout(db(), &t);
let (comma, space) = (at(&l.glyphs, 4 * alef), at(&l.glyphs, 4 * alef + 1));
assert!(l.lines.len() == 2 && near(comma.origin.x, -comma.advance), "{:?} {:?}", l.lines, comma.origin);
assert!(near(end(&space), comma.origin.x), "{:?} {:?}", space.origin, comma.origin);
// Point type: Auto grows the line left from its anchor, and the opening quotation mark hangs
// to the right of the anchor.
let mut t = point("\"שלום", style(20.0));
(t.para.justify, t.optical_margin) = (Justify::Auto, true);
let l = layout(db(), &t);
assert!(near(at(&l.glyphs, 0).origin.x, 0.0) && near(end(&at(&l.glyphs, 1)), 0.0), "{:?}", at(&l.glyphs, 0).origin);
// Roman Hanging Punctuation hangs the full stop the same way.
let mut t = area("שלום, עולם.", style(20.0), frame, Justify::Left);
t.para.roman_hanging = true;
let l = layout(db(), &t);
let stop = "שלום, עולם".len();
assert!(near(at(&l.glyphs, stop).origin.x, -at(&l.glyphs, stop).advance), "{:?}", at(&l.glyphs, stop).origin);
// Vertical type: the letter after the quotation mark takes the mark's place at the column's top.
let vertical = |text: &str, justify: Justify, on: bool| {
let mut t = object(text, justify, on);
t.vertical = true;
layout(db(), &t).glyphs
};
let (on, off) = (vertical("\"שלום", Justify::Left, true), vertical("\"שלום", Justify::Left, false));
assert!((at(&on, 1).origin - at(&off, 0).origin).hypot() < 1e-6, "{:?} {:?}", at(&on, 1).origin, at(&off, 0).origin);
// …and `."` ending a line at the column's bottom hangs both glyphs there.
let text = "שלום.\"";
let (on, off) = (vertical(text, Justify::Right, true), vertical(text, Justify::Right, false));
let (stop, mem) = (text.len() - 2, text.len() - 2 - alef);
let shift = at(&off, stop).advance + at(&off, stop + 1).advance;
assert!(near((at(&on, mem).origin - at(&off, mem).origin).hypot(), shift), "{:?} {:?}", at(&on, mem).origin, at(&off, mem).origin);
}
/// Roman Hanging Punctuation (a paragraph attribute) hangs the punctuation Optical Margin
@@ -985,7 +1065,8 @@ fn roman_hanging_punctuation_hangs_punctuation_but_not_letters() {
/// start bracket, a right-aligned line at the end bracket, a centered line at both, and the
/// justified alignments where they place the line. A mark hanging past the end bracket doesn't
/// overflow. A hang never takes a glyph off an open path's ends, nor past the other bracket of a
/// closed path. Roman Hanging Punctuation hangs path type the same way, without the letters.
/// closed path. Right-to-left type hangs at the bracket Auto places it at and is read from right to
/// left. Roman Hanging Punctuation hangs path type the same way, without the letters.
#[test]
fn optical_margin_alignment_hangs_type_on_a_path_at_its_brackets() {
// A straight path along y = 50 from x = 0 to 500: distance along it is x.
@@ -1099,16 +1180,34 @@ fn optical_margin_alignment_hangs_type_on_a_path_at_its_brackets() {
let origins = |on: bool| closed(Justify::Center, on).iter().map(|g| g.origin).collect::<Vec<_>>();
let (on, off) = (origins(true), origins(false));
assert!(on.iter().zip(&off).all(|(a, b)| (*a - *b).hypot() < 1e-6), "{on:?} {off:?}");
// Right-to-left type on a path is placed as before.
let rtl = |on: bool| lay("שלום, עולם.", Justify::Left, on).glyphs.iter().map(|g| g.origin).collect::<Vec<_>>();
assert_eq!(rtl(true), rtl(false));
// So is vertical type on a path whose paragraph runs right to left.
let vertical_rtl = |on: bool| {
let mut t = on_path("שלום, עולם.", Justify::Right, 0.1, Some(0.9), on);
t.vertical = true;
layout(db(), &t).glyphs.iter().map(|g| g.origin).collect::<Vec<_>>()
// Right-to-left type on a path hangs too. Auto places it at the end bracket, where its opening
// quotation mark, drawn last, hangs wholly past the bracket.
let (on, off) = (lay("\"שלום\" עולם", Justify::Auto, true), lay("\"שלום\" עולם", Justify::Auto, false));
let n = on.glyphs.len();
assert!(near(end(&off.glyphs[n - 1]), 450.0) && near(on.glyphs[n - 1].origin.x, 450.0), "{:?}", on.glyphs[n - 1].origin);
assert!(!on.overflow);
// The line is read from right to left: left aligned, `."` ending it hangs both glyphs before the
// start bracket.
let (on, off) = (lay("עולם.\"", Justify::Left, true), lay("עולם.\"", Justify::Left, false));
assert!(near(off.glyphs[0].origin.x, 50.0) && near(on.glyphs[2].origin.x, 50.0), "{:?}", on.glyphs[2].origin);
// On a closed path, Auto right-to-left type takes the stretch past the end bracket first, as
// right-aligned type does.
let closed_rtl = |on: bool| {
let mut t = on_path("\"שלום\"", Justify::Auto, 0.1, None, on);
t.kind = TextKind::OnPath { path: square.clone(), start: 0.1, end: Some((102.0 - gap) / 1020.0) };
layout(db(), &t).glyphs
};
assert_eq!(vertical_rtl(true), vertical_rtl(false));
let (on, off) = (closed_rtl(true), closed_rtl(false));
assert!(near(off.last().map_or(0.0, end), 102.0 - gap) && near(on.last().map_or(0.0, end), 102.0), "{on:?}");
// Vertical type on a path whose paragraph runs right to left reads from the top: the letter
// after the quotation mark takes the mark's place.
let vertical_rtl = |on: bool| {
let mut t = on_path("\"שלום", Justify::Left, 0.1, Some(0.9), on);
t.vertical = true;
layout(db(), &t).glyphs
};
let (on, off) = (vertical_rtl(true), vertical_rtl(false));
assert!((on[1].origin - off[0].origin).hypot() < 1e-6 && (on[0].origin - off[0].origin).hypot() > 1.0, "{:?} {:?}", on[1].origin, off[0].origin);
// Roman Hanging Punctuation hangs the quotation mark and no letters.
let roman = |text: &str| {
let mut t = on_path(text, Justify::Left, 0.1, Some(0.9), false);
+18
View File
@@ -112,6 +112,24 @@ fn justified_lines_use_word_then_letter_spacing_then_glyph_scaling() {
assert!((at(&l, 2).advance - (space + 10.0 - letters)).abs() < 1e-6);
}
/// Full Justify spreads a right-to-left word between its letters as they are drawn: the word runs
/// from the left edge to the right edge, with the same gap between each two letters.
#[test]
fn a_justified_right_to_left_word_is_spread_between_its_drawn_letters() {
let text = "שלום";
let natural = layout(db(), &point(text, JustificationOptions::default()));
let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default()));
// The letters from left to right: ם, ו, ל, ש.
let mut drawn: Vec<&PositionedGlyph> = l.glyphs.iter().collect();
drawn.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
assert!(drawn.len() == 4 && drawn.iter().all(|g| g.rtl), "{drawn:?}");
let gaps: Vec<f64> = drawn.windows(2).map(|p| p[1].origin.x - p[0].origin.x - at(&natural, p[0].byte).advance).collect();
let last = drawn[3];
assert!(drawn[0].origin.x.abs() < 1e-6, "{:?}", drawn[0].origin);
assert!((last.origin.x + at(&natural, last.byte).advance - 300.0).abs() < 1e-6, "{:?}", last.origin);
assert!(gaps.iter().all(|&g| g > 1.0 && (g - gaps[0]).abs() < 1e-6), "{gaps:?}");
}
/// Single Word Justification: a justified line holding one word is spread between its letters
/// (Full Justify, the default) or aligned as chosen.
#[test]