From 9d52f5d5ca6b4b443ba2b47c5fa58b5fb30ada2a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 19:01:24 +0700 Subject: [PATCH] M4: text behaviour suite: metrics, line height, CJK --- Cargo.lock | 1 + Cargo.toml | 1 + ROADMAP.md | 9 +- crates/doc/src/node.rs | 2 +- crates/doc/src/text.rs | 29 +- crates/engine/src/cmd/props.rs | 3 +- crates/engine/src/codegen.rs | 3 + crates/engine/src/devinspect.rs | 3 + crates/engine/src/inspect.rs | 3 + crates/engine/src/lib.rs | 2 + crates/engine/src/text_behaviour_tests.rs | 185 ++++++++++++ crates/engine/src/textedit.rs | 67 +++-- crates/format/src/rest/tests.rs | 3 +- crates/format/src/rest/text.rs | 3 + crates/text/Cargo.toml | 1 + crates/text/src/behaviour_tests.rs | 343 ++++++++++++++++++++++ crates/text/src/breaks.rs | 95 ++++++ crates/text/src/layout.rs | 111 ++++--- crates/text/src/lib.rs | 4 + crates/ui-egui/locales/en.toml | 4 + crates/ui-egui/locales/es.toml | 4 + crates/ui-egui/src/typography.rs | 23 +- docs/target-app-parity.md | 27 +- docs/ui-parity.md | 2 +- 24 files changed, 833 insertions(+), 95 deletions(-) create mode 100644 crates/engine/src/text_behaviour_tests.rs create mode 100644 crates/text/src/behaviour_tests.rs create mode 100644 crates/text/src/breaks.rs diff --git a/Cargo.lock b/Cargo.lock index cdbb2cb..77352d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1667,6 +1667,7 @@ dependencies = [ "log", "skrifa 0.47.0", "unicode-bidi", + "unicode-segmentation", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 27c61ec..ae1bb3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ krilla = "0.8" skrifa = "0.47" harfrust = "0.13" unicode-bidi = "0.3" +unicode-segmentation = "1.13" linesweeper = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } sha2 = "0.10" diff --git a/ROADMAP.md b/ROADMAP.md index 3838fc8..0c87550 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~28 points and ~195–355 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text behaviour suite: CJK line breaking, vertical trim, carets by grapheme cluster; 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 ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -19,7 +19,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~43%** | estimated: weekly areas 66.8% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~195–355 h** one agent | estimated | -| Remaining to full parity | **~345–666 h** one agent (~92–160 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~343–663 h** one agent (~92–160 h wall-clock with 5 agents) | estimated | ### Readiness by audience @@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 57% (core editor 67%) | 60–115 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 57% (core editor 67%) | 58–112 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | @@ -81,7 +81,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) | | Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) | | Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | -| Text | 85% | 64% | 7–13 | [target-app-parity](docs/target-app-parity.md) | +| Text | 87% | 68% | 5–10 | [target-app-parity](docs/target-app-parity.md) | | Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) | | Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) | | Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) | @@ -190,6 +190,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~345–666 h → ~343–663 h | | 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | | 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index fa30741..8ca35b6 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -705,7 +705,7 @@ impl Node { 0.0 }; let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max); - let glyphs = if let NodeKind::Text(t) = &self.kind { t.path_reach() } else { 0.0 }; + let glyphs = if let NodeKind::Text(t) = &self.kind { t.path_reach() + t.trim_reach() } else { 0.0 }; stroke + fx + self.spread_miter_reach() + glyphs } diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 291f211..e10fc8a 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -158,6 +158,17 @@ pub enum TextAutoResize { Truncate, } +/// Vertical trim: what bounds a text box vertically. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LeadingTrim { + /// The full line height of the first and last lines. + #[default] + None, + /// From the first line's cap height to the last line's baseline. + CapHeight, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum ListStyle { @@ -318,6 +329,10 @@ pub struct TextData { pub paragraph_indent: f64, #[serde(skip_serializing_if = "crate::node::is_default")] pub list: ListStyle, + /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last + /// line's baseline). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub leading_trim: LeadingTrim, /// Max lines when truncating (0 = as many as fit). #[serde(skip_serializing_if = "crate::node::is_default")] pub max_lines: u32, @@ -391,9 +406,19 @@ impl TextData { if self.path.is_none() { return 0.0; } - let size = self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0); let lines = self.characters.lines().count().clamp(1, 10_000) as f64; - size * 1.5 * lines + self.max_size() * 1.5 * lines + } + + /// How far vertically trimmed text paints past its box (descenders below the last baseline, + /// accents above the first cap height): half the largest size; 0 untrimmed. + pub fn trim_reach(&self) -> f64 { + if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } + } + + /// The largest font size used (capped like layout caps it). + fn max_size(&self) -> f64 { + self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) } pub fn char_count(&self) -> usize { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 137daa6..1b18282 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -19,7 +19,7 @@ gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRow absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \ (widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ -listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ +listStyle (none|bulleted|numbered), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; @@ -581,6 +581,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), "listStyle" => text_mut(cmd, doc.get_mut(id)?)?.list = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?, + "leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { let f = features_of(cmd, v)?; diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index cfb11f2..46d5d57 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -370,6 +370,9 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), _ => {} } + if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { + put("text-box", "trim-both cap alphabetic".into()); + } } out } diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 8513ac8..e79262e 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -444,6 +444,9 @@ fn typography(doc: &Document, n: &Node, t: &TextData) -> Section { if t.paragraph_spacing > 0.0 { rows.push(prop("Paragraph spacing", num(t.paragraph_spacing))); } + if t.leading_trim == figcraft_doc::LeadingTrim::CapHeight { + rows.push(prop("Vertical trim", "cap height to baseline")); + } let align = match t.align_h { TextAlignH::Left => "left", TextAlignH::Center => "center", diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index b1c0270..fdc06f1 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -175,6 +175,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if t.style.decoration != TextDecoration::None { o.insert("textDecoration".into(), json!(t.style.decoration)); } + if t.leading_trim != figcraft_doc::LeadingTrim::None { + o.insert("leadingTrim".into(), json!(t.leading_trim)); + } if !t.runs.is_empty() { o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5221f47..5e8eabe 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1315,6 +1315,8 @@ mod svg_import_tests; #[cfg(test)] mod tests; #[cfg(test)] +mod text_behaviour_tests; +#[cfg(test)] mod text_fill_tests; #[cfg(test)] mod text_style_picker_tests; diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs new file mode 100644 index 0000000..709e7c4 --- /dev/null +++ b/crates/engine/src/text_behaviour_tests.rs @@ -0,0 +1,185 @@ +//! Text behaviour through commands (the rules of `figcraft_text`'s behaviour suite that involve +//! editing or settings): caret moves, deletes and word selection by grapheme cluster and by script, +//! vertical trim and paragraph indent through `node.set`, and renders and exports from one layout. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::textedit::{begin, key, select_word}; +use figcraft_doc::{LeadingTrim, NodeId, NodeKind}; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn text(s: &mut Session, chars: &str, props: Value) -> NodeId { + id(s.execute("text.create", &json!({"text": chars, "x": 0, "y": 0, "props": props})).unwrap()) +} + +fn chars(s: &Session, t: NodeId) -> String { + s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters.clone() +} + +fn caret(s: &Session) -> (usize, usize) { + let te = s.ds().unwrap().text_edit.clone().unwrap(); + (te.anchor, te.caret) +} + +fn size(s: &Session, t: NodeId) -> (f64, f64) { + let n = s.doc().unwrap().node(t).unwrap(); + (n.width, n.height) +} + +const FAMILY: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; + +#[test] +fn arrows_and_deletes_step_over_whole_clusters() { + // A user-perceived character (emoji ZWJ sequence, flag, letter + combining mark) is one step. + let mut s = Session::new(); + let src = format!("a{FAMILY}b\u{1F1EF}\u{1F1F5}e\u{0301}"); + let t = text(&mut s, &src, json!({"fontSize": 24})); + begin(&mut s, t, Some(0), false).unwrap(); + // (key, caret after, derivation) + for (k, want, why) in [ + ("Right", 1, "over 'a'"), + ("Right", 6, "over the five-char family emoji"), + ("Right", 7, "over 'b'"), + ("Right", 9, "over the two regional indicators of a flag"), + ("Right", 11, "over e + combining acute"), + ("Right", 11, "the end stays"), + ("Left", 9, "back over é"), + ("Left", 7, "back over the flag"), + ("Left", 6, "back over 'b'"), + ("Left", 1, "back over the family"), + ] { + key(&mut s, k, false, false, false).unwrap(); + assert_eq!(caret(&s), (want, want), "{why}"); + } + // Shift extends by clusters too. + key(&mut s, "Right", true, false, false).unwrap(); + assert_eq!(caret(&s), (1, 6)); + key(&mut s, "Left", false, false, false).unwrap(); + // Delete and Backspace remove a whole cluster. + key(&mut s, "Delete", false, false, false).unwrap(); + assert_eq!(chars(&s, t), format!("ab\u{1F1EF}\u{1F1F5}e\u{0301}")); + key(&mut s, "Right", false, false, true).unwrap(); // to the end + key(&mut s, "Backspace", false, false, false).unwrap(); + assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}"); + key(&mut s, "Backspace", false, false, false).unwrap(); + assert_eq!(chars(&s, t), "ab"); +} + +#[test] +fn word_selection_and_motion_follow_scripts() { + // CJK text has no spaces: a double click or word move takes the run of one script (Han, + // hiragana, katakana, Hangul); Latin words keep their combining marks. Documentation silent on + // dictionary segmentation (a Han compound next to a Han word is one run). + let mut s = Session::new(); + let src = "日本語のテキストです。한국어 단어 cafe\u{0301} ok"; + let t = text(&mut s, src, json!({"fontSize": 24})); + begin(&mut s, t, Some(0), false).unwrap(); + // (double-click index, selection, derivation) + for (at, want, why) in [ + (1, (0, 3), "日本語: the Han run"), + (3, (3, 4), "の: one hiragana"), + (5, (4, 8), "テキスト: the katakana run"), + (9, (8, 10), "です: hiragana"), + (10, (8, 10), "right after です (before 。) selects です"), + (12, (11, 14), "한국어: the Hangul word"), + (17, (15, 17), "단어: up to the space"), + (20, (18, 23), "café with a combining accent is one word"), + ] { + select_word(&mut s, at).unwrap(); + assert_eq!(caret(&s), want, "{why}"); + } + // Word moves stop at the same edges. + crate::textedit::set_caret(&mut s, 0, false).unwrap(); + for want in [3, 4, 8, 10, 14, 17, 23, 26] { + key(&mut s, "Right", false, true, false).unwrap(); + assert_eq!(caret(&s).1, want, "word right"); + } + for want in [24, 18, 15, 11, 8, 4, 3, 0] { + key(&mut s, "Left", false, true, false).unwrap(); + assert_eq!(caret(&s).1, want, "word left"); + } +} + +#[test] +fn ime_composition_with_cjk() { + // Composing shows inline and wraps like committed text; committing types it in one go. + let mut s = Session::new(); + let t = text(&mut s, "x", json!({"fontSize": 24})); + s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 60, "characters": ""}})).unwrap(); + begin(&mut s, t, Some(0), false).unwrap(); + crate::textedit::preedit(&mut s, "にほんごのぶんしょう", None).unwrap(); + let (doc, range, cursor) = crate::textedit::preedit_doc(&s).unwrap(); + assert_eq!((range, cursor), ((0, 10), 10)); + assert_eq!(chars(&s, t), "", "the document waits for the commit"); + let shown = doc.node(t).unwrap(); + if let Some((l, _)) = figcraft_text::layout_node(shown).filter(|(l, _)| !l.glyphs.is_empty()) { + assert!(l.lines.len() > 1, "a long composition wraps in the 60 px box"); + assert!((shown.height - l.height).abs() < 1e-6, "the box grows with it"); + } + crate::textedit::insert(&mut s, "日本語の文章").unwrap(); + assert_eq!(chars(&s, t), "日本語の文章"); + assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none()); +} + +#[test] +fn vertical_trim_through_node_set() { + let mut s = Session::new(); + let t = text(&mut s, "Hello\nworld", json!({"fontSize": 40, "lineHeight": 60})); + let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()) else { return }; + if l.glyphs.is_empty() { + return; + } + let (w, h) = size(&s, t); + assert!((h - 120.0).abs() < 1e-6, "two 60 px lines"); + s.execute("node.set", &json!({"id": t, "props": {"leadingTrim": "capHeight"}})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!(n.kind.text().unwrap().leading_trim, LeadingTrim::CapHeight); + let (l, _) = figcraft_text::layout_node(n).unwrap(); + let cap = l.lines[0].baseline; + // The auto-size box now runs from the first cap height to the last baseline: cap + 60. + assert!((n.height - (cap + 60.0)).abs() < 1e-6 && cap > 10.0 && cap < 40.0, "{} vs {cap}", n.height); + assert_eq!(n.width, w, "the width doesn't change"); + // Descenders hang below the trimmed box: exports reach past it to keep them. + let eb = figcraft_render::export_bounds(s.doc().unwrap(), t).unwrap(); + assert!(eb.y1 > n.height + 10.0, "{eb:?}"); + // The PNG's ink starts at the box's top (cap height at y = 0); SVG and PDF use the same layout. + let pix = figcraft_render::render_node(s.doc().unwrap(), t, 1.0).unwrap(); + let first_ink = (0..pix.height()).find(|y| (0..pix.width()).any(|x| pix.pixel(x, *y).is_some_and(|p| p.alpha() > 64))); + assert!(first_ink.is_some_and(|y| (f64::from(y) + eb.y0).abs() <= 2.0), "{first_ink:?} {eb:?}"); + let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[t], &figcraft_svg::SvgOptions::default()).unwrap(); + assert!(!svg.contains("NaN")); + assert!(figcraft_pdf::export_nodes_pdf(s.doc().unwrap(), &[t]).is_ok()); + // Inspect and code say so; one undo step back; junk is refused. + let css = crate::codegen::css_decls(s.doc().unwrap(), s.doc().unwrap().node(t).unwrap(), &mut Default::default(), (true, true)); + assert!(css.contains("text-box: trim-both cap alphabetic"), "{css}"); + s.undo().unwrap(); + assert_eq!(size(&s, t), (w, h)); + assert!(s.execute("node.set", &json!({"id": t, "props": {"leadingTrim": "sideways"}})).is_err()); + // The REST spelling. + s.execute("node.set", &json!({"id": t, "props": {"leadingTrim": "CAP_HEIGHT"}})).unwrap(); + assert!(matches!(&s.doc().unwrap().node(t).unwrap().kind, NodeKind::Text(td) if td.leading_trim == LeadingTrim::CapHeight)); + assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["leadingTrim"], "capHeight", "inspect reports it"); +} + +#[test] +fn paragraph_indent_only_moves_left_aligned_text() { + let mut s = Session::new(); + let t = text(&mut s, "Indented paragraph", json!({"fontSize": 20, "paragraphIndent": 30})); + let first_x = |s: &Session| { + let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap(); + (l.glyphs.first().map_or(0.0, |g| g.x), l.width) + }; + let (x, w) = first_x(&s); + if w <= 0.0 { + return; + } + assert!((x - 30.0).abs() < 1e-6, "left: indented"); + s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center"}})).unwrap(); + let (x, w2) = first_x(&s); + assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it"); +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 8330406..02d1dee 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -99,26 +99,47 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> { }) } -fn is_word(c: char) -> bool { - c.is_alphanumeric() || c == '_' -} - -fn word_left(chars: &[char], mut i: usize) -> usize { - while i > 0 && chars.get(i - 1).is_some_and(|c| !is_word(*c)) { +/// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word +/// ends where the script changes (Han, hiragana, katakana and Hangul runs are words of their own in +/// text without spaces). Combining marks and voicing marks belong to the char before them. +fn word_class(chars: &[char], mut i: usize) -> u8 { + while i > 0 && chars.get(i).is_some_and(|c| matches!(*c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0x3099..=0x309A | 0xFE20..=0xFE2F)) { i -= 1; } - while i > 0 && chars.get(i - 1).is_some_and(|c| is_word(*c)) { + match chars.get(i).copied() { + Some('\u{3005}' | '\u{3006}' | '\u{3007}' | '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{20000}'..='\u{3FFFF}') => 2, + Some('\u{3041}'..='\u{309F}') => 3, + Some('\u{30A0}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' | '\u{FF66}'..='\u{FF9F}') => 4, + Some('\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}') => 5, + Some(c) if c.is_alphanumeric() || c == '_' => 1, + _ => 0, + } +} + +fn is_word(chars: &[char], i: usize) -> bool { + word_class(chars, i) != 0 +} + +/// The start of the word before `i` (skipping spaces and punctuation first). +fn word_left(chars: &[char], mut i: usize) -> usize { + while i > 0 && !is_word(chars, i - 1) { + i -= 1; + } + let class = word_class(chars, i.saturating_sub(1)); + while i > 0 && word_class(chars, i - 1) == class && class != 0 { i -= 1; } i } +/// The end of the word after `i` (skipping spaces and punctuation first). fn word_right(chars: &[char], mut i: usize) -> usize { let n = chars.len(); - while i < n && chars.get(i).is_some_and(|c| !is_word(*c)) { + while i < n && !is_word(chars, i) { i += 1; } - while i < n && chars.get(i).is_some_and(|c| is_word(*c)) { + let class = word_class(chars, i); + while i < n && word_class(chars, i) == class && class != 0 { i += 1; } i @@ -146,7 +167,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R } else if word { word_left(&chars, a) } else { - a.saturating_sub(1) + figcraft_text::grapheme_step(&t.characters, a, false) }; (from, a) } else { @@ -155,7 +176,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R } else if word { word_right(&chars, a) } else { - (a + 1).min(n) + figcraft_text::grapheme_step(&t.characters, a, true).min(n) }; (a, to) }; @@ -190,8 +211,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R // Plain arrows move on screen through right-to-left runs, by char elsewhere. "Left" | "Right" => match layout.as_ref().and_then(|l| l.visual_step(cur, key == "Right")) { Some(i) => i.min(n), - None if key == "Left" => cur.saturating_sub(1), - None => (cur + 1).min(n), + None => figcraft_text::grapheme_step(&t.characters, cur, key == "Right").min(n), }, "Home" => line_start(&chars, cur), "End" => line_end(&chars, cur), @@ -313,25 +333,22 @@ pub fn select_word(s: &mut Session, index: usize) -> Result<()> { let chars: Vec = text_of(s, te.id)?.characters.chars().collect(); let mut a = index.min(chars.len()); // Double-clicking just after a word's last char (or at the end) selects that word. - if a > 0 && chars.get(a).is_none_or(|c| !is_word(*c)) && chars.get(a - 1).is_some_and(|c| is_word(*c)) { + if a > 0 && !is_word(&chars, a) && is_word(&chars, a - 1) { a -= 1; } let mut b = a; - let class = |c: char| { - if is_word(c) { - 0 - } else if c.is_whitespace() { - 1 - } else { - 2 - } + // Words by script (see `word_class`), then runs of spaces (10) and of punctuation (11). + let class = |i: usize| match word_class(&chars, i) { + 0 if chars.get(i).is_some_and(|c| c.is_whitespace()) => 10, + 0 => 11, + k => k, }; // A word, a run of spaces or a run of punctuation, like the incumbent; newlines stay unselected. - if let Some(k) = chars.get(a).filter(|c| **c != '\n').map(|c| class(*c)) { - while a > 0 && chars.get(a - 1).is_some_and(|c| *c != '\n' && class(*c) == k) { + if let Some(k) = chars.get(a).filter(|c| **c != '\n').map(|_| class(a)) { + while a > 0 && chars.get(a - 1).is_some_and(|c| *c != '\n') && class(a - 1) == k { a -= 1; } - while b < chars.len() && chars.get(b).is_some_and(|c| *c != '\n' && class(*c) == k) { + while b < chars.len() && chars.get(b).is_some_and(|c| *c != '\n') && class(b) == k { b += 1; } } diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 6a1aa0e..31e72e7 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -301,7 +301,7 @@ fn text_styles_and_runs() { "style": {"fontFamily": "Inter", "fontPostScriptName": "Inter-Regular", "fontStyle": "Regular", "fontWeight": 400, "fontSize": 24, "lineHeightPx": 29, "lineHeightUnit": "INTRINSIC_%", "letterSpacing": 0, "textAlignHorizontal": "CENTER", "textAlignVertical": "CENTER", "textAutoResize": "HEIGHT", "textCase": "UPPER", "textDecoration": "UNDERLINE", - "paragraphSpacing": 8, "paragraphIndent": 4, "openTypeFlags": {"LIGA": 0, "ss01": 1}}, + "paragraphSpacing": 8, "paragraphIndent": 4, "leadingTrim": "CAP_HEIGHT", "openTypeFlags": {"LIGA": 0, "ss01": 1}}, // UTF-16 code units: the emoji takes two. "characterStyleOverrides": [0, 1, 1, 2], "styleOverrideTable": {"1": {"fontWeight": 700}, "2": {"fills": [solid(1.0, 0.0, 0.0)], "hyperlink": {"type": "URL", "url": "https://example.com"}}}, @@ -322,6 +322,7 @@ fn text_styles_and_runs() { assert_eq!((td.style.case, td.style.decoration), (TextCase::Upper, TextDecoration::Underline)); assert_eq!((td.align_h, td.align_v, td.auto_resize), (TextAlignH::Center, TextAlignV::Middle, TextAutoResize::Height)); assert_eq!((td.paragraph_spacing, td.paragraph_indent), (8.0, 4.0)); + assert_eq!(td.leading_trim, figcraft_doc::LeadingTrim::CapHeight); assert_eq!(td.features.get("liga"), Some(&false)); assert_eq!(td.features.get("ss01"), Some(&true)); assert_eq!(td.list, ListStyle::Bulleted); diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 6faaa33..a4ee8a6 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -57,6 +57,9 @@ fn paragraph(s: &Value, td: &mut TextData) { td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32); td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0); td.paragraph_indent = len(s, "paragraphIndent").unwrap_or(0.0); + if text(s, "leadingTrim") == Some("CAP_HEIGHT") { + td.leading_trim = figcraft_doc::LeadingTrim::CapHeight; + } if let Some(flags) = s.get("openTypeFlags").and_then(Value::as_object) { for (tag, on) in flags.iter().take(64) { if figcraft_doc::valid_feature_tag(&tag.to_ascii_lowercase()) { diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 44a4af9..273ff12 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -14,4 +14,5 @@ figcraft-doc = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } unicode-bidi = { workspace = true } +unicode-segmentation = { workspace = true } log = { workspace = true } diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs new file mode 100644 index 0000000..1769e2e --- /dev/null +++ b/crates/text/src/behaviour_tests.rs @@ -0,0 +1,343 @@ +//! Text behaviour suite, written from the public documentation of the target app's text settings +//! (and, for line breaking, from UAX #14 and the CJK line-start/line-end rules it describes). Each +//! case gives the rule, the numbers it implies and a one-line derivation. Where the documentation +//! is silent the case says so and pins today's behaviour. +//! +//! Metrics cases read the face actually used (any default face: craft-fonts or the system) and +//! derive the expected numbers from its own ascent, descent, line gap and cap height, so they hold +//! on every machine that has a font; with none they skip. Break and caret rules are font-free. + +use super::*; +use crate::breaks::{break_between, grapheme_starts}; +use figcraft_doc::{LeadingTrim, LetterSpacing, LineHeight, TextAlignH, TextAutoResize, TextData, TextDecoration}; + +/// The default face once system fonts are indexed (a Latin face where the system has one, so CJK +/// and emoji come from fallback faces), or None (skip) on a machine without fonts. +fn face() -> Option> { + scan_system_fonts(); + db().write().unwrap().default_face() +} + +/// A 20 px text in the default face, with the given resize mode. +fn text(s: &str, resize: TextAutoResize) -> TextData { + let mut t = TextData::new(s); + if let Some(f) = face() { + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + } + t.style.font_size = 20.0; + t.auto_resize = resize; + t +} + +/// The chars of each line. +fn line_texts(t: &TextData, l: &TextLayout) -> Vec { + let chars: Vec = t.characters.chars().collect(); + l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::()).collect() +} + +const EPS: f64 = 1e-6; + +#[test] +fn line_height_auto_percent_and_pixels() { + let Some(f) = face() else { return }; + let k = 20.0 / f.upem; + let auto = (f.ascent + f.descent + f.line_gap.max(0.0)) * k; + // (line height, lines, expected height, derivation) + let cases: [(LineHeight, &str, f64, &str); 5] = [ + (LineHeight::Auto, "A", auto, "Auto: the font's own line height (ascent + descent + line gap)"), + (LineHeight::Auto, "A\nB\nC", 3.0 * auto, "every line takes the same Auto height"), + (LineHeight::Percent(150.0), "A\nB", 60.0, "150% of 20 px = 30 px per line, two lines"), + (LineHeight::Pixels(40.0), "A\nB\nC", 120.0, "40 px per line, three lines"), + (LineHeight::Pixels(10.0), "A\nB", 20.0, "a line height below the font size still spaces lines by it"), + ]; + for (lh, s, want, why) in cases { + let mut t = text(s, TextAutoResize::WidthAndHeight); + t.style.line_height = lh; + let l = layout(&t, None, None); + assert!((l.height - want).abs() < EPS, "{why}: {} != {want}", l.height); + } +} + +#[test] +fn leading_is_split_evenly_above_and_below_each_line() { + let Some(f) = face() else { return }; + let k = 20.0 / f.upem; + let (asc, desc) = (f.ascent * k, f.descent * k); + for (lh, why) in [ + (LineHeight::Pixels(40.0), "40 px: half of (40 − ascent − descent) above the first line too"), + (LineHeight::Percent(200.0), "200% = 40 px, same split"), + (LineHeight::Pixels(12.0), "negative leading is split the same way (glyphs overflow both edges)"), + ] { + let mut t = text("A\nB", TextAutoResize::WidthAndHeight); + t.style.line_height = lh; + let l = layout(&t, None, None); + let px = lh.resolve(20.0, 1.0); + for (i, line) in l.lines.iter().enumerate() { + let want = i as f64 * px + (px - asc - desc) / 2.0 + asc; + assert!((line.baseline - want).abs() < EPS, "{why}: line {i} baseline {} != {want}", line.baseline); + } + } +} + +#[test] +fn letter_spacing_percent_and_pixels() { + if face().is_none() { + return; + } + let plain = layout(&text("abc", TextAutoResize::WidthAndHeight), None, None).width; + // (spacing, extra width for 3 chars, derivation) + let cases: [(LetterSpacing, f64, &str); 4] = [ + (LetterSpacing::Percent(10.0), 6.0, "10% of 20 px = 2 px after each of 3 chars (the last one too: documentation silent)"), + (LetterSpacing::Pixels(3.0), 9.0, "3 px after each char"), + (LetterSpacing::Percent(-5.0), -3.0, "negative spacing tightens: −1 px per char"), + (LetterSpacing::Pixels(0.0), 0.0, "0 changes nothing"), + ]; + for (ls, extra, why) in cases { + let mut t = text("abc", TextAutoResize::WidthAndHeight); + t.style.letter_spacing = ls; + let w = layout(&t, None, None).width; + assert!((w - plain - extra).abs() < 1e-3, "{why}: {w} vs {plain} + {extra}"); + } +} + +#[test] +fn paragraph_spacing_and_indent() { + if face().is_none() { + return; + } + let mut t = text("A\nB\nC", TextAutoResize::WidthAndHeight); + t.style.line_height = LineHeight::Pixels(30.0); + t.paragraph_spacing = 10.0; + let l = layout(&t, None, None); + // Between paragraphs only: 3 × 30 + 2 × 10, nothing after the last. + assert!((l.height - 110.0).abs() < EPS, "{}", l.height); + assert!((l.lines[1].top - 40.0).abs() < EPS && (l.lines[2].top - 80.0).abs() < EPS); + + // Indent: the first line of every paragraph, on left-aligned text only. + let words = "one two three four five six seven eight nine ten"; + for (align, want, why) in [ + (TextAlignH::Left, 24.0, "left: the first line starts 24 px in"), + (TextAlignH::Center, 0.0, "documented: indent applies to left-aligned text only"), + (TextAlignH::Right, 0.0, "right-aligned (left-to-right paragraph): no indent"), + (TextAlignH::Justified, 0.0, "justified isn't left-aligned: no indent"), + ] { + let mut t = text(&format!("{words}\n{words}"), TextAutoResize::Height); + t.paragraph_indent = 24.0; + t.align_h = align; + let mut plain = t.clone(); + plain.paragraph_indent = 0.0; + let (l, p) = (layout(&t, Some(160.0), None), layout(&plain, Some(160.0), None)); + let first_x = |l: &TextLayout, li: usize| l.glyphs[l.lines[li].glyph_start].x; + assert!((first_x(&l, 0) - first_x(&p, 0) - want).abs() < 1e-3, "{why}"); + assert!((first_x(&l, 1) - first_x(&p, 1)).abs() < 1e-3, "{why}: wrapped lines don't indent"); + let second_para = l.lines.iter().position(|ln| ln.char_start == words.chars().count() + 1).unwrap(); + let second_plain = p.lines.iter().position(|ln| ln.char_start == words.chars().count() + 1).unwrap(); + assert!((first_x(&l, second_para) - first_x(&p, second_plain) - want).abs() < 1e-3, "{why}: every paragraph"); + } +} + +#[test] +fn resizing_auto_width_auto_height_fixed_and_truncate() { + if face().is_none() { + return; + } + let s = "one two three four five six seven eight"; + // Auto width: one line per paragraph whatever the box. + assert_eq!(layout(&text(s, TextAutoResize::WidthAndHeight), Some(50.0), None).lines.len(), 1); + // Auto height: wraps to the width and grows. + let l = layout(&text(s, TextAutoResize::Height), Some(80.0), None); + assert!(l.lines.len() >= 3 && l.lines.iter().all(|ln| ln.width <= 80.0 + EPS)); + // Fixed: wraps the same, the overflow stays (not truncated). + let fixed = layout(&text(s, TextAutoResize::None), Some(80.0), Some(20.0)); + assert_eq!(fixed.lines.len(), l.lines.len()); + assert!(!fixed.truncated); + // Truncate: lines that don't fit the box are hidden and the last one ends in an ellipsis. + let one = layout(&text(s, TextAutoResize::Truncate), Some(80.0), Some(l.lines[0].height + 1.0)); + assert!(one.truncated && one.lines.len() == 1); + assert!(one.glyphs.last().is_some_and(|g| g.char_start == g.char_end), "ellipsis glyph last"); + // Max lines: the number of lines before it truncates. + let mut t = text(s, TextAutoResize::Truncate); + t.max_lines = 2; + let two = layout(&t, Some(80.0), Some(1000.0)); + assert!(two.truncated && two.lines.len() == 2); +} + +#[test] +fn vertical_trim_cap_height_to_baseline() { + let Some(f) = face() else { return }; + let k = 20.0 / f.upem; + let cap = f.cap_height * k; + for (s, lh, want, why) in [ + ("A", LineHeight::Pixels(40.0), cap, "one line: cap height to baseline = the cap height"), + ("A\nB\nC", LineHeight::Pixels(40.0), cap + 80.0, "first cap height, then the baselines 40 px apart"), + ("A\nB", LineHeight::Auto, cap + (f.ascent + f.descent + f.line_gap.max(0.0)) * k, "Auto line height, same rule"), + ] { + let mut t = text(s, TextAutoResize::WidthAndHeight); + t.style.line_height = lh; + t.leading_trim = LeadingTrim::CapHeight; + let l = layout(&t, None, None); + assert!((l.height - want).abs() < 1e-6, "{why}: {} != {want}", l.height); + assert!((l.lines[0].baseline - cap).abs() < 1e-6, "{why}: first baseline sits a cap height below the top"); + // Trimmed and untrimmed glyphs keep their spacing; only the box edges move. + t.leading_trim = LeadingTrim::None; + let u = layout(&t, None, None); + let shift = u.lines[0].baseline - l.lines[0].baseline; + for (a, b) in u.glyphs.iter().zip(&l.glyphs) { + assert!((a.y - b.y - shift).abs() < 1e-9, "{why}"); + } + } + // Truncation counts the trimmed bottom: two 40 px lines need cap + 40 px, not 80. + let mut t = text("A\nB\nC", TextAutoResize::Truncate); + t.style.line_height = LineHeight::Pixels(40.0); + t.leading_trim = LeadingTrim::CapHeight; + let l = layout(&t, Some(200.0), Some(cap + 40.0 + 0.1)); + assert_eq!(l.lines.len(), 2, "two trimmed lines fit"); +} + +#[test] +fn decorations_use_the_fonts_metrics() { + let Some(f) = face() else { return }; + let k = 20.0 / f.upem; + for (deco, pos, why) in [ + (TextDecoration::Underline, f.underline_pos, "underline: the font's underline offset below the baseline"), + (TextDecoration::Strikethrough, f.strike_pos, "strikethrough: the font's strikeout offset above it"), + ] { + let mut t = text("漢字 Hello", TextAutoResize::WidthAndHeight); + t.style.decoration = deco; + let l = layout(&t, None, None); + assert_eq!(l.decorations.len(), 1, "{why}: one line through mixed scripts"); + let d = &l.decorations[0]; + let th = (f.underline_thickness * k).max(0.5); + assert!((d.rect.height() - th).abs() < 1e-9, "{why}: the font's thickness"); + assert!(((d.rect.y0 + d.rect.y1) / 2.0 - (l.lines[0].baseline - pos * k)).abs() < 1e-9, "{why}"); + } +} + +#[test] +fn fallback_fonts_dont_move_the_baseline() { + // Documentation silent on fallback faces; rule chosen: a style's line metrics come from its own + // font, so a line with CJK (drawn by a fallback face) is spaced like a Latin one. + if face().is_none() { + return; + } + for lh in [LineHeight::Auto, LineHeight::Pixels(32.0), LineHeight::Percent(140.0)] { + let mut t = text("Ab\n漢字かな\nAb 한국어 😀\nAb", TextAutoResize::WidthAndHeight); + t.style.line_height = lh; + let l = layout(&t, None, None); + let first = &l.lines[0]; + for line in &l.lines { + assert!((line.height - first.height).abs() < 1e-9, "{lh:?}: same height"); + assert!((line.baseline - line.top - (first.baseline - first.top)).abs() < 1e-9, "{lh:?}: same baseline offset"); + } + } +} + +#[test] +fn break_opportunities() { + // (before, after, may break between, derivation) + let cases: [(char, char, bool, &str); 22] = [ + ('a', 'b', false, "inside a Latin word: never"), + (' ', 'b', true, "after a space"), + ('a', ' ', false, "before a space: the space hangs at the line's end"), + ('-', 'b', true, "after a hyphen"), + ('\u{2014}', 'b', true, "after an em dash"), + ('日', '本', true, "between ideographs"), + ('あ', 'い', true, "between kana"), + ('한', '국', true, "between Hangul syllables (documentation silent on Korean word mode: by syllable, like CSS normal)"), + ('a', '日', true, "Latin then an ideograph"), + ('日', 'a', true, "an ideograph then Latin"), + ('本', '。', false, "kinsoku: an ideographic full stop never starts a line"), + ('本', '、', false, "kinsoku: nor an ideographic comma"), + ('本', '」', false, "kinsoku: nor a closing bracket"), + ('本', 'ー', false, "kinsoku: nor the prolonged sound mark"), + ('キ', 'ャ', false, "kinsoku: nor a small kana"), + ('本', '々', false, "kinsoku: nor an iteration mark"), + ('本', '!', false, "kinsoku: nor full-width punctuation"), + ('「', '日', false, "kinsoku: an opening bracket never ends a line"), + ('(', '日', false, "kinsoku: nor a full-width opening parenthesis"), + ('。', '日', true, "after a full stop"), + ('日', '「', true, "before an opening bracket"), + ('\u{3000}', '日', true, "after an ideographic space"), + ]; + for (a, b, want, why) in cases { + assert_eq!(break_between(a, b), want, "{a}{b}: {why}"); + } +} + +#[test] +fn cjk_lines_wrap_between_ideographs_with_kinsoku() { + if face().is_none() { + return; + } + // Box width cut right after char `fit` (chars 0..fit fit, the next one doesn't): the first line + // and how the second starts. (text, fit, first line, second line starts with, derivation) + let cases: [(&str, usize, &str, &str, &str); 6] = [ + ("一二三四五六", 3, "一二三", "四", "ideographs break anywhere"), + ("一二三。四五", 3, "一二", "三。", "。 can't start a line: 三 moves down with it"), + ("一二「三」", 3, "一二", "「三", "「 can't end a line: it moves down"), + ("日本語abc def", 5, "日本語", "abc", "no break inside a Latin word: before it"), + ("abc 日本語です", 6, "abc 日本", "語", "a space or an ideograph is a break"), + ("한국어 텍스트입니다", 5, "한국어 텍", "스", "Korean breaks between syllables (documentation silent)"), + ]; + for (s, fit, first, second, why) in cases { + let t = text(s, TextAutoResize::Height); + let natural = layout(&t, None, None); + let (fits, next) = (natural.caret(fit).0, natural.caret(fit + 1).0); + assert!(next > fits, "{why}: chars need advances"); + let l = layout(&t, Some(fits + (next - fits) / 4.0), None); + let got = line_texts(&t, &l); + assert_eq!(got[0], first, "{why}"); + assert!(got[1].starts_with(second), "{why}: {got:?}"); + } +} + +#[test] +fn full_width_space_hangs_and_breaks() { + if face().is_none() { + return; + } + let t = text("日本\u{3000}語", TextAutoResize::Height); + let natural = layout(&t, None, None); + let w = natural.caret(2).0 + 0.1; + let l = layout(&t, Some(w), None); + assert_eq!(line_texts(&t, &l), ["日本\u{3000}", "語"], "the ideographic space ends the line and hangs"); + assert!(l.lines[0].width <= w); +} + +#[test] +fn grapheme_clusters() { + let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; // 5 chars, one cluster + let flag = "\u{1F1EF}\u{1F1F5}"; // two regional indicators + let accent = "e\u{0301}"; // e + combining acute + let thumbs = "\u{1F44D}\u{1F3FD}"; // emoji + skin tone + let s = format!("a{family}b{flag}{accent}{thumbs}\u{2764}\u{FE0F}"); + // Every boundary in order: a | family | b | flag | é | thumbs | heart. + let stops = [0, 1, 6, 7, 9, 11, 13, 15]; + for w in stops.windows(2) { + assert_eq!(grapheme_step(&s, w[0], true), w[1], "forward from {}", w[0]); + assert_eq!(grapheme_step(&s, w[1], false), w[0], "back from {}", w[1]); + } + assert_eq!(grapheme_step(&s, 15, true), 15, "the end stays"); + assert_eq!(grapheme_step(&s, 0, false), 0, "the start stays"); + assert_eq!(grapheme_step(&s, 3, true), 6, "from inside a cluster: its end"); + assert_eq!(grapheme_step(&s, 3, false), 1, "back from inside: its start"); + assert_eq!(grapheme_step("", 0, true), 0); + let starts = grapheme_starts(&s); + assert_eq!(starts.len(), s.chars().count()); + assert_eq!(starts.iter().filter(|b| **b).count(), stops.len() - 1); + assert!(grapheme_starts("plain ascii").iter().all(|b| *b)); +} + +#[test] +fn a_cluster_is_shaped_with_one_face() { + if face().is_none() { + return; + } + // Wherever the base char's face is, its marks and joiners go with it (one glyph run). + for s in ["e\u{0301}", "\u{1F468}\u{200D}\u{1F469}", "漢\u{FE00}"] { + let l = layout(&text(s, TextAutoResize::WidthAndHeight), None, None); + let faces: std::collections::HashSet = l.glyphs.iter().map(|g| g.face.id).collect(); + assert_eq!(faces.len(), 1, "{s:?}"); + } +} + diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs new file mode 100644 index 0000000..37881a3 --- /dev/null +++ b/crates/text/src/breaks.rs @@ -0,0 +1,95 @@ +//! Where lines may break and where carets may stop: break opportunities (a small subset of UAX #14 +//! with the CJK line-start and line-end rules) and grapheme clusters (UAX #29). + +use unicode_segmentation::UnicodeSegmentation; + +/// The break class of a char. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Class { + /// Breakable space: lines break after it (it hangs at the line's end). + Space, + /// Ideographs, kana, Hangul and full-width forms: lines may break before and after each. + Ideo, + /// Opening brackets and quotes: never end a line. + Open, + /// Closing brackets, quotes and punctuation, small kana, the prolonged sound mark and + /// iteration marks: never start a line. + Close, + /// Hyphens, dashes and the slash: lines may break after them. + Hyphen, + /// Everything else (letters, digits, other symbols): no break inside a run of them. + Other, +} + +fn class(c: char) -> Class { + match c { + '(' | '[' | '{' | '\u{2018}' | '\u{201C}' | '\u{3008}' | '\u{300A}' | '\u{300C}' | '\u{300E}' | '\u{3010}' | '\u{3014}' + | '\u{3016}' | '\u{3018}' | '\u{301A}' | '\u{301D}' | '\u{FF08}' | '\u{FF3B}' | '\u{FF5B}' | '\u{FF62}' => Class::Open, + ')' | ']' | '}' | ',' | '.' | ':' | ';' | '!' | '?' | '\u{2019}' | '\u{201D}' | '\u{2025}' | '\u{2026}' | '\u{203C}' + | '\u{2047}'..='\u{2049}' | '\u{3001}' | '\u{3002}' | '\u{3005}' | '\u{3009}' | '\u{300B}' | '\u{300D}' | '\u{300F}' + | '\u{3011}' | '\u{3015}' | '\u{3017}' | '\u{3019}' | '\u{301B}' | '\u{301C}' | '\u{301E}' | '\u{301F}' | '\u{303B}' + | '\u{3041}' | '\u{3043}' | '\u{3045}' | '\u{3047}' | '\u{3049}' | '\u{3063}' | '\u{3083}' | '\u{3085}' | '\u{3087}' + | '\u{308E}' | '\u{3095}' | '\u{3096}' | '\u{309D}' | '\u{309E}' | '\u{30A0}' | '\u{30A1}' | '\u{30A3}' | '\u{30A5}' + | '\u{30A7}' | '\u{30A9}' | '\u{30C3}' | '\u{30E3}' | '\u{30E5}' | '\u{30E7}' | '\u{30EE}' | '\u{30F5}' | '\u{30F6}' + | '\u{30FB}' | '\u{30FC}' | '\u{30FD}' | '\u{30FE}' | '\u{31F0}'..='\u{31FF}' | '\u{FF01}' | '\u{FF09}' | '\u{FF0C}' + | '\u{FF0E}' | '\u{FF1A}' | '\u{FF1B}' | '\u{FF1F}' | '\u{FF3D}' | '\u{FF5D}' | '\u{FF61}' | '\u{FF63}'..='\u{FF65}' + | '\u{FF70}' => Class::Close, + ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}' => Class::Space, + '-' | '/' | '\u{2010}' | '\u{2013}' | '\u{2014}' => Class::Hyphen, + '\u{2E80}'..='\u{2FFF}' + | '\u{3003}'..='\u{33FF}' + | '\u{3400}'..='\u{4DBF}' + | '\u{4E00}'..='\u{9FFF}' + | '\u{A960}'..='\u{A97F}' + | '\u{AC00}'..='\u{D7AF}' + | '\u{F900}'..='\u{FAFF}' + | '\u{FE30}'..='\u{FE4F}' + | '\u{FF00}'..='\u{FFEF}' + | '\u{20000}'..='\u{3FFFF}' => Class::Ideo, + _ => Class::Other, + } +} + +/// Whether a line may break between `a` and the `b` after it (the break goes before `b`). +pub(crate) fn break_between(a: char, b: char) -> bool { + use Class::*; + match (class(a), class(b)) { + // Spaces hang at the end of the line before them; kinsoku: no closing mark starts a line, + // no opening mark ends one. + (_, Space | Close) | (Open, _) => false, + (Space | Hyphen | Ideo, _) | (_, Ideo) => true, + _ => false, + } +} + +/// Whether char `c` is a breakable space (it hangs at a line's end and doesn't count for its width). +pub(crate) fn is_space(c: char) -> bool { + class(c) == Class::Space +} + +/// For each char of `text`, whether it starts a grapheme cluster (a user-perceived character: +/// an emoji sequence, a letter with its combining marks). +pub(crate) fn grapheme_starts(text: &str) -> Vec { + if text.is_ascii() && !text.contains('\r') { + return vec![true; text.len()]; + } + let mut starts = Vec::with_capacity(text.len()); + for g in text.graphemes(true) { + starts.extend(g.chars().enumerate().map(|(k, _)| k == 0)); + } + starts +} + +/// The char index of the grapheme cluster boundary after (`forward`) or before char `index` in +/// `text`: where an arrow key, Delete or Backspace moves the caret to. +pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { + let mut at = 0; + for g in text.graphemes(true) { + let next = at + g.chars().count(); + if (forward && index < next) || (!forward && index <= next) { + return if forward { next } else { at }; + } + at = next; + } + at.min(index) +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 49411a3..3a31a88 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use figcraft_doc::{LineHeight, ListStyle, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, valid_feature_tag}; +use figcraft_doc::{LeadingTrim, ListStyle, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, valid_feature_tag}; use figcraft_geom::{Affine, BezPath, Rect}; use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation}; @@ -106,10 +106,7 @@ struct Item { impl Item { fn is_space(&self) -> bool { - matches!(self.ch, ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}') - } - fn break_after(&self) -> bool { - self.is_space() || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || is_cjk(self.ch) + crate::breaks::is_space(self.ch) } } @@ -171,10 +168,6 @@ pub(crate) fn visual_order(levels: &[u8]) -> Vec { order } -fn is_cjk(c: char) -> bool { - matches!(c as u32, 0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF) -} - /// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width /// pass `None`). `box_height` is used for truncation only. pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout { @@ -212,6 +205,8 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let bidi = Bidi::of(&text.characters); let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0); let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false); + // A grapheme cluster (emoji sequence, letter with marks) is shaped with one face. + let cluster_start = crate::breaks::grapheme_starts(&text.characters); let mut items: Vec = Vec::with_capacity(chars.len()); for (si, (start, end, _)) in spans.iter().enumerate() { let st_idx = span_style.get(si).copied().unwrap_or(0); @@ -233,7 +228,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let level = level_at(seg_start); let mut seg_end = seg_start + 1; while seg_end < *end - && chars.get(seg_end).is_some_and(|c| *c != '\n' && first != '\n' && face_for(*c).id == face.id) + && chars.get(seg_end).is_some_and(|c| { + *c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) + }) && level_at(seg_end) == level { seg_end += 1; @@ -265,7 +262,12 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the // indent are on the right, the text ends where they start. let marker_width = list_gutter(text.list, &chars, styles.first(), faces.first().cloned().flatten().as_ref()); - let indent = if text.list == ListStyle::None && text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; + // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph). + let indent_px = if text.list == ListStyle::None && text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; + let indent_at = |char_idx: usize| { + let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left }; + if start_aligned { indent_px } else { 0.0 } + }; // Line breaking. let wrap = match text.auto_resize { @@ -289,7 +291,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para_first_line = true; continue; } - let line_indent = marker_width + if para_first_line { indent } else { 0.0 }; + let line_indent = marker_width + if para_first_line { indent_at(it.char_start) } else { 0.0 }; let w = it.adv; if let Some(maxw) = wrap && x + w > maxw - line_indent + 1e-6 @@ -306,7 +308,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) continue; } x += w; - if it.break_after() { + if items.get(i + 1).is_some_and(|next| crate::breaks::break_between(it.ch, next.ch)) { last_break = Some(i); } i += 1; @@ -322,39 +324,41 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut y = 0.0; let mut para_start_line = true; let max_lines = if text.max_lines > 0 && matches!(text.auto_resize, TextAutoResize::Truncate) { text.max_lines as usize } else { usize::MAX }; + // A style's vertical metrics (ascent, descent, line height, cap height) come from its own + // font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't. + let metrics = |st_idx: usize| -> [f64; 4] { + let st = styles.get(st_idx).unwrap_or(&default_style); + let size = sanitize_size(st.font_size); + match faces.get(st_idx).and_then(Option::as_ref).or(default_face.as_ref()) { + Some(f) => { + let k = size / f.upem; + [f.ascent * k, f.descent * k, st.line_height.resolve(size, natural_ratio(f)), f.cap_height * k] + } + None => [size * 0.8, size * 0.2, st.line_height.resolve(size, 1.2), size * 0.7], + } + }; + let mut first_cap = 0.0; for (li, (s, e, para_end)) in lines.iter().enumerate() { if li >= max_lines { out.truncated = true; break; } // Metrics from the styles present on the line (or the style at the break for empty lines). - let mut asc: f64 = 0.0; - let mut desc: f64 = 0.0; - let mut lh: f64 = 0.0; - let mut any = false; - for it in items.get(*s..*e).unwrap_or(&[]) { - let Some(st) = styles.get(it.style) else { continue }; - let size = sanitize_size(st.font_size); - let k = size / it.face.upem; - asc = asc.max(it.face.ascent * k); - desc = desc.max(it.face.descent * k); - lh = lh.max(st.line_height.resolve(size, natural_ratio(&it.face))); - any = true; - } - if !any { - let st = items.get(s.saturating_sub(1)).and_then(|it| styles.get(it.style)).unwrap_or(&default_style); - let size = sanitize_size(st.font_size); - if let Some(f) = items.get(s.saturating_sub(1)).map(|it| it.face.clone()).or_else(|| default_face.clone()) { - let k = size / f.upem; - asc = f.ascent * k; - desc = f.descent * k; - lh = st.line_height.resolve(size, natural_ratio(&f)); - } else { - asc = size * 0.8; - desc = size * 0.2; - lh = size * 1.2; + let [mut asc, mut desc, mut lh, mut cap] = [0.0_f64; 4]; + let mut seen = usize::MAX; + for st in items.get(*s..*e).unwrap_or(&[]).iter().map(|it| it.style) { + if st != seen { + seen = st; + let m = metrics(st); + (asc, desc, lh, cap) = (asc.max(m[0]), desc.max(m[1]), lh.max(m[2]), cap.max(m[3])); } } + if seen == usize::MAX { + [asc, desc, lh, cap] = metrics(items.get(s.saturating_sub(1)).map_or(0, |it| it.style)); + } + if li == 0 { + first_cap = cap; + } if para_start_line && li > 0 { y += text.paragraph_spacing.max(0.0); } @@ -365,8 +369,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) vis_end -= 1; } let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum(); - let line_indent = marker_width + if para_start_line { indent } else { 0.0 }; - let rtl_line = rtl_para_at(items.get(*s).map(|it| it.char_start).unwrap_or(0)); + let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); + let rtl_line = rtl_para_at(first_char); + let line_indent = marker_width + if para_start_line { indent_at(first_char) } else { 0.0 }; let gstart = out.glyphs.len(); // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); @@ -494,21 +499,34 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) y += lh; para_start_line = *para_end; } - out.height = y; out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max); + // Vertical trim: the box starts at the first line's cap height and ends at the last baseline. + let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none(); + if trim && let Some(first) = out.lines.first() { + let shift = first.baseline - first_cap; + for l in &mut out.lines { + l.top -= shift; + l.baseline -= shift; + } + for g in &mut out.glyphs { + g.y -= shift; + } + } + let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height }; + out.height = out.lines.last().map_or(y, bottom).max(0.0); // Truncation by height. if matches!(text.auto_resize, TextAutoResize::Truncate) && let Some(h) = box_height && out.height > h + 0.5 { - let keep = out.lines.iter().take_while(|l| l.top + l.height <= h + 0.5).count().max(1); + let keep = out.lines.iter().take_while(|l| bottom(l) <= h + 0.5).count().max(1); if keep < out.lines.len() { out.lines.truncate(keep); let gend = out.lines.last().map(|l| l.glyph_end).unwrap_or(0); out.glyphs.truncate(gend); out.truncated = true; - out.height = out.lines.last().map(|l| l.top + l.height).unwrap_or(0.0); + out.height = out.lines.last().map_or(0.0, bottom); } } if out.truncated { @@ -560,8 +578,10 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) { let x0 = g0.x; let x1 = last.x + last.advance; - let f = &g0.face; - let s = g0.size / f.upem; + // The style's own font and size (not a fallback face or a small-cap scale), so the + // line runs at one height and weight through mixed scripts. + let f = faces.get(g0.style).and_then(Option::as_ref).unwrap_or(&g0.face); + let s = sanitize_size(st.font_size) / f.upem; let th = (f.underline_thickness * s).max(0.5); let yy = match st.decoration { TextDecoration::Underline => line.baseline - f.underline_pos * s, @@ -576,7 +596,6 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) if let Some(tp) = &text.path { crate::onpath::place(&mut out, tp, text.align_h); } - let _ = LineHeight::Auto; out } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 542c694..b63255b 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -9,12 +9,14 @@ pub mod display; pub mod fontdb; mod layout; +mod breaks; mod onpath; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, scan_system_fonts, system_fonts_scanned, }; +pub use breaks::grapheme_step; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; @@ -141,6 +143,8 @@ pub fn auto_size(n: &Node) -> Option<(f64, f64)> { } } +#[cfg(test)] +mod behaviour_tests; #[cfg(test)] mod cache_tests; #[cfg(test)] diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c9829e6..d2e6cfe 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -170,6 +170,7 @@ name = "English" "Cancel" = "Cancel" "Canvas" = "Canvas" "Canvas stacking" = "Canvas stacking" +"Cap height to baseline" = "Cap height to baseline" "Case" = "Case" "Case-sensitive forms" = "Case-sensitive forms" "Categories" = "Categories" @@ -785,6 +786,7 @@ name = "English" "Panels" = "Panels" "Paper" = "Paper" "Paragraph indent" = "Paragraph indent" +"Paragraph indent applies to left-aligned text" = "Paragraph indent applies to left-aligned text" "Paragraph spacing" = "Paragraph spacing" "Pass through" = "Pass through" "Paste a team library link (a view link)" = "Paste a team library link (a view link)" @@ -1034,6 +1036,7 @@ name = "English" "Splay" = "Splay" "Spread" = "Spread" "Square" = "Square" +"Standard" = "Standard" "Standard ligatures" = "Standard ligatures" "Star" = "Star" "Stars" = "Stars" @@ -1176,6 +1179,7 @@ name = "English" "Vertical hex" = "Vertical hex" "Vertical padding" = "Vertical padding" "Vertical spacing" = "Vertical spacing" +"Vertical trim" = "Vertical trim" "View" = "View" "View link" = "View link" "View link copied" = "View link copied" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index c34da14..2aeb3d4 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -171,6 +171,7 @@ status = "draft — needs native review" "Cancel" = "Cancelar" "Canvas" = "Lienzo" "Canvas stacking" = "Apilado en el lienzo" +"Cap height to baseline" = "De la altura de mayúsculas a la línea base" "Case" = "Mayúsculas y minúsculas" "Case-sensitive forms" = "Formas sensibles a mayúsculas" "Categories" = "Categorías" @@ -786,6 +787,7 @@ status = "draft — needs native review" "Panels" = "Paneles" "Paper" = "Papel" "Paragraph indent" = "Sangría de párrafo" +"Paragraph indent applies to left-aligned text" = "La sangría de párrafo se aplica al texto alineado a la izquierda" "Paragraph spacing" = "Espacio entre párrafos" "Pass through" = "Atravesar" "Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" @@ -1035,6 +1037,7 @@ status = "draft — needs native review" "Splay" = "Apertura" "Spread" = "Extensión" "Square" = "Cuadrado" +"Standard" = "Estándar" "Standard ligatures" = "Ligaduras estándar" "Star" = "Estrella" "Stars" = "Estrellas" @@ -1177,6 +1180,7 @@ status = "draft — needs native review" "Vertical hex" = "Hexágono vertical" "Vertical padding" = "Margen interno vertical" "Vertical spacing" = "Espaciado vertical" +"Vertical trim" = "Recorte vertical" "View" = "Ver" "View link" = "Enlace de lectura" "View link copied" = "Enlace de lectura copiado" diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index eb20e50..996e182 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -438,14 +438,25 @@ fn type_settings( } ui.add_space(6.0); let indent = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_indent.to_bits())).flatten().map(f64::from_bits); + // Indent applies to start-aligned text only; elsewhere the field stays visible but off. + let start_aligned = common(doc, ids, |n| n.kind.text().map(|t| t.align_h != TextAlignH::Center && t.align_h != TextAlignH::Justified)).flatten(); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Paragraph indent")).font(regular(11.0)).color(t.text))); - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = - widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", w - 118.0, 1.0) - { - app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); - } + ui.add_enabled_ui(start_aligned != Some(false), |ui| { + ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Paragraph indent")).font(regular(11.0)).color(t.text))); + let field = widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", w - 118.0, 1.0); + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = field { + app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); + } + }) + .response + .on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text")); }); + caption(ui, tr("Vertical trim")); + let trim = common(doc, ids, |n| n.kind.text().map(|t| usize::from(t.leading_trim == LeadingTrim::CapHeight))).flatten(); + let items = [(tr("Standard"), tr_noop("Standard")), ("Ag", tr_noop("Cap height to baseline"))]; + if let Some(i) = text_seg(ui, "trim", &items, trim, w) { + app.set_props(ids, json!({"leadingTrim": if i == 1 { "capHeight" } else { "none" }})); + } let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten(); caption(ui, tr("Truncate text")); let active = resize.map(|r| usize::from(r == TextAutoResize::Truncate)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2917bf5..1444a19 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-10-11 · **Change:** minor (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 ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text behaviour suite: CJK line breaking, vertical trim, carets by grapheme cluster; 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 ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **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 @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) | | Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.8% × discounts 0.9 × 0.9 × 0.8) | | Remaining to beta | **~195–355 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~345–666 h** one agent (~92–160 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~343–663 h** one agent (~92–160 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 | | 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | -| 10 | Text | 8 | 85% | 64% | Spell check, CJK text QA, real-file text metrics (text on a path landed) | 7–13 | +| 10 | Text | 8 | 87% | 68% | Spell check, real-file text metrics, list spacing, hanging punctuation, underline styles (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed) | 5–10 | | 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 | | 12 | Styles & variables | 5 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1–2 | | 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 | @@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **187–380** | -| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 82–161 | +| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **185–377** | +| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 80–158 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 57% | 60–115 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | +| Features (depth) | 35 | 57% | 58–112 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~47%** | **~345–666** | | | +| **Ready for real work** | 100 | **~47%** | **~343–663** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -281,7 +281,17 @@ Hours in this narrative are the builders' estimates; the tables above supersede paragraph's font, renames start with the name selected and centred, Max lines shows while truncating. Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on the right, wrapped lines hanging from the right, the paragraph indent on the right, trailing - spaces hanging outside the line (canvas, PNG, PDF and SVG). *Left:* visual QA. 0.5 h. + spaces hanging outside the line (canvas, PNG, PDF and SVG). A behaviour suite from the public + documentation (`crates/text/src/behaviour_tests.rs`, `crates/engine/src/text_behaviour_tests.rs`): + Auto / % / px line heights with the leading split above and below every line, letter spacing in + % and px, paragraph spacing and indent (start-aligned text only), resizing and truncation, + decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no + break inside Latin words, kinsoku for closing and opening punctuation, small kana and the + prolonged sound mark); a style's metrics from its own font, not a fallback; 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). *Left:* visual QA; list spacing, hanging quotes and + lists, underline style/offset/thickness/skip ink (documented, not modelled); Korean keeps + breaking by syllable and Han runs aren't split by a dictionary (documentation silent). 0.5 h. 3. **Auto layout and sections depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text @@ -580,6 +590,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~345–666 h → ~343–663 h | | 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | | 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f6e77e7..0049f7d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -58,7 +58,7 @@ interaction feel not yet compared. | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | -| Text: point, auto height, fixed; caret, words, lines, IME | done | no spell check | +| Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only |