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
+2 -3
View File
@@ -1329,9 +1329,8 @@ pub struct TextObject {
pub wrap: Vec<WrapShape>,
/// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted
/// sides of letters such as A and W at a line's start or end hang outside the margin there
/// (point and area type of left-to-right paragraphs, and left-to-right type on a path at its
/// brackets; see the layout's `optical_hangs`). Off for new type and for documents from before
/// it, and saved only when on.
/// (point and area type, and type on a path at its brackets; see the layout's `optical_hangs`).
/// Off for new type and for documents from before it, and saved only when on.
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
pub optical_margin: bool,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
+1 -1
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Optical Margin Alignment",
["Type"],
None,
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both; left-to-right type on a path hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered → {on, ids}",
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both; Auto aligns a right-to-left line at its right; type on a path hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered → {on, ids}",
has_selection,
optical_margin_alignment
),
+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]
+15 -13
View File
@@ -3898,21 +3898,24 @@ for the selected type objects (or `ids`); without `on` it sets the opposite of t
on. Optical Margin Alignment is a setting of the whole thread: the command sets every frame of a selected frame's
thread, and `ids` lists them all. Threading frames gives the thread its first frame's setting, frames taken out of
a thread keep it, and a file whose frames of one thread differ opens with the first frame's setting in all of them.
In every left-to-right paragraph of point and area type, these characters hang outside the margin when they
In every paragraph of point and area type, these characters hang outside the margin when they
start or end a line: quotation marks, hyphens (a hyphenated line's hyphen too), full stops and commas hang wholly;
asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half their width; and A, V, W, Y, v, w
and y hang by a tenth of theirs. A punctuation mark followed by a quotation mark hangs with it.
Left-aligned lines hang at their left margin, right-aligned lines hang at their right margin, and centered and
justified lines hang at both. In vertical type the margins are the top and bottom of a column. The space that
justification adds between glyphs leaves out the gaps next to a hanging punctuation mark. Lines break where they did
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: where it hangs at its left margin, `."` ending it hangs both glyphs. In vertical type the margins are the
top and bottom of a column. The space that justification adds between glyphs leaves out the gaps next to a
hanging punctuation mark. Lines break where they did
without it, and a line of one glyph does not hang. East Asian commas and full stops hang only through burasagari, and
a line that ends in one hangs only at its start. Type on a path hangs at its brackets, measured along the path:
left-aligned, Auto, Justify All and Justify with Last Line Aligned Left type hangs at the start bracket, right-aligned
and Justify with Last Line Aligned Right type hangs at the end bracket, and centered and Justify with Last Line Aligned
Center type hangs at both. Glyphs that hang past the end bracket do not make the type overflow. On an open path a
hang stops at the path's end, and on a closed path the two hangs together take at most the stretch between the end
and start brackets. SVG export writes type on a path whose glyphs hang as outlines, with a warning. Type on a path
whose first paragraph runs right to left, and right-to-left paragraphs of point and area type, are laid out as before.
left-aligned, Justify All and Justify with Last Line Aligned Left type hangs at the start bracket, right-aligned and
Justify with Last Line Aligned Right type hangs at the end bracket, and centered and Justify with Last Line Aligned
Center type hangs at both. Auto type hangs at the bracket it is placed at: the end bracket for horizontal type whose
first paragraph runs right to left, and the start bracket otherwise. Glyphs that hang past the end bracket do not make
the type overflow. On an open path a hang stops at the path's end, and on a closed path the two hangs together take
at most the stretch between the end and start brackets. SVG export writes type on a path whose glyphs hang as
outlines, with a warning.
New type and documents from before it have it off, and it is saved only when on. One undo step.
```json
@@ -3928,10 +3931,9 @@ hyphens, full stops and commas wholly; asterisks, tildes, ellipses, en and em da
on the same sides (left-aligned lines at their left, right-aligned at their right, centered and justified at both), and
letters don't hang. With Optical Margin Alignment on as well, the letters hang too. East Asian punctuation hangs only
through burasagari. When the first paragraph of type on a path has Roman Hanging Punctuation on, the type hangs its
punctuation at its brackets as Optical Margin Alignment does, and no letters. Type on a path whose first paragraph runs
right to left, and right-to-left paragraphs of point and area type, are laid out as before. Off by default, saved with
the paragraph (`roman_hanging` in `para`) only when on, carried by paragraph styles, and turned off by Reset Panel.
One undo step. The menu item is checked while the paragraph the Paragraph panel shows has it on.
punctuation at its brackets as Optical Margin Alignment does, and no letters. Off by default, saved with the paragraph
(`roman_hanging` in `para`) only when on, carried by paragraph styles, and turned off by Reset Panel. One undo step.
The menu item is checked while the paragraph the Paragraph panel shows has it on.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"romanHangingPunctuation":true}}}
+1
View File
@@ -673,6 +673,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.
The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it.
Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object.
Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs.
The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options.
The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting.
In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X.
+1 -1
View File
@@ -35,7 +35,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Fallback glyphs keep the text's weight and slant | done | #2491: a character from a fallback face is set in that family's face nearest the run's weight and slant; Bold Italic of a missing family is drawn Bold (Source Sans 3 has no Bold Italic) rather than Italic |
| Colour fonts (OpenType SVG / COLR) | partial | |
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
| Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, with one setting for all the frames of a thread, in point and area type of left-to-right paragraphs, and at the brackets of type on a path whose first paragraph runs left to right; right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, do not hang |
| Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, with one setting for all the frames of a thread, in point and area type of left-to-right and right-to-left paragraphs, and at the brackets of type on a path |
| Roman Hanging Punctuation | done | Paragraph panel menu › Roman Hanging Punctuation, `text.setFormat {romanHangingPunctuation}` (#2756): per paragraph, Optical Margin Alignment's punctuation and amounts without the letters; paragraph styles carry it. Evidence: `tests::roman_hanging_punctuation_hangs_punctuation_but_not_letters`, `typecmd::area_tests::roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on` |
| Hyphenation… and Justification… options | done | Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Evidence: `crates/text/src/tests_justify.rs`, `dialogs::justification::tests` |
| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877) |