From df975e63002255c5ebd15cc1f56282c1f0d10729 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:41:22 -0800 Subject: [PATCH] Justify: a line with a tab spreads the text after its last tab to the edge A justified area type line counted a tab as a word space, so it gave the tab a share of its room that no glyph drew, and the line ended short of its measure. A word-tab-word line ended short too. - The text up to a line's last tab keeps its spacing, each tab keeps its stop and its leader stays in its gap. The text after the last tab takes the line's room: word spacing, letter spacing and glyph scaling within their ranges, then its word spaces, or the gaps between its letters when it has none. Kashidas go in that text when it is one run. - A tab separates two words, so Single Word Justification does not align a word-tab-word line. - Right-to-left lines take the text drawn after the last tab drawn, measured as the line is drawn, and vertical lines work down the column. Point type keeps its natural width. Docs: docs/target-app-parity.md, docs/type-parity.md. --- crates/text/src/layout/flow.rs | 117 +++++++++++++++++++------ crates/text/src/tests/justify.rs | 142 ++++++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + docs/type-parity.md | 2 +- 4 files changed, 232 insertions(+), 30 deletions(-) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index aae91be1ee..a77e05e2cb 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -494,6 +494,9 @@ struct LineJustification { /// The tracking after the line's last character that its alignment leaves out of its width: /// [`LineGeometry::trailing`] for a line that isn't justified, 0 for one that is. trailing: f64, + /// The glyphs justification leaves at their natural advance, by offset from the line's first + /// glyph: on a line with a tab, those drawn up to its last tab ([`tab_fixed_glyphs`]). + fixed: Vec, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -796,10 +799,48 @@ fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option], index: u } } -// A space that takes a share of a justified line's room: not a zero-width space, which is a line -// break opportunity but not a word separator. -fn is_justified_space(g: &SGlyph) -> bool { - g.is_space() && !is_format_control(g.ch) +// The glyphs of a justified line that keep their natural advance, by offset from the line's first +// glyph: on a line with a tab of its own (not a list marker's), every glyph drawn up to and +// including the last such tab, so the tabs keep their stops and leaders. Justification spreads the +// line's room over the glyphs drawn after that tab. Empty on a line without such a tab. +fn tab_fixed_glyphs(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> Vec { + let sg = ¶graph.sg; + let Some(last) = line.drawn.iter().rposition(|&j| j >= paragraph.marker && sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0)) else { + return Vec::new(); + }; + let mut fixed = vec![false; line.body.saturating_sub(line.index)]; + for &j in line.drawn.iter().take(last + 1) { + if let Some(slot) = j.checked_sub(line.index).and_then(|k| fixed.get_mut(k)) { + *slot = true; + } + } + fixed +} + +// Does justification leave glyph `glyph` of the line starting at `index` at its natural advance +// (see [`tab_fixed_glyphs`])? +fn is_tab_fixed(fixed: &[bool], index: usize, glyph: usize) -> bool { + glyph.checked_sub(index).and_then(|k| fixed.get(k)).copied().unwrap_or(false) +} + +// The natural width of justified area type `line` with a tab, as [`emit_flow_line`] draws it from +// the line's start: its glyphs in drawing order, each tab up to its stop from where the glyphs +// drawn before it end, then the line's hyphen. +fn tabbed_natural_width(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, fixed: &[bool]) -> f64 { + let sg = ¶graph.sg; + let start = line.ax0 - line.opt_l; + let mut x = start; + for &j in &line.drawn { + let Some(g) = sg.get(j) else { continue }; + x += if g.ch == '\t' && g.len > 0 && is_tab_fixed(fixed, line.index, j) { + tab_advance(¶graph.para.tabs, line.position.x0, x + line.opt_l, sg.get(j + 1..line.trimmed).unwrap_or_default()).0 + } else if j + 1 == line.trimmed { + g.adv - line.end_trim + } else { + g.adv + }; + } + x - start + line.hyphen.as_ref().map_or(0.0, |h| h.adv) } // May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic, @@ -845,7 +886,11 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin Justify::JustifyAll => (0, true), }; justify &= paragraph.regions.is_some(); - let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).count(); + // A tab keeps its stop: the glyphs drawn up to a line's last tab keep their advances, and the + // ones after it take the line's room. + let fixed = tab_fixed_glyphs(paragraph, line); + let free = |j: usize| !is_tab_fixed(&fixed, line.index, j); + let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && free(j) && is_word_space(&sg[j])).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. @@ -855,10 +900,10 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin *slot = Some(b); } } - let cjk_gaps = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).count(); + let cjk_gaps = (line.index..line.body).filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg)).count(); // Single Word Justification: a justified line holding one word (no word space) is - // spread between its letters (Full Justify) or aligned. - let single_word = justify && spaces == 0 && cjk_gaps == 0; + // spread between its letters (Full Justify) or aligned. A tab separates two words. + let single_word = justify && fixed.is_empty() && spaces == 0 && cjk_gaps == 0; (align, justify) = match (single_word, para.justification.single_word) { (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), (true, SingleWordJustify::AlignLeft) => (0, false), @@ -867,12 +912,24 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin }; // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, // and the word spaces and gaps take what is left. - let kashidas = if justify && !cx.vertical && room > line.render_width + EPS { - kashida::plan(cx.text, sg, line.index..line.body, para.kashida, room - line.render_width, |byte| cx.style_at(byte).kashidas) + // On a line with a tab, the kashidas go in the text after its last tab, when that text is one + // run of characters. + let kashida_range = if fixed.is_empty() { + Some(line.index..line.body) + } else { + let start = (line.index..line.body).find(|&j| free(j)).unwrap_or(line.body); + let end = (line.index..line.body).rev().find(|&j| free(j)).map_or(start, |j| j + 1); + (start..end).all(free).then_some(start..end) + }; + // A justified line with a tab is as wide as it is drawn: in a right-to-left line its tabs are + // drawn in another order than the one the line was measured in. + let natural = if justify && !fixed.is_empty() { tabbed_natural_width(paragraph, line, &fixed) } else { line.render_width }; + let kashidas = if let Some(range) = kashida_range.filter(|r| justify && !cx.vertical && !r.is_empty() && room > natural + EPS) { + kashida::plan(cx.text, sg, range, para.kashida, room - natural, |byte| cx.style_at(byte).kashidas) } else { Vec::new() }; - let width = line.render_width + kashidas.iter().map(|k| k.1).sum::(); + let width = natural + kashidas.iter().map(|k| k.1).sum::(); let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); let mut fit = LineFit::default(); let mut squeeze = 0.0; @@ -882,13 +939,17 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; if pushed_in { let ems: f64 = (line.index..line.body) - .filter(|&j| is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j])) + .filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j])) .map(|j| squeeze_em(&sg[j], cx.vertical)) .sum(); if ems > EPS { squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); } } else if justify && (room - width).abs() > EPS { + // What justification may change of each glyph: nothing of one drawn up to the last tab. + let line_flex = || -> Vec { + (line.index..line.body).map(|j| if free(j) { flow_line_flex(j, ¶graph.flex, held, hung) } else { Flex::default() }).collect() + }; if cjk_gaps > 0 && room > width { // Spread over the CJK gaps and the word spaces alike. per_space = (room - width) / (cjk_gaps + spaces) as f64; @@ -896,18 +957,20 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin } else if spaces > 0 { // Word spacing, letter spacing and glyph scaling within their ranges, then the // word spaces alone; composed lines may shrink word spaces (never below zero). - let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); - let floor = - (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).map(|j| sg[j].adv).fold(f64::MAX, f64::min); + let flex = line_flex(); + let floor = (line.index..line.body) + .filter(|&j| j >= paragraph.marker && free(j) && is_word_space(&sg[j])) + .map(|j| sg[j].adv) + .fold(f64::MAX, f64::min); fit = fit_line(&flex, room - width, spaces, floor); } else if room > width { // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. - let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); + let flex = line_flex(); fit = fit_line(&flex, room - width, 0, 0.0); let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); let rest = room - width - used; let hyphen = line.hyphen.is_some(); - let gaps = (line.index..line.body).filter(|&j| is_letter_gap(j, held, &after, line.index, sg, hyphen)).count(); + let gaps = (line.index..line.body).filter(|&j| free(j) && is_letter_gap(j, held, &after, line.index, sg, hyphen)).count(); if gaps > 0 && rest > EPS { per_gap = rest / gaps as f64; } @@ -939,7 +1002,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing, fixed } } /// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop @@ -1017,22 +1080,20 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let mut leader = ""; if glyph.ch == '\t' { (advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance); - } else if glyph_index < body { + } else if glyph_index < body && !is_tab_fixed(&justification.fixed, index, glyph_index) { let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); if is_word_space(glyph) { - advance += justification.fit.space(&flex); + advance += justification.fit.space(&flex) + justification.per_space; } else { advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex); scale = justification.fit.glyph_scale(&flex); - } - if is_justified_space(glyph) { - advance += justification.per_space; - } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { - if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); + if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { + if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { + advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); + } + } else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) { + advance += justification.per_gap; } - } else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) { - advance += justification.per_gap; } } // The kashida before a cluster in the text is drawn at the cluster's right, between diff --git a/crates/text/src/tests/justify.rs b/crates/text/src/tests/justify.rs index dd80eed25b..8212a652e3 100644 --- a/crates/text/src/tests/justify.rs +++ b/crates/text/src/tests/justify.rs @@ -4,7 +4,7 @@ use crate::*; use kurbo::{Affine, Shape}; use vectorcraft_doc::text::Kashida; -use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TextKind, TextRun}; +use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TabAlign, TabStop, TextKind, TextRun}; use vectorcraft_geom::PathData; fn db() -> &'static FontDb { @@ -412,6 +412,146 @@ fn single_word_lines_follow_single_word_justification() { assert!((c0 - 15.0).abs() < 1e-3 && (c1 - (w - 15.0)).abs() < 1e-3, "centred: {c0}..{c1}"); } +/// Word spacing at 100% with no range: the line's room goes on its word spaces alone. +fn no_ranges() -> JustificationOptions { + JustificationOptions { word_spacing: [100.0, 100.0, 100.0], ..JustificationOptions::default() } +} + +/// `text` in a 300 pt wide frame with left tab stops at `stops` (a leader of `leader` on each). +fn tabbed(text: &str, stops: &[f64], leader: &str, justify: Justify, j: JustificationOptions) -> TextLayout { + let mut t = area(text, 300.0, justify, j); + t.para.tabs = stops.iter().map(|&position| TabStop { position, align: TabAlign::Left, leader: leader.to_string(), align_on: '.' }).collect(); + layout(db(), &t) +} + +/// Where the glyph of the character at byte `b` ends. +fn end(l: &TextLayout, b: usize) -> f64 { + at(l, b).origin.x + at(l, b).advance +} + +/// A justified line with a tab: the text before its last tab keeps its spacing and the tab its +/// stop, and the word spaces after the tab take the line's room, so the line ends on the far edge. +#[test] +fn a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge() { + // A (0), A (1), space (2), B (3), B (4), tab (5), C (6), C (7), space (8), D (9), D (10); a + // soft return ends the line, so Justify Left justifies it. + let text = "AA BB\tCC DD\u{2028}x"; + for j in [no_ranges(), JustificationOptions::default()] { + let natural = tabbed(text, &[150.0], "", Justify::Left, j.clone()); + let l = tabbed(text, &[150.0], "", Justify::JustifyLeft, j); + for b in 0..5 { + assert!((at(&l, b).origin.x - at(&natural, b).origin.x).abs() < 1e-6, "byte {b} stays where it was"); + assert!((at(&l, b).advance - at(&natural, b).advance).abs() < 1e-6, "byte {b} keeps its advance"); + } + assert!((end(&l, 5) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", end(&l, 5)); + assert!((end(&l, 10) - 300.0).abs() < 1e-6, "the last word ends on the edge: {}", end(&l, 10)); + assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "the line's record ends there too: {}", l.lines[0].x1); + assert!(at(&l, 8).advance > at(&natural, 8).advance + 1.0, "the word space after the tab takes the room"); + } +} + +/// A leader stays inside its tab's gap on a justified line. +#[test] +fn a_justified_tab_keeps_its_leader_inside_its_gap() { + let l = tabbed("AA BB\tCC DD", &[150.0], ".", Justify::JustifyAll, no_ranges()); + let tab = at(&l, 5); + let dots = tab.outline.bounding_box(); + assert!(!tab.outline.elements().is_empty(), "the leader is drawn"); + assert!(dots.x0 >= tab.origin.x - 1e-6 && dots.x1 <= 150.0 + 1e-6, "{dots:?} inside {}..150", tab.origin.x); + assert!((end(&l, 10) - 300.0).abs() < 1e-6, "{}", end(&l, 10)); +} + +/// Two words with a tab between them and no word space: the word after the tab is spread +/// between its letters to the edge, whatever Single Word Justification says, since a tab +/// separates two words. +#[test] +fn a_justified_word_tab_word_line_reaches_the_edge() { + // A (0), A (1), tab (2), B (3), B (4). + let text = "AA\tBB\u{2028}x"; + for single_word in [SingleWordJustify::FullJustify, SingleWordJustify::AlignLeft, SingleWordJustify::AlignRight] { + let j = JustificationOptions { single_word, ..JustificationOptions::default() }; + let natural = tabbed(text, &[150.0], "", Justify::Left, j.clone()); + let l = tabbed(text, &[150.0], "", Justify::JustifyLeft, j); + assert!(at(&l, 0).origin.x.abs() < 1e-6, "{single_word:?}: starts at the left edge: {}", at(&l, 0).origin.x); + assert!((at(&l, 0).advance - at(&natural, 0).advance).abs() < 1e-6, "{single_word:?}: the word before the tab keeps its width"); + assert!((end(&l, 2) - 150.0).abs() < 1e-6, "{single_word:?}: the tab keeps its stop: {}", end(&l, 2)); + assert!((end(&l, 4) - 300.0).abs() < 1e-6, "{single_word:?}: ends on the edge: {}", end(&l, 4)); + } +} + +/// The last line of a paragraph: Justify All spreads the text after its tab to the edge, and +/// Justify Left leaves it aligned left. +#[test] +fn the_last_line_with_a_tab_fills_the_measure_only_with_justify_all() { + let text = "AA BB\tCC DD"; + let all = tabbed(text, &[150.0], "", Justify::JustifyAll, no_ranges()); + assert!((end(&all, 5) - 150.0).abs() < 1e-6 && (end(&all, 10) - 300.0).abs() < 1e-6, "{} {}", end(&all, 5), end(&all, 10)); + let left = tabbed(text, &[150.0], "", Justify::JustifyLeft, no_ranges()); + let natural = tabbed(text, &[150.0], "", Justify::Left, no_ranges()); + assert!((end(&left, 10) - end(&natural, 10)).abs() < 1e-6 && end(&left, 10) < 250.0, "{}", end(&left, 10)); +} + +/// A line with two tabs: both keep their stops, the word space between them keeps its width, and +/// the text after the last tab takes the room. +#[test] +fn a_justified_line_takes_its_room_after_its_last_tab() { + // A (0), tab (1), B (2), space (3), C (4), tab (5), D (6), space (7), E (8). + let text = "A\tB C\tD E"; + let natural = tabbed(text, &[80.0, 160.0], "", Justify::Left, no_ranges()); + let l = tabbed(text, &[80.0, 160.0], "", Justify::JustifyAll, no_ranges()); + assert!((end(&l, 1) - 80.0).abs() < 1e-6 && (end(&l, 5) - 160.0).abs() < 1e-6, "{} {}", end(&l, 1), end(&l, 5)); + assert!((at(&l, 3).advance - at(&natural, 3).advance).abs() < 1e-6, "the space between the tabs keeps its width"); + assert!((end(&l, 8) - 300.0).abs() < 1e-6, "{}", end(&l, 8)); +} + +/// Vertical area type: the tab keeps its stop down the column, and the text after it reaches the +/// column's end. +#[test] +fn a_justified_vertical_line_with_a_tab_reaches_the_end_of_its_column() { + let mut t = area("AA BB\tCC DD", 100.0, Justify::JustifyAll, no_ranges()); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 300.0).to_path(0.1)) }; + t.vertical = true; + t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let l = layout(db(), &t); + let down = |b: usize| at(&l, b).origin.y + at(&l, b).advance; + assert!(at(&l, 0).origin.y.abs() < 1e-6, "{}", at(&l, 0).origin.y); + assert!((down(5) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", down(5)); + assert!((down(10) - 300.0).abs() < 1e-6, "the last word ends at the column's end: {}", down(10)); +} + +/// Right-to-left area type: the glyphs drawn up to the last tab drawn keep their advances, the tab +/// its stop, and the text drawn after it reaches the right edge. +#[test] +fn a_justified_right_to_left_line_with_a_tab_spans_its_measure() { + // א (0), ב (2), space (4), ג (5), ד (7), tab (9), ה (10), ו (12), space (14), ז (15), ח (17). + let text = "אב גד\tהו זח"; + let natural = tabbed(text, &[150.0], "", Justify::Right, no_ranges()); + let l = tabbed(text, &[150.0], "", Justify::JustifyAll, no_ranges()); + let mut drawn: Vec<&PositionedGlyph> = l.glyphs.iter().collect(); + drawn.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x)); + assert!(drawn[0].origin.x.abs() < 1e-6, "starts at the left edge: {:?}", drawn[0].origin); + let tab = drawn.iter().position(|g| g.byte == 9).unwrap(); + for g in &drawn[..tab] { + assert!((g.advance - at(&natural, g.byte).advance).abs() < 1e-6, "byte {} keeps its advance", g.byte); + } + assert!((end(&l, 9) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", end(&l, 9)); + let last = drawn.last().unwrap(); + assert!((last.origin.x + last.advance - 300.0).abs() < 1e-6, "ends on the right edge: {}", last.origin.x + last.advance); + assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "{}", l.lines[0].x1); +} + +/// Point type has no measure to fill: a justified line with a tab keeps its natural width. +#[test] +fn justified_point_type_with_a_tab_keeps_its_width() { + let mut t = point("AA BB\tCC DD", no_ranges()); + t.para.justify = Justify::JustifyAll; + t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let l = layout(db(), &t); + t.para.justify = Justify::Left; + let natural = layout(db(), &t); + assert!((end(&l, 10) - end(&natural, 10)).abs() < 1e-6, "{} {}", end(&l, 10), end(&natural, 10)); +} + /// An OpenType (GSUB) Arabic face with a tatweel: craft-fonts' Noto Sans Arabic when the build has /// craft-fonts (`CRAFT_FONTS_DIR`), else an installed one. None skips the test. fn arabic_face(test: &str) -> Option> { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..206365d7f1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -667,6 +667,7 @@ since 2026-10-01. - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy ` copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading () also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. - **Letter spacing and Full Justify between shaped clusters:** Desired Letter Spacing on any line, the letter spacing range of a justified line, and Single Word Justification's Full Justify add space only between shaped clusters, in horizontal, right-to-left and vertical type, so a combining mark stays on its letter and a Devanagari syllable's glyphs stay together. They add no space next to a zero-width format control (a soft hyphen, a bidi mark, embedding, override or isolate, a zero-width space, a joiner or non-joiner, a word joiner, a variation selector, a tag, the byte order mark), so a word wrapped in an override still reaches both edges of a justified line, and a Kinsoku push-in counts no gap at one either. A zero-width space is a line break opportunity but not a word separator ([CSS Text Level 3](https://www.w3.org/TR/css-text-3/#word-separator) does not list it), so a justified line gives it no share of its room. Desired Letter Spacing between the letters on either side of a zero-width space goes after the zero-width space, so a line that breaks there ends at its last letter, and the line breaker does not measure a gap after it. A line that Full Justify spreads and that ends in a drawn hyphen puts the same gap between its last letter and the hyphen. The cluster boundary is VectorCraft's own rule, based on the typographic character unit of [CSS Text Level 3](https://www.w3.org/TR/css-text-3/#typographic-character-unit). Tests `tests::justify::single_word_full_justify_keeps_combining_mark_with_base`, `desired_and_fitted_letter_spacing_skip_shaped_cluster`, `rtl_single_word_cluster_gap_follows_drawn_order`, `vertical_single_word_cluster_gap_follows_drawn_order`, `single_word_full_justify_keeps_conjunct_cluster`, `desired_letter_spacing_skips_bidi_format_controls`, `letter_spacing_and_full_justify_skip_joiners_and_soft_hyphens`, `format_controls_are_the_zero_width_default_ignorables`, `zero_width_space_takes_no_word_space_room`, `desired_letter_spacing_ends_a_line_broken_at_a_zero_width_space_without_a_gap`, `single_word_full_justify_spreads_up_to_the_drawn_hyphen` and `layout::wrapping_tests::kinsoku_shori_type_counts_no_gap_at_a_format_control`. Public reference: the help page (). +- **Justified lines with tabs:** a tab is not a word space, so a justified area type line no longer gives it a share of its room that no glyph draws. On a justified line with a tab, the text up to the last tab keeps its spacing, each tab keeps its stop and its leader stays in its gap, and the text after the last tab takes the room (word spaces first, then letter spacing, glyph scaling and the gaps between its letters), so the line ends on the far edge. A tab separates two words, so Single Word Justification does not align a word-tab-word line. Right-to-left lines take the text drawn after the last tab drawn, and vertical lines work down the column. Spreading the room after the last tab is VectorCraft's own rule. Tests `tests::justify::a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge`, `a_justified_tab_keeps_its_leader_inside_its_gap`, `a_justified_word_tab_word_line_reaches_the_edge`, `the_last_line_with_a_tab_fills_the_measure_only_with_justify_all`, `a_justified_line_takes_its_room_after_its_last_tab`, `a_justified_vertical_line_with_a_tab_reaches_the_end_of_its_column`, `a_justified_right_to_left_line_with_a_tab_spans_its_measure` and `justified_point_type_with_a_tab_keeps_its_width`. Public reference: the help pages (, ). - **The text style panel menus' Duplicate and Delete:** in the Character Styles and Paragraph Styles panel menus, Duplicate highlights the copy, as Create New Style highlights the new style, and Delete leaves no style highlighted, as the Delete button does, so Duplicate, Clear Overrides, Redefine and Style Options are disabled until another style is clicked. A highlighted style that is renamed in Style Options or deleted by a command is no longer highlighted either. The tests `panels::text_styles::tests::panel_menu_duplicate_and_delete_update_the_highlighted_style` and `a_highlighted_style_that_is_no_longer_listed_is_not_highlighted` cover this. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. diff --git a/docs/type-parity.md b/docs/type-parity.md index 102a454c3b..b8397e8404 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -48,7 +48,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; 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) | +| 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 lines are still aligned as a whole); in justified area type the text up to a line's last tab keeps its spacing and its tabs their stops, and the text after the last tab takes the line's room to the far edge, VectorCraft's own rule (evidence: `tests::justify::a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge` and the other tab tests in `crates/text/src/tests/justify.rs`) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. Edit › Edit Custom Dictionary… lists the custom dictionary's words, with an Entry field and Add, Delete and Change, applied on Done (Cancel leaves the dictionary as it was). The Check Spelling dialog remains. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click), `crates/ui-egui/src/dialogs/custom_dictionary.rs` (the dialog). | | Smart Punctuation, Change Case, Find & Replace | done | |