From b0056a127d41089509b032fde0f60cd64b646c0e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 18:18:23 -0700 Subject: [PATCH] Type: Optical Margin Alignment hangs punctuation at the ends of type on a path Optical Margin Alignment and Roman Hanging Punctuation hung punctuation in point and area type only. Type on a path kept its first and last glyphs inside its brackets with either option on. When Optical Margin Alignment is on, or the first paragraph has Roman Hanging Punctuation on, the glyphs at the ends of left-to-right type on a path now hang outside its brackets by the same amounts, 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. 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 that hangs as outlines, with a warning. Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md. --- crates/doc/src/text.rs | 5 +- crates/engine/src/cmd/typecmd.rs | 2 +- crates/svg/src/export.rs | 23 ++++++ crates/svg/tests/roundtrip.rs | 25 ++++++ crates/text/src/layout.rs | 77 +++++++++++++++-- crates/text/src/tests.rs | 138 +++++++++++++++++++++++++++++++ docs/mcp.md | 16 +++- docs/target-app-parity.md | 1 + docs/type-parity.md | 2 +- 9 files changed, 272 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 91940f4eb..535f81a6c 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1329,8 +1329,9 @@ pub struct TextObject { pub wrap: Vec, /// 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; 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 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. #[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). diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index fa9441150..fd7e45054 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "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 → {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 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}", has_selection, optical_margin_alignment ), diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 1f9f79ac0..433cf02ce 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1890,6 +1890,11 @@ impl Writer<'_> { self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look"); return self.text_outlines(n, t); } + // It cannot hang glyphs outside the start bracket either. + if hangs_on_path(t, &lay) { + self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); + return self.text_outlines(n, t); + } return self.text_on_path(n, t, path, *start); } self.note_fonts(t, &lay); @@ -2164,6 +2169,24 @@ fn aligned(st: &CharStyle) -> bool { st.has_stroke() && st.stroke_align != StrokeAlign::Center } +/// Does type on a path `t`, laid out as `lay`, hang glyphs outside its brackets? It does when +/// Optical Margin Alignment or its first paragraph's Roman Hanging Punctuation is on and its +/// glyphs sit elsewhere with both off. +fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { + if !t.optical_margin && !t.para_at(0).roman_hanging { + return false; + } + let mut flush = t.clone(); + flush.optical_margin = false; + let mut styles = flush.paragraph_styles(); + for p in &mut styles { + p.roman_hanging = false; + } + flush.set_paragraph_styles(styles); + let flush = vectorcraft_text::layout(FontDb::global(), &flush); + lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) +} + /// What the `` of a run whose properties are `run` sets over its ``'s `base`: the /// properties that differ, and the initial value of each one `base` sets and the run doesn't. fn run_diff(base: &Props, run: Props) -> Props { diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index f1d14c9af..562d808f8 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -468,6 +468,31 @@ fn text_on_path_options_svg_cannot_express_are_outlined() { } } +/// Type on a path whose punctuation hangs outside its brackets (Optical Margin Alignment or Roman +/// Hanging Punctuation) keeps its look as outlines; with the option on and nothing at its ends to +/// hang, it stays a ``. +#[test] +fn text_on_path_with_hanging_punctuation_is_outlined() { + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((20.0, 150.0)); + curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0)); + for (text, optical, roman, live) in [ + ("“On a curve", true, false, false), + ("“On a curve", false, true, false), + ("On a curve", true, true, true), + ("“On a curve", false, false, true), + ] { + let mut d = Document::new(200.0, 200.0); + let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 14.0, ..CharStyle::default() }); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None }; + t.optical_margin = optical; + t.para.roman_hanging = roman; + let id = d.alloc_id(); + let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default()); + assert_eq!(svg.contains(", paras: &[Range], 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. + // 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. 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) { @@ -2197,16 +2200,71 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 }; let mut steps = path_steps(&ap, &sg, s_start, spacing); let w: f64 = steps.iter().sum(); - let s0 = match para.justify { - Justify::Auto if rtl => s_start + (avail - w).max(0.0), - Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0), - Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0), - _ => s_start, + // Optical Margin Alignment and Roman Hanging Punctuation: the brackets are the margins, and the + // glyphs at the line's ends hang outside them by `hang_l` and `hang_r`, measured along the path. + // The line is aligned in a span that much longer at each end, and the glyphs that hang at the + // end (`hung_r` of them) do not make it overflow past the end bracket. On an open path a hang + // 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); + 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) = if ap.is_closed() { + let gap = (ap.len() - avail).max(0.0); + match para.justify { + Justify::Right | Justify::JustifyRight => { + let r = r.min(gap); + (l.min(gap - r), r) + } + Justify::Center | Justify::JustifyCenter => { + let l = l.min(gap - r.min(gap * 0.5)); + (l, r.min(gap - l)) + } + _ => { + let l = l.min(gap); + (l, r.min(gap - l)) + } + } + } else { + (l.min(s_start), r.min((ap.len() - s_start - avail).max(0.0))) + }; + (l, r, hung_r) }; + let place = |(l, r, _): (f64, f64, usize)| { + let (lo, room) = (s_start - l, avail + l + r); + match para.justify { + Justify::Auto if rtl => lo + (room - w).max(0.0), + Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0), + Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0), + _ => lo, + } + }; + let mut hung = hangs(&steps); + let mut s0 = place(hung); if spacing != 0.0 && s0 != s_start { - // Spaced from where the alignment puts them, the curve under them is another. + // Spaced from where the alignment puts them, the curve under them is another. That changes + // the hanging glyphs' steps, so the line is placed again from the new steps until it stays + // where it is (at most 8 times). steps = path_steps(&ap, &sg, s0, spacing); + for _ in 0..8 { + hung = hangs(&steps); + let moved = place(hung); + if (moved - s0).abs() < 1e-9 { + break; + } + s0 = moved; + steps = path_steps(&ap, &sg, s0, spacing); + } } + let (hang_l, hang_r, hung_r) = hung; + let (lo, room) = (s_start - hang_l, avail + hang_l + hang_r); // Align to Path: how far down (glyph space) the type moves to run its ascender, centre or // descender along the path instead of its baseline. let rise = match t.path_align { @@ -2216,9 +2274,10 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path PathAlign::Center => (m.0 - m.1) * 0.5, }; let mut x = 0.0; - for (g, &step) in sg.iter().zip(&steps) { + for (k, (g, &step)) in sg.iter().zip(&steps).enumerate() { let s = s0 + x; - if s + step - s_start > avail + 1e-6 { + let end = if k + hung_r >= sg.len() { room } else { room - hang_r }; + if s + step - lo > end + 1e-6 { cx.out.overflow = true; break; } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 792abd9c4..48b538739 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -981,6 +981,144 @@ fn roman_hanging_punctuation_hangs_punctuation_but_not_letters() { assert!(near(at("“One\n".len()), 0.0), "second doesn't: {}", at("“One\n".len())); } +/// Type on a path hangs at its brackets, measured along the path: a left-aligned line at the +/// 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. +#[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. + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let path = PathData::from_bezpath(&line); + let on_path = |text: &str, justify: Justify, start: f64, end: Option, on: bool| { + let mut t = point(text, style(20.0)); + t.kind = TextKind::OnPath { path: path.clone(), start, end }; + t.para.justify = justify; + t.optical_margin = on; + t + }; + let lay = |text: &str, justify: Justify, on: bool| layout(db(), &on_path(text, justify, 0.1, Some(0.9), on)); + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + let end = |g: &PositionedGlyph| g.origin.x + g.advance; + // Left aligned: the opening quotation mark hangs wholly before the start bracket (x = 50). + let (on, off) = (lay("“Hello", Justify::Left, true), lay("“Hello", Justify::Left, false)); + assert!(near(off.glyphs[0].origin.x, 50.0) && near(on.glyphs[1].origin.x, 50.0), "{:?}", on.glyphs[1].origin); + assert!(near(on.glyphs[0].origin.x, 50.0 - on.glyphs[0].advance) && on.glyphs[0].advance > 1.0); + // Auto, Justify All and Justify with Last Line Aligned Left place the line at the start bracket + // and hang there too. + for justify in [Justify::Auto, Justify::JustifyAll, Justify::JustifyLeft] { + assert!(near(lay("“Hello", justify, true).glyphs[1].origin.x, 50.0), "{justify:?}"); + } + // Right aligned: the full stop hangs past the end bracket (x = 450) without overflowing. + for justify in [Justify::Right, Justify::JustifyRight] { + let (on, off) = (lay("Hello there.", justify, true), lay("Hello there.", justify, false)); + let n = on.glyphs.len(); + assert!(near(end(&off.glyphs[n - 1]), 450.0) && near(on.glyphs[n - 1].origin.x, 450.0), "{justify:?}: {:?}", on.glyphs[n - 1].origin); + assert!(!on.overflow); + } + // A right-aligned line doesn't hang at its start, nor a left-aligned one at its end. + assert!(near(lay("“Hello", Justify::Right, true).glyphs.last().map_or(0.0, end), 450.0)); + assert!(near(lay("Hello.", Justify::Left, true).glyphs[0].origin.x, 50.0)); + // Centered: the letters between the hanging hyphens are centered between the brackets. + for justify in [Justify::Center, Justify::JustifyCenter] { + let on = lay("-ab-", justify, true).glyphs; + assert!(near((on[1].origin.x + end(&on[2])) * 0.5, 250.0), "{justify:?}: {on:?}"); + } + // The hanging full stop is room: a line that overflows by part of its full stop fits with it. + let wide = lay("Hello.", Justify::Left, false).glyphs; + let w: f64 = wide.iter().map(|g| g.advance).sum(); + let tight = |on: bool| layout(db(), &on_path("Hello.", Justify::Left, 0.1, Some((50.0 + w - 0.5 * wide[5].advance) / 500.0), on)); + let (on, off) = (tight(true), tight(false)); + assert!(off.overflow && off.glyphs.len() == 5, "{} {}", off.overflow, off.glyphs.len()); + assert!(!on.overflow && on.glyphs.len() == 6, "{} {}", on.overflow, on.glyphs.len()); + // Only the glyphs that hang take that room: a letter ending past the end bracket by less than + // the overset story's last full stop still overflows. + let story = lay("Hello there.", Justify::Left, false).glyphs; + let past = (end(&story[3]) - 0.5 * story[11].advance) / 500.0; + let overset = |on: bool| layout(db(), &on_path("Hello there.", Justify::Left, 0.1, Some(past), on)); + assert!(overset(true).overflow && overset(true).glyphs.len() == 3, "{}", overset(true).glyphs.len()); + assert_eq!(overset(false).glyphs.len(), 3); + // A hang stops at an open path's first point: no glyph is held there. + let at_start = |on: bool| layout(db(), &on_path("“Hello", Justify::Left, 0.0, None, on)).glyphs; + assert!(near(at_start(true)[0].origin.x, 0.0) && near(at_start(true)[1].origin.x, at_start(false)[1].origin.x)); + let quote = at_start(false)[0].advance; + let part = layout(db(), &on_path("“Hello", Justify::Left, 0.5 * quote / 500.0, None, true)).glyphs; + assert!(near(part[0].origin.x, 0.0), "cut to the room before the bracket: {:?}", part[0].origin); + // Vertical type on the path: the letter after the quotation mark takes the mark's place. + let vertical = |on: bool| { + let mut t = on_path("“Hello", Justify::Left, 0.1, Some(0.9), on); + t.vertical = true; + layout(db(), &t).glyphs + }; + let (on, off) = (vertical(true), vertical(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); + // With Spacing on a curve, the letter after the hanging quotation mark still starts at the + // bracket: the rest of the line is drawn as the same text without the mark. + let mut curve = BezPath::new(); + curve.move_to((20.0, 150.0)); + curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0)); + let spaced = |text: &str, on: bool| { + let mut t = point(text, style(20.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.2, end: None }; + (t.path_spacing, t.optical_margin) = (12.0, on); + layout(db(), &t).glyphs.iter().map(|g| (g.origin, g.angle)).collect::>() + }; + let (on, off) = (spaced("“Hello", true), spaced("Hello", false)); + assert!(on.len() == 6 && on[1..].iter().zip(&off).all(|(a, b)| (a.0 - b.0).hypot() < 1e-6 && (a.1 - b.1).abs() < 1e-6), "{on:?} {off:?}"); + // A closed path whose brackets take all of it leaves no room to hang. + let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1)); + let round = |on: bool| { + let mut t = point("“Around the circle”", style(14.0)); + t.kind = TextKind::OnPath { path: circle.clone(), start: 0.0, end: None }; + t.optical_margin = on; + layout(db(), &t).glyphs.iter().map(|g| g.origin).collect::>() + }; + assert_eq!(round(true), round(false)); + // On a closed path whose brackets leave a stretch narrower than a quotation mark, a + // right-aligned line hangs its closing mark into that stretch; its opening mark doesn't take it. + let mut square = BezPath::new(); + square.move_to((0.0, 0.0)); + square.line_to((500.0, 0.0)); + square.line_to((500.0, 10.0)); + square.line_to((0.0, 10.0)); + square.close_path(); + let square = PathData::from_bezpath(&square); + let gap = 0.5 * quote; + let closed = |justify: Justify, on: bool| { + // 1,020 pt round; the brackets are at 102 and at 102 - gap on the top side. + let mut t = on_path("“Hi”", justify, 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 + }; + let (on, off) = (closed(Justify::Right, true), closed(Justify::Right, false)); + assert!(near(off.last().map_or(0.0, end), 102.0 - gap) && near(on.last().map_or(0.0, end), 102.0), "{on:?}"); + // Centered, each mark hangs into half of it, and the line stays where it was. + let origins = |on: bool| closed(Justify::Center, on).iter().map(|g| g.origin).collect::>(); + 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::>(); + 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::>() + }; + assert_eq!(vertical_rtl(true), vertical_rtl(false)); + // 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); + t.para.roman_hanging = true; + layout(db(), &t).glyphs + }; + assert!(near(roman("“Hello")[1].origin.x, 50.0) && near(roman("AVA")[0].origin.x, 50.0)); + assert!(near(lay("AVA", Justify::Left, true).glyphs[0].origin.x, 50.0 - 0.1 * lay("AVA", Justify::Left, true).glyphs[0].advance)); +} + /// With craft-fonts and no installed fonts (the web), Thai is set in Noto Sans Thai, not as empty /// boxes. Skipped when built without craft-fonts or with a checkout that has no Thai font. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 2560766f1..3b904e828 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3856,7 +3856,13 @@ Left-aligned lines hang at their left margin, right-aligned lines hang at their 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 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 and right-to-left paragraphs are laid out as before. +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. New type and documents from before it have it off, and it is saved only when on. One undo step. ```json @@ -3871,9 +3877,11 @@ paragraphs the punctuation Optical Margin Alignment hangs hangs outside the marg hyphens, full stops and commas wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons by half), 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; type on a path and right-to-left paragraphs 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. +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. ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"romanHangingPunctuation":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b3f9c7e52..6dceee593 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -654,6 +654,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). + 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. 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. diff --git a/docs/type-parity.md b/docs/type-parity.md index 07ec457c6..3ca14a4fb 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -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; type on a path and right-to-left paragraphs 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 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 | | 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) |