diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 59e0d5877..e28682d74 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -297,48 +297,37 @@ pub(crate) fn is_rtl_char(c: char) -> bool { matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF | 0x200F | 0x202B | 0x202E | 0x2067) } -/// Which visual positions of `order` (the logical index at each) a justify gap follows. A gap -/// after logical item k (`after[k]`) opens between logically neighbouring glyphs where they are, -/// or where two runs meet (a right-to-left run is laid out reversed) between the runs, so a gap -/// beside a Hebrew word in Chinese text opens beside the word, not inside it; each opens once. -/// From item `trailing` on (a line's trailing spaces) a gap opens after the item itself. -fn visual_gaps(order: &[usize], after: &[bool], trailing: usize) -> Vec { - let run_min = visual_run_starts(order); - let mut applied = vec![false; after.len()]; +/// Which visual positions of a line's items (in `visual_order(levels)`) a justify gap follows. The +/// gap after logical item k (`after[k]`) is reordered like a glyph of no width at the lower of its +/// two neighbours' levels, as rule N1 resolves a space between them: between logically neighbouring +/// glyphs it opens where they are, and where runs meet it opens beside the run it belongs to, so a +/// gap beside a Hebrew word in Chinese text opens beside the word, not inside it, and so does one +/// beside a Chinese word within that Hebrew. From item `trailing` on (a line's trailing spaces) a +/// gap opens after the item itself. +fn visual_gaps(levels: &[u8], after: &[bool], trailing: usize) -> Vec { let gap_after = |k: usize| after.get(k).copied().unwrap_or(false); - order - .iter() - .enumerate() - .map(|(i, &k)| { + let level = |k: usize| levels.get(k).copied().unwrap_or(0); + // Items at the even indices, the gap after item k at 2k + 1 (a gap's level never exceeds its + // neighbours', so every run reversed starts and ends on an item and the two keep alternating). + let mut both = Vec::with_capacity(after.len() * 2); + for k in 0..after.len() { + if k > 0 { + both.push(level(k).min(level(k - 1))); + } + both.push(level(k)); + } + let order = visual_order(&both); + (0..after.len()) + .map(|i| { + let k = order.get(2 * i).copied().unwrap_or(0) / 2; if k >= trailing { return gap_after(k); } - let at = match order.get(i + 1) { - Some(&n) if n == k + 1 || k == n + 1 => Some(k.min(n)), - Some(_) => run_min.get(i).copied().max(run_min.get(i + 1).copied()).and_then(|m| m.checked_sub(1)), - None => None, - }; - at.is_some_and(|g| gap_after(g) && applied.get_mut(g).is_some_and(|a| !std::mem::replace(a, true))) + order.get(2 * i + 1).is_some_and(|&g| g % 2 == 1 && g / 2 < trailing && gap_after(g / 2)) }) .collect() } -/// For each position of `order` (the logical index at each visual position), the smallest -/// logical index of its run: the visually consecutive glyphs whose logical indices step one way. -fn visual_run_starts(order: &[usize]) -> Vec { - let mut out = Vec::with_capacity(order.len()); - let mut start = 0; - for i in 1..=order.len() { - let joined = i < order.len() && order.get(i - 1).zip(order.get(i)).is_some_and(|(&p, &k)| p + 1 == k || k + 1 == p); - if !joined { - let m = order.get(start..i).and_then(|run| run.iter().copied().min()).unwrap_or(0); - out.extend(std::iter::repeat_n(m, i - start)); - start = i; - } - } - out -} - /// Visual order of a line's items from their levels (rule L2: from the highest level down to the /// lowest odd one, reverse every run at that level or above). pub(crate) fn visual_order(levels: &[u8]) -> Vec { @@ -1396,16 +1385,12 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Right-to-left runs are placed in visual order; trailing spaces take the paragraph's // direction (rule L1). let line_items = items.get(*s..*e).unwrap_or(&[]); - let order: Vec = if bidi.is_some() { - let para_level = u8::from(rtl_line); - let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect(); - visual_order(&levels) - } else { - (0..line_items.len()).collect() - }; + let para_level = u8::from(rtl_line); + let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect(); + let order = visual_order(&levels); // Each gap opens at its visual boundary; trailing spaces widen where they are. let after: Vec = (0..line_items.len()).map(|k| widens(*s + k)).collect(); - let gaps_at = visual_gaps(&order, &after, vis_end - *s); + let gaps_at = visual_gaps(&levels, &after, vis_end - *s); for (i, &k) in order.iter().enumerate() { let Some(it) = line_items.get(k) else { continue }; if is_line_break(it.ch) { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8082ffed3..cbd1e9d4c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -608,12 +608,18 @@ fn justified_ideographs_spread_the_room_between_characters() { }; // Box width cut inside char `fit`, so the first line is chars 0..fit; how many gaps lie before // each of its chars. - let rows: [(&str, usize, &[i32], &str); 5] = [ + let rows: [(&str, usize, &[i32], &str); 6] = [ ("一二三四五六七八九十", 6, &[0, 1, 2, 3, 4, 5], "one gap between each pair of ideographs"), ("一二三。四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none before the full stop, which never starts a line"), ("一二三「四五六七八九", 6, &[0, 1, 2, 3, 3, 4], "none after the opening bracket, which never ends a line"), ("一二三\u{20DD}四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none inside a grapheme cluster: a combining mark stays on its base"), ("漢אב字一二三", 4, &[0, 1, 1, 2], "beside a right-to-left word (laid out ב then א), not inside it"), + ( + "漢\u{202B}אב字一גד\u{202C}文一二三", + 10, + &[0, 1, 4, 4, 2, 3, 1, 1, 1, 5], + "beside a Chinese word embedded in a Hebrew phrase embedded in Chinese (laid out 漢 ד ג 字 一 ב א 文, the embedding marks of no width), and beside the phrase", + ), ]; for (s, fit, gaps_before, why) in rows { let natural = lay_out(s, TextAlignH::Left, None); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 572bb7ffa..c7cef88e1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -297,7 +297,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede break inside Latin words, kinsoku to JIS X 4051 for closing and opening punctuation, small kana (halfwidth too), the prolonged sound mark, the iteration marks, the (semi-)voiced sound marks and the halfwidth parentheses); Thai, Lao, Khmer and Burmese lines break between words (ICU4X's word - dictionaries, its LSTM models in the browser), never after a leading vowel or before a mark; justified text widens each line's spaces but the last line's, and in Chinese, Japanese and Korean text, which has none, the gaps between its characters wherever a line could break (#1970); a style's metrics from its own font, not a fallback; a letter and + dictionaries, its LSTM models in the browser), never after a leading vowel or before a mark; justified text widens each line's spaces but the last line's, and in Chinese, Japanese and Korean text, which has none, the gaps between its characters wherever a line could break, a gap beside a right-to-left word opening beside the word (#1970); a style's metrics from its own font, not a fallback; a letter and its combining marks (Vietnamese typed decomposed, say) from one font that has them all; carets, Delete and Backspace by grapheme cluster; double-click and word moves by script run; IME composition with CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, @@ -648,7 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| -| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | +| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | | 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | | 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | | 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged |