diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index f46c1c23a..2af953157 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -383,6 +383,9 @@ struct LineJustification { start_x: f64, after: Vec>, kashidas: Vec<(usize, f64)>, + /// Right-aligned area type: the line's last tab, which takes the text after it to the right + /// edge (the text before it sits at its stops, from the left edge). + last_tab: Option, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -667,7 +670,14 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin } } let position = &line.position; - let start_x = if justify { + // Right-aligned area type with a tab: the text before the last tab sits at its stops, from the + // left edge, and the text after it against the right edge, as Illustrator's own layout in a + // user's `.ai` sets a price list. Centred and justified lines are aligned as a whole. + let last_tab = (para.justify == Justify::Right && !justify && !paragraph.rtl && paragraph.regions.is_some()) + // A tab ending the line counts: its text sits at the left edge, the tab up to the right one. + .then(|| (line.index..line.position.end).rev().find(|&j| sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0))) + .flatten(); + let start_x = if justify || last_tab.is_some() { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { match align { @@ -682,7 +692,35 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, fit, start_x, after, kashidas } + LineJustification { per_space, per_gap, per_cjk, fit, start_x, after, kashidas, last_tab } +} + +/// 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 +/// 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`). +fn tab_glyph<'p>( + paragraph: &'p ParagraphData<'_, '_>, + line: &LineGeometry, + justification: &LineJustification, + glyph_index: usize, + x: f64, + (tab_origin, tab_shift): (f64, f64), + advance: f64, +) -> (f64, &'p str) { + let sg = ¶graph.sg; + if !sg.get(glyph_index).is_some_and(|g| g.len > 0) { + return (advance, ""); + } + let (advance, leader) = tab_advance(¶graph.para.tabs, tab_origin, x - tab_shift, sg.get(glyph_index + 1..line.trimmed).unwrap_or_default()); + if justification.last_tab != Some(glyph_index) { + return (advance, leader); + } + // The width of what follows up to the line's end (nothing, when the tab ends the line). + let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default(); + let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim }; + ((line.ax1 + line.opt_r - rest - x).max(0.0), leader) } fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { @@ -724,10 +762,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let mut scale = 1.0; let mut leader = ""; if glyph.ch == '\t' { - // The tab after a list marker keeps the width `prepend_marker` gave it. - if glyph.len > 0 { - (advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]); - } + (advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance); } else if glyph_index < body { let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); if is_word_space(glyph) { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e7526849e..a08ac54f6 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -664,12 +664,56 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { } } +/// Right-aligned area type with tabs: the text before the last tab sits at its stops, measured from +/// the frame's left edge, and the text after the last tab against the right edge, as Illustrator's +/// own layout in a user's `.ai` (used locally, not committed) sets a price list: "Name950 +/// Ft1 750 Ft" with a right stop at 269.29 and a left one at 272.13 puts the name at 0, the +/// first price ending at 269.29 and the second ending on the edge. +#[test] +fn right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge() { + use vectorcraft_doc::{TabAlign, TabStop}; + let stop = |position: f64, align: TabAlign| TabStop { position, align, leader: String::new(), align_on: '.' }; + let frame = Rect::new(0.0, 0.0, 336.0, 100.0); + let mut t = area("Name\t950 Ft\t1 750 Ft", style(13.0), frame, Justify::Right); + t.para.tabs = vec![stop(269.29, TabAlign::Right), stop(272.13, TabAlign::Left)]; + let lay = layout(db(), &t); + let glyph = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let end = |byte: usize| glyph(byte).origin.x + glyph(byte).advance; + assert!(glyph(0).origin.x.abs() < 1e-6, "the name starts at the left edge: {}", glyph(0).origin.x); + assert!((end(10) - 269.29).abs() < 1e-6, "the first price ends on its right stop: {}", end(10)); + let last = t.plain_text().len() - 1; + assert!((end(last) - 336.0).abs() < 1e-6, "the last price ends on the edge: {}", end(last)); + // One tab: the text after it is against the edge, whatever its stop. + let mut one = area("Cseles Spritz\t2 350 Ft", style(13.0), frame, Justify::Right); + one.para.tabs = t.para.tabs.clone(); + let lay = layout(db(), &one); + let first = lay.glyphs.first().unwrap(); + let last = lay.glyphs.last().unwrap(); + assert!( + first.origin.x.abs() < 1e-6 && (last.origin.x + last.advance - 336.0).abs() < 1e-6, + "{} {}", + first.origin.x, + last.origin.x + last.advance + ); + // A tab ending the line (a heading "Sör" in the user's file): the text before it at the + // left edge, the tab to the right edge. + let lay = layout(db(), &area("Heading\t\nNext", style(13.0), frame, Justify::Right)); + assert!(lay.glyphs.first().unwrap().origin.x.abs() < 1e-6, "{}", lay.glyphs.first().unwrap().origin.x); + let tab = lay.glyphs.iter().find(|g| g.byte == 7).unwrap(); + assert!((tab.origin.x + tab.advance - 336.0).abs() < 1e-6, "the tab ends at {}", tab.origin.x + tab.advance); + // Without a tab, the line is right aligned as a whole. + let lay = layout(db(), &area("Name", style(13.0), frame, Justify::Right)); + assert!(lay.glyphs.first().unwrap().origin.x > 200.0); +} + #[test] fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() { use vectorcraft_doc::{TabAlign, TabStop}; // "AB" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line // moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457). - for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] { + // (Right aligned area type sets the text after its last tab against the edge instead: see + // `right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`.) + for justify in [Justify::Center, Justify::JustifyCenter, Justify::JustifyRight] { let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify); t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; let lay = layout(db(), &t); diff --git a/docs/type-parity.md b/docs/type-parity.md index 6100f028a..5b0ca35a5 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (a soft return in justified type no longer keeps its line from being justified; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -45,7 +45,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). 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` | | 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 | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). 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. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | -| 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); Ctrl-dragging a stop (Command on macOS) moves all the stops together | +| 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); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | @@ -79,6 +79,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Tabs: in right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none, when the tab ends the line) against the right edge; the line was aligned as a whole, which moved its start off the left edge. Illustrator's own layout in a user's `.ai` (used locally, not committed) sets its price lists so. Centred and justified lines keep their alignment as a whole. Scores and estimates unchanged. | | 2026-11-07 | minor | Soft return: in Justify Left, Center or Right, the line a soft return ends is justified as a wrapped line is; only the paragraph's last line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not committed) justifies those lines. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged |