From 95d71a6825477e13a74deddc1f4401ef0a2c0f03 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 4 Nov 2026 03:59:46 -0500 Subject: [PATCH] M4: justified Chinese, Japanese and Korean text spreads the room between its characters (#1970) Cause: justified lines only widened their spaces, so a line of ideographs, written without any, kept its natural width and its right edge never reached the box's. Fix: the room on a line (but a paragraph's last) also goes into the gaps next to an ideograph, kana, Hangul or full-width form wherever a line could break there (`breaks::justify_between`, beside `break_between`): each is a word of its own, as for line breaking, and no gap opens before a closing mark or after an opening one. Spaces widen as before; mixed text widens both. Co-Authored-By: Claude Fable 5.1 --- crates/text/src/breaks.rs | 9 ++++++++ crates/text/src/layout.rs | 14 ++++++++---- crates/text/src/tests.rs | 45 +++++++++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 5 +++-- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 6d094e9dc..f0352f852 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -79,6 +79,15 @@ pub(crate) fn is_space(c: char) -> bool { class(c) == Class::Space } +/// Whether justified text may widen the gap between `a` and the `b` after it (a space widens on +/// its own): next to an ideograph, kana, Hangul or full-width form, which are written without +/// spaces, so each is a word of its own, as for line breaking, wherever a line may break there (no +/// gap before a closing mark or after an opening one). +pub(crate) fn justify_between(a: char, b: char) -> bool { + let (ca, cb) = (class(a), class(b)); + (ca == Class::Ideo || cb == Class::Ideo) && ca != Class::Space && break_between(a, b) +} + /// Whether `c` is in a script written without spaces between words that the line segmenter has /// word data for (UAX #14 class SA): Thai, Lao, Burmese (with the Myanmar extensions) and Khmer. fn writes_no_spaces(c: char) -> bool { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index dd728916b..1cb5170fa 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1259,8 +1259,13 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let hang_r = items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); let gstart = out.glyphs.len(); - // Justify: extra space per gap. - let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); + // Justify: extra space per gap (a space, or between ideographs, whose text has no spaces). + let widens = |k: usize| { + items.get(k).is_some_and(|it| { + it.stretches() || (k + 1 < vis_end && items.get(k + 1).is_some_and(|next| crate::breaks::justify_between(it.ch, next.ch))) + }) + }; + let gaps = (*s..vis_end).filter(|k| widens(*k)).count(); let justify_extra = match (text.align_h, wrap) { (TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => { ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) @@ -1349,13 +1354,14 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } else { (0..line_items.len()).collect() }; - for it in order.iter().filter_map(|k| line_items.get(*k)) { + for k in order.iter().copied() { + let Some(it) = line_items.get(k) else { continue }; if is_line_break(it.ch) { continue; } out.glyphs.extend(glyph_at(&styles, it, pen, baseline)); pen += it.adv; - if it.stretches() { + if widens(*s + k) { pen += justify_extra; } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 6d341419c..e59438c83 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -590,6 +590,51 @@ fn lower_case_writes_final_sigma() { assert_eq!(gids(&t).iter().map(|g| g.0).collect::>(), want.iter().map(|g| g.0).collect::>(), "split runs"); } +/// Chinese is written without spaces: justified, the room goes between its characters (each a word +/// of its own, as for line breaking), so the last one reaches the right edge too, and none opens +/// where a line couldn't break (before a closing mark, after an opening one). "Justify aligns the +/// first word in each line of text to the left-most edge, and the last to the right-most edge." +#[test] +fn justified_ideographs_spread_the_room_between_characters() { + if !have_fonts() { + return; + } + let lay_out = |s: &str, align, w| { + let mut t = TextData::new(s); + t.style.font_size = 20.0; + t.align_h = align; + t.auto_resize = figcraft_doc::TextAutoResize::Height; + layout(&t, w, None) + }; + // Box width cut inside char `fit`, so the first line is chars 0..fit; how many gaps lie before + // each of its chars. + for (s, fit, gaps_before, why) in [ + ("一二三四五六七八九十", 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"), + ] { + let natural = lay_out(s, TextAlignH::Left, None); + let (fits, next) = (natural.caret(fit).0, natural.caret(fit + 1).0); + assert!(next > fits, "{why}: chars need advances"); + let w = fits + (next - fits) / 4.0; + let left = lay_out(s, TextAlignH::Left, Some(w)); + let just = lay_out(s, TextAlignH::Justified, Some(w)); + assert!(just.lines.len() >= 2 && just.lines[0].char_end == fit, "{why}: {:?}", just.lines); + let extra = (w - left.lines[0].width) / f64::from(gaps_before[fit - 1]); + assert!(extra > 0.0, "{why}"); + assert_eq!(left.glyphs.len(), just.glyphs.len()); + for (l, j) in left.glyphs.iter().zip(&just.glyphs).filter(|(g, _)| g.char_end <= fit) { + let want = f64::from(gaps_before[l.char_start]) * extra; + assert!((j.x - l.x - want).abs() < 1e-6, "{why}: char {} moved {} (want {want})", l.char_start, j.x - l.x); + } + let last = just.glyphs.iter().rfind(|g| g.char_end <= fit).unwrap(); + assert!((last.x + last.advance - w).abs() < 1e-6, "{why}: the last char ends at the right edge, not {}", last.x + last.advance); + // The last line is left-aligned, as always. + let (l2, j2) = (left.glyphs.iter().find(|g| g.char_start == fit).unwrap(), just.glyphs.iter().find(|g| g.char_start == fit).unwrap()); + assert!((j2.x - l2.x).abs() < 1e-6, "{why}: the last line is not justified"); + } +} + /// Thai is written without spaces between words; U+200B ZERO WIDTH SPACE marks where a line may /// break. ภาษา|ไทย|เขียน|ติดกัน ("Thai is written joined up") ไม่มี|ช่องว่าง|ระหว่าง|คำ ("with no /// spaces between words"), with one real space between the two phrases. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 75a426231..572bb7ffa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** patch (justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -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; 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 (#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,6 +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-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 |