M4: text behaviour suite: metrics, line height, CJK

This commit is contained in:
bflatastic 2026-10-11 19:01:24 +07:00
parent d01d18cd88
commit 9d52f5d5ca
24 changed files with 833 additions and 95 deletions

1
Cargo.lock generated
View File

@ -1667,6 +1667,7 @@ dependencies = [
"log",
"skrifa 0.47.0",
"unicode-bidi",
"unicode-segmentation",
]
[[package]]

View File

@ -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"

View File

@ -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 |

View File

@ -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
}

View File

@ -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 {

View File

@ -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<String, Valu
"paragraphSpacing" => 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)?;

View File

@ -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
}

View File

@ -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",

View File

@ -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));
}

View File

@ -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;

View File

@ -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");
}

View File

@ -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<char> = 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;
}
}

View File

@ -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);

View File

@ -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()) {

View File

@ -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 }

View File

@ -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<std::sync::Arc<Face>> {
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<String> {
let chars: Vec<char> = t.characters.chars().collect();
l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::<String>()).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<u32> = l.glyphs.iter().map(|g| g.face.id).collect();
assert_eq!(faces.len(), 1, "{s:?}");
}
}

95
crates/text/src/breaks.rs Normal file
View File

@ -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<bool> {
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)
}

View File

@ -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<usize> {
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<f64>, box_height: Option<f64>) -> TextLayout {
@ -212,6 +205,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
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<Item> = 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<f64>, box_height: Option<f64>)
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<f64>, box_height: Option<f64>)
// 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<f64>, box_height: Option<f64>)
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<f64>, box_height: Option<f64>)
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<f64>, box_height: Option<f64>)
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<f64>, box_height: Option<f64>)
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<f64>, box_height: Option<f64>)
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<f64>, box_height: Option<f64>)
{
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<f64>, box_height: Option<f64>)
if let Some(tp) = &text.path {
crate::onpath::place(&mut out, tp, text.align_h);
}
let _ = LineHeight::Auto;
out
}

View File

@ -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)]

View File

@ -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"

View File

@ -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"

View File

@ -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));

View File

@ -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 |

View File

@ -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 |