From f77d9a467e73570aa2f4a4d770c93064c2f33a8f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 17:00:17 +0700 Subject: [PATCH] M3: text metrics: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only Measured against documents re-saved by other software. A line's height is rounded to whole pixels (halves up) with the baseline keeping its fraction inside it; a glyph's advance and its kerning are each rounded to 1/1024 em before scaling; a line's width and wrapping leave out the letter spacing after its last visible glyph. Rule tests on built-in fonts, a local-only check over such documents, and three behaviour expectations updated to the new rules. --- ROADMAP.md | 6 +- crates/engine/src/lib.rs | 2 + crates/engine/src/text_metrics_local_tests.rs | 38 ++++++ crates/text/src/behaviour_tests.rs | 17 ++- crates/text/src/layout.rs | 35 +++++- crates/text/src/lib.rs | 2 + crates/text/src/metrics_tests.rs | 119 ++++++++++++++++++ docs/gaps.md | 13 +- docs/target-app-parity.md | 5 +- 9 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/text_metrics_local_tests.rs create mode 100644 crates/text/src/metrics_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 191f2cb..cf086cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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 @@ -131,6 +131,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and + compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing + goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before + in the design-system example). One font family; real files still unchecked (gap 3). No number moves - **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover, people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 70f9d4d..0d7a2d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1447,6 +1447,8 @@ mod text_behaviour_tests; #[cfg(test)] mod text_fill_tests; #[cfg(test)] +mod text_metrics_local_tests; +#[cfg(test)] mod text_motion_tests; #[cfg(test)] mod text_style_picker_tests; diff --git a/crates/engine/src/text_metrics_local_tests.rs b/crates/engine/src/text_metrics_local_tests.rs new file mode 100644 index 0000000..c4159ed --- /dev/null +++ b/crates/engine/src/text_metrics_local_tests.rs @@ -0,0 +1,38 @@ +//! Text box sizes measured against documents re-saved by other software. Local only: runs when +//! `FIGCRAFT_FIG_SAMPLES` names a folder of `.fig` files, and only for text whose exact font is +//! installed. The rules themselves are tested on built-in fonts in `figcraft_text`. + +use figcraft_doc::{LineHeight, Node, NodeKind, TextAutoResize}; + +/// `n` with its percent line height read as a share of the font's own line height (the import +/// reads every percent as a share of the font size; files can mean either). +fn of_own_line_height(n: &Node) -> Option { + let mut m = n.clone(); + let NodeKind::Text(t) = &mut m.kind else { return None }; + let LineHeight::Percent(p) = t.style.line_height else { return None }; + let f = figcraft_text::find_face(&t.style.font_family, &t.style.font_style)?; + t.style.line_height = LineHeight::Percent(p * (f.ascent + f.descent + f.line_gap.max(0.0)) / f.upem); + Some(m) +} + +/// Each text box that sizes itself comes out at the size stored in the file, to the pixel. +#[test] +fn local_samples_keep_their_text_box_sizes() { + let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return }; + let Ok(dir) = std::fs::read_dir(dir) else { return }; + figcraft_text::scan_system_fonts(); + for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")) { + let Ok((doc, _)) = figcraft_format::fig::import(&std::fs::read(&p).unwrap(), "sample") else { continue }; + for n in doc.nodes() { + let NodeKind::Text(t) = &n.kind else { continue }; + // Stored sizes are whole pixels once the text was laid out by the software that saved it. + let laid_out = n.height.fract() == 0.0 && (t.auto_resize != TextAutoResize::WidthAndHeight || n.width.fract() == 0.0); + let own_font = t.spans().iter().all(|(_, _, s)| { + figcraft_text::find_face(&s.font_family, &s.font_style).is_some_and(|f| f.family == s.font_family && f.style == s.font_style) + }); + let Some((w, h)) = figcraft_text::auto_size(n).filter(|_| laid_out && own_font) else { continue }; + let h = if h == n.height { h } else { of_own_line_height(n).and_then(|m| figcraft_text::auto_size(&m)).map_or(h, |s| s.1) }; + assert_eq!((w, h), (n.width, n.height), "{}: {:?}", p.display(), t.characters); + } + } +} diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index 04eff79..a5400b6 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -41,10 +41,10 @@ const EPS: f64 = 1e-6; 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; + let auto = ((f.ascent + f.descent + f.line_gap.max(0.0)) * k + 0.5).floor(); // (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", auto, "Auto: the font's own line height (ascent + descent + line gap) in whole pixels"), (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"), @@ -87,9 +87,9 @@ fn letter_spacing_percent_and_pixels() { 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::Percent(10.0), 4.0, "10% of 20 px = 2 px between the 3 chars (none after the last one: measured)"), + (LetterSpacing::Pixels(3.0), 6.0, "3 px in each of the 2 gaps"), + (LetterSpacing::Percent(-5.0), -2.0, "negative spacing tightens: −1 px per gap"), (LetterSpacing::Pixels(0.0), 0.0, "0 changes nothing"), ]; for (ls, extra, why) in cases { @@ -170,7 +170,12 @@ fn vertical_trim_cap_height_to_baseline() { 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"), + ( + "A\nB", + LineHeight::Auto, + cap + ((f.ascent + f.descent + f.line_gap.max(0.0)) * k + 0.5).floor(), + "Auto line height (whole pixels), same rule", + ), ] { let mut t = text(s, TextAutoResize::WidthAndHeight); t.style.line_height = lh; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index cc1a2cc..110c758 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -179,11 +179,25 @@ pub struct TextLayout { pub on_path: Option, } -/// Ratio of Figma's "Auto" line height to the font size (font ascent+descent+gap, per face). +/// Ratio of the "Auto" line height to the font size (font ascent+descent+gap, per face). fn natural_ratio(face: &Face) -> f64 { ((face.ascent + face.descent + face.line_gap.max(0.0)) / face.upem).max(0.5) } +/// A line's height: whole pixels (halves round up), whatever unit it was given in. Heights under a +/// pixel stay as they are, so tiny text keeps its lines apart. +fn whole_px(line_height: f64) -> f64 { + if line_height >= 1.0 { (line_height + 0.5).floor() } else { line_height } +} + +/// Advances sit on a grid of this many steps per em, then scale with the font size. +const ADVANCE_GRID: f64 = 1024.0; + +/// `units` (font units) on the advance grid, in ems. +fn on_grid(units: f64, upem: f64) -> f64 { + (units * ADVANCE_GRID / upem).round() / ADVANCE_GRID +} + struct Item { face: Arc, coords: Coords, @@ -191,6 +205,8 @@ struct Item { scale: f64, gid: u32, adv: f64, + /// The letter spacing included in `adv` (none after a line's last visible glyph). + spacing: f64, dx: f64, dy: f64, char_start: usize, @@ -339,6 +355,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) scale: 1.0, gid: 0, adv: 0.0, + spacing: 0.0, dx: 0.0, dy: 0.0, char_start: seg_start, @@ -415,7 +432,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let hung = items.get(line_start).filter(|f| hang_start && ltr && opens(f.ch)).map_or(0.0, |f| f.adv) + if hang_end && ltr && closes(it.ch) { w } else { 0.0 }; if let Some(maxw) = wrap - && x + w > maxw - line_indent + hung + 1e-6 + && x + w - it.spacing > maxw - line_indent + hung + 1e-6 && i > line_start && !it.is_space() { @@ -453,9 +470,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) 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] + [f.ascent * k, f.descent * k, whole_px(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], + None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7], } }; let mut first_cap = 0.0; @@ -492,7 +509,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') { vis_end -= 1; } - let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum(); + // Letter spacing separates glyphs: none after the line's last visible one. + let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing); + let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing; 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 = line_indent_of(*para, para_start_line, first_char); @@ -1033,13 +1052,17 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl); let ch = chars.get(cl).copied().unwrap_or(' '); let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 }; - let adv = f64::from(xa) * k * scale + if last_in_cluster { seg.spacing * (cend - cl) as f64 } else { 0.0 }; + let spacing = if last_in_cluster { seg.spacing * (cend - cl) as f64 } else { 0.0 }; + // The glyph's own advance and what shaping added to it (kerning) each land on the grid. + let own = face.advance_at(gid, &coords); + let adv = (on_grid(own, face.upem) + on_grid(f64::from(xa) - own, face.upem)) * seg.size * scale + spacing; items.push(Item { face: face.clone(), coords: coords.clone(), scale, gid, adv, + spacing, dx: f64::from(xo) * k * scale, dy: f64::from(yo) * k * scale, char_start: cl, diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 17251f0..101d8bc 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -150,6 +150,8 @@ mod cache_tests; #[cfg(test)] mod list_tests; #[cfg(test)] +mod metrics_tests; +#[cfg(test)] mod onpath_tests; #[cfg(test)] mod rtl_list_tests; diff --git a/crates/text/src/metrics_tests.rs b/crates/text/src/metrics_tests.rs new file mode 100644 index 0000000..f3fe4e3 --- /dev/null +++ b/crates/text/src/metrics_tests.rs @@ -0,0 +1,119 @@ +//! Text metric rules measured against documents re-saved by other software: line heights are whole +//! pixels, advances sit on a grid of 1024 steps per em, and letter spacing goes between glyphs +//! only. Each case derives its numbers from the face in use (skipped on a machine without fonts); +//! the last test pins sizes that were measured, for the one font build they were measured with. + +use super::*; +use figcraft_doc::{LetterSpacing, LineHeight, TextAutoResize, TextData}; + +fn face() -> Option> { + scan_system_fonts(); + db().write().unwrap().default_face() +} + +fn text(s: &str, f: &Face, size: f64) -> TextData { + let mut t = TextData::new(s); + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + t.style.font_size = size; + t.auto_resize = TextAutoResize::WidthAndHeight; + t +} + +#[test] +fn line_heights_are_whole_pixels() { + let Some(f) = face() else { return }; + let own = |size: f64| (f.ascent + f.descent + f.line_gap.max(0.0)) * size / f.upem; + // (font size, line height, expected line height, derivation) + let cases = [ + (11.0, LineHeight::Percent(130.0), 14.0, "14.3 rounds down"), + (13.0, LineHeight::Percent(140.0), 18.0, "18.2 rounds down"), + (15.0, LineHeight::Percent(150.0), 23.0, "22.5: halves round up"), + (17.0, LineHeight::Percent(150.0), 26.0, "25.5: halves round up"), + (22.0, LineHeight::Percent(125.0), 28.0, "27.5: halves round up"), + (48.0, LineHeight::Percent(110.0), 53.0, "52.8 rounds up"), + (14.0, LineHeight::Pixels(20.0), 20.0, "whole pixels stay"), + (14.0, LineHeight::Pixels(20.4), 20.0, "pixel values round too (not measured: pins today's behaviour)"), + (14.0, LineHeight::Auto, (own(14.0) + 0.5).floor(), "Auto: the font's own line height, rounded"), + (20.0, LineHeight::Auto, (own(20.0) + 0.5).floor(), "Auto at 20 px"), + (24.0, LineHeight::Auto, (own(24.0) + 0.5).floor(), "Auto at 24 px"), + (32.0, LineHeight::Auto, (own(32.0) + 0.5).floor(), "Auto at 32 px"), + (0.5, LineHeight::Percent(100.0), 0.5, "under a pixel: kept, so lines don't collapse"), + ]; + for (size, lh, want, why) in cases { + let mut t = text("Ag\nAg", &f, size); + t.style.line_height = lh; + let l = layout(&t, None, None); + assert_eq!(l.lines.len(), 2); + assert!((l.height - 2.0 * want).abs() < 1e-9, "{why}: two lines are {} tall, not {}", l.height, 2.0 * want); + // The rounded height is what the glyphs centre in; the baseline itself is not rounded. + let (asc, desc) = (f.ascent * size / f.upem, f.descent * size / f.upem); + for (i, line) in l.lines.iter().enumerate() { + let baseline = i as f64 * want + (want - asc - desc) / 2.0 + asc; + assert!((line.height - want).abs() < 1e-9 && (line.baseline - baseline).abs() < 1e-9, "{why}: line {i} baseline {}", line.baseline); + } + } +} + +#[test] +fn advances_sit_on_a_grid_of_1024_per_em() { + let Some(f) = face() else { return }; + for size in [11.0, 13.0, 15.0, 48.0] { + let l = layout(&text("Hamburgefonstiv AVATAR To Ty.", &f, size), None, None); + for g in &l.glyphs { + let steps = g.advance / size * 1024.0; + assert!((steps - steps.round()).abs() < 1e-6, "{size} px: glyph {} advances {steps} steps", g.gid); + } + // One glyph, so no kerning: its own advance, rounded to the grid, times the size. + let l = layout(&text("n", &f, size), None, None); + let own = (f.advance(f.glyph_for('n')) * 1024.0 / f.upem).round() / 1024.0 * size; + assert!((l.width - own).abs() < 1e-9, "{size} px: {} != {own}", l.width); + } +} + +#[test] +fn letter_spacing_goes_between_glyphs_only() { + let Some(f) = face() else { return }; + let plain = layout(&text("abc", &f, 20.0), None, None); + for (ls, px) in [(LetterSpacing::Pixels(5.0), 5.0), (LetterSpacing::Percent(8.0), 1.6), (LetterSpacing::Percent(-3.0), -0.6)] { + let mut t = text("abc", &f, 20.0); + t.style.letter_spacing = ls; + let l = layout(&t, None, None); + assert!((l.width - plain.width - 2.0 * px).abs() < 1e-9, "{ls:?}: two gaps, {} vs {}", l.width, plain.width); + for (i, (g, p)) in l.glyphs.iter().zip(&plain.glyphs).enumerate() { + assert!((g.x - p.x - i as f64 * px).abs() < 1e-9, "{ls:?}: glyph {i} at {}", g.x); + } + // Trailing spaces stay outside the width, and so does the spacing before them. + t.characters = "abc ".into(); + assert!((layout(&t, None, None).width - l.width).abs() < 1e-9, "{ls:?}: trailing spaces"); + // A box as wide as the text holds it on one line. + t.characters = "abc".into(); + t.auto_resize = TextAutoResize::Height; + assert_eq!(layout(&t, Some(l.width.ceil()), None).lines.len(), 1, "{ls:?}: wraps in its own width"); + } +} + +/// Box sizes measured for Inter 3 (2816 units per em); skipped where that build isn't installed. +#[test] +fn measured_box_sizes() { + scan_system_fonts(); + // (text, style, size, line height %, letter spacing %, box width, box height) + let cases = [ + ("RIDGELINE UI · VERSION 2.4", "SemiBold", 11.0, 130.0, 8.0, 178.0, 14.0), + ("Foundations and components", "Bold", 48.0, 110.0, -3.0, 661.0, 53.0), + ("Colour", "Bold", 22.0, 125.0, -1.0, 72.0, 28.0), + ("Pine", "SemiBold", 15.0, 140.0, 0.0, 32.0, 21.0), + ("6 steps", "Medium", 13.0, 140.0, 0.0, 47.0, 18.0), + ("Slate 300", "Medium", 13.0, 140.0, 0.0, 60.0, 18.0), + ("Three ramps: Pine for actions, Sunset for highlights, Slate for text and surfaces.", "Regular", 15.0, 150.0, 0.0, 563.0, 23.0), + ]; + for (s, style, size, lh, ls, w, h) in cases { + let Some(f) = find_face("Inter", style).filter(|f| f.family == "Inter" && f.style == style && f.upem == 2816.0 && !f.is_variable()) else { + continue; + }; + let mut t = text(s, &f, size); + t.style.line_height = LineHeight::Percent(lh); + t.style.letter_spacing = LetterSpacing::Percent(ls); + let l = layout(&t, None, None); + assert_eq!((l.width.ceil(), l.height), (w, h), "{s:?}: line width {}", l.width); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index aebb341..8139b90 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -104,6 +104,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Impact:** unknown fidelity in auto layout sizing, text metrics, effects and component resolution on real files; the numbers in this repo can't be trusted beyond "estimated" until it exists. +- **Measured so far:** text box sizes and auto layout results of our own example documents, + against the same documents re-saved by other software (which lays text out again). Three text + rules explained every difference and are applied (whole-pixel line heights, advances on a + 1024-per-em grid, letter spacing between glyphs only): all 230 self-sizing text boxes in 7 + documents now come out to the pixel (24 of 131 before, in the design-system example), and 208 of + its 231 other layers match in size and position (115 before). The other 23 hang on one grid + auto layout frame whose row positions came back as written while its cells got shorter. One font + family (Inter), left-aligned text; real files, other fonts, effects and component resolution are + still unmeasured (`crates/text/src/metrics_tests.rs`, and a local check over such documents in + `crates/engine/src/text_metrics_local_tests.rs`). - **Estimate:** 15–30 h once `.fig` or REST import of real files is available; needs files whose licence allows use (owner to supply or approve; never committed if not redistributable, see craftrules `standards/test-corpora.md`). @@ -375,6 +385,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 3: first measured fidelity data. Text box sizes and auto layout results of our example documents were compared with the same documents re-saved by other software; three text rules (whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only) were found and applied, and all 230 self-sizing text boxes in 7 documents now match to the pixel (one font family). Real files are still unchecked: hours and the ranking unchanged | | 2026-10-12 | minor | Gap 2: live sessions are per document (each open file has its own session or none, several run at once, the window and the `collab.*` commands follow the file in front, closing a file ends its session only) added to the landed list; a fix, so the estimate and ranking are unchanged | | 2026-10-12 | minor | Gap 1: the experimental `.fig` export now opens elsewhere: written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Hours and the ranking unchanged | | 2026-10-12 | minor | Gap 1: an experimental `.fig` export landed (the whole document, from the same public format notes as the import; checked only by round trips through our own importer); export hours 15–30 h → 5–15 h (checks against real files); gap 1's read tail (5–10 h) and the ranking unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f2c04c..3784d97 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-12 · **Change:** minor (.fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **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 @@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side; inner shadow and background blur on groups (behaviour suite from the documentation, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files; a one-step frame/group convert (behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 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 | 90% | 70% | Spell check, real-file text metrics (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | +| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Variables inside styles, real-library QA (behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | @@ -625,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Text metrics, first measured fidelity data (gap 3): our example documents, exported and re-saved by other software, were read back and every text layer's stored box, line heights, baselines and glyph positions compared with ours. Three rules explained every row and were applied: a line's height is rounded to whole pixels (halves up; Auto, % and px alike; the baseline keeps its fraction inside the rounded line); glyph advances and kerning are each rounded to 1/1024 em before scaling by the font size; letter spacing is not added after a line's last visible glyph. Before: 24 of 131 text boxes of the design-system example matched (118 in width, 24 in height); after: 131 of 131, and all 230 self-sizing text boxes across the 7 documents (Inter only, 11–120 px, letter spacing −6% to 8%, one wrapped paragraph). Glyph positions within 0.001 px: 223 of 1,832 → 1,781; the rest are kerning pairs at weights where the installed font files differ slightly, at most 0.05 px. Frames and other layers matching in size and position: 115 of 231 → 208; the other 23 hang on one grid frame whose row positions came back as written. Not measured: other font families, fractional px line heights, centred or right-aligned text with letter spacing, lists, mixed sizes on a line. Wording only; no number moves | | 2026-10-12 | minor | `.fig` export (gap 1): written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Wording only; no number moves | | 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 2026-10-12 | minor | Experimental `.fig` export (gap 1): the whole document written from the same public format notes as the import, verified only by round trips through our own importer. Import/export breadth 84% → 86%, ready 57% → 58%; file formats 59% → 60%; file exchange with Figma users stays ×0.85 (nothing shows another app opens the files); mainstream hours ~130–244 h → ~120–229 h (`.fig` export 15–30 h → checking it elsewhere 5–15 h); feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta and full parity hours unchanged at this rounding |