mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
M4: a justify gap is reordered with the line's runs, so a nested embedding gets its gaps at its edges (#1970)
Cause: `visual_gaps` mapped a gap at a run boundary to the gap before the later run's first item, which for a run nested inside another (a Chinese word in a Hebrew phrase in Chinese text, made with embedding marks) is a gap inside the embedding: one boundary took it, another got none, and the line came up a gap short of the box's edge. Fix: each gap is reordered with the line as a glyph of no width at the lower of its two neighbours' levels (rule N1 for a space between them), through the same `visual_order`: items and gaps keep alternating and each gap lands once at the boundary it belongs to. Test row with explicit embedding marks (red before: "char 8 moved 2 (want 1)"). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
44f9eccb71
commit
4cd01f0a04
3 files changed
+36
-45
No files matched your search
+27
-42
@@ -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<bool> {
|
||||
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<bool> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
@@ -1396,16 +1385,12 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
// 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<usize> = if bidi.is_some() {
|
||||
let para_level = u8::from(rtl_line);
|
||||
let levels: Vec<u8> = 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<u8> = 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<bool> = (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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user