Compare commits
6 Commits
main
...
feat/text-
| Author | SHA1 | Date |
|---|---|---|
|
|
a9e54446e7 | |
|
|
12cf466211 | |
|
|
5ff22ba258 | |
|
|
61cbda9ac4 | |
|
|
5eeaf59ff3 | |
|
|
9d52f5d5ca |
|
|
@ -1670,6 +1670,7 @@ dependencies = [
|
|||
"log",
|
||||
"skrifa 0.47.0",
|
||||
"unicode-bidi",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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
|
||||
|
|
@ -19,7 +19,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~46%** | estimated: weekly areas 67.8% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~173–310 h** one agent | estimated |
|
||||
| Remaining to full parity | **~323–621 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
| Remaining to full parity | **~321–618 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
|
|
@ -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) |
|
||||
|
|
@ -195,6 +195,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 ~323–621 h → ~321–618 h |
|
||||
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
|
||||
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1325,6 +1325,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_motion_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
//! 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), "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");
|
||||
}
|
||||
// The platforms' word keys reach the same moves: Ctrl+arrow on Windows and Linux, Alt on macOS.
|
||||
use crate::keymap::{Mods, Platform, text_motion};
|
||||
for (platform, m) in [(Platform::Other, Mods { ctrl: true, ..Default::default() }), (Platform::Mac, Mods { alt: true, ..Default::default() })] {
|
||||
crate::textedit::set_caret(&mut s, 0, false).unwrap();
|
||||
let (word, line) = text_motion(platform, "ArrowRight", m);
|
||||
key(&mut s, "ArrowRight", false, word, line).unwrap();
|
||||
assert_eq!(caret(&s).1, 3, "{platform:?}: one word right");
|
||||
}
|
||||
}
|
||||
|
||||
#[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; ascenders like "l" may poke a few
|
||||
// px above it, by how much depends on the font); 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| (-8.0..=2.0).contains(&(f64::from(y) + eb.y0))), "{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");
|
||||
}
|
||||
|
|
@ -99,26 +99,59 @@ 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
|
||||
|
|
@ -148,7 +181,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 {
|
||||
|
|
@ -157,7 +190,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)
|
||||
};
|
||||
|
|
@ -192,8 +225,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" if line => 0,
|
||||
"End" if line => n,
|
||||
|
|
@ -317,25 +349,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
//! 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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
//! 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,
|
||||
}
|
||||
|
||||
/// Opening brackets and quotes (line-end prohibited).
|
||||
const OPEN: &str = "([{‘“〈《「『【〔〖〘〚〝([{「";
|
||||
/// Closing brackets, quotes and punctuation, iteration marks, small kana and the prolonged sound
|
||||
/// mark (line-start prohibited; the small katakana extensions U+31F0..U+31FF are a range below).
|
||||
const CLOSE: &str =
|
||||
")]},.:;!?’”‥…‼⁇⁈⁉、。々〉》」』】〕〗〙〛〜〞〟〻ぁぃぅぇぉっゃゅょゎゕゖゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾ!),.:;?]}。」、・ー";
|
||||
|
||||
fn class(c: char) -> Class {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
return Class::Other;
|
||||
}
|
||||
if OPEN.contains(c) {
|
||||
return Class::Open;
|
||||
}
|
||||
if CLOSE.contains(c) || ('\u{31F0}'..='\u{31FF}').contains(&c) {
|
||||
return Class::Close;
|
||||
}
|
||||
match c {
|
||||
' ' | '\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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@
|
|||
//! fills [`Glyph::path`]s and the editor uses [`TextLayout::caret`] / [`TextLayout::hit`].
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod breaks;
|
||||
pub mod display;
|
||||
pub mod fontdb;
|
||||
mod layout;
|
||||
mod onpath;
|
||||
|
||||
pub use breaks::grapheme_step;
|
||||
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,
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -787,6 +788,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)"
|
||||
|
|
@ -1037,6 +1039,7 @@ name = "English"
|
|||
"Splay" = "Splay"
|
||||
"Spread" = "Spread"
|
||||
"Square" = "Square"
|
||||
"Standard" = "Standard"
|
||||
"Standard ligatures" = "Standard ligatures"
|
||||
"Star" = "Star"
|
||||
"Stars" = "Stars"
|
||||
|
|
@ -1181,6 +1184,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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -788,6 +789,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)"
|
||||
|
|
@ -1038,6 +1040,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"
|
||||
|
|
@ -1182,6 +1185,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"
|
||||
|
|
|
|||
|
|
@ -438,14 +438,26 @@ 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));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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
|
||||
|
|
@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
|||
| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) |
|
||||
| Mainstream practitioner | **~46%** | estimated ([below](#mainstream-practitioner): weekly areas 67.8% × discounts 0.9 × 0.9 × 0.85) |
|
||||
| Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~323–621 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~321–618 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files 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,8 +115,8 @@ 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 | **~80%** | **~58%** | | **165–335** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 60–116 |
|
||||
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **163–332** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 58–113 |
|
||||
|
||||
## By dimension (ready for real work)
|
||||
|
||||
|
|
@ -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 | **~49%** | **~323–621** | | |
|
||||
| **Ready for real work** | 100 | **~49%** | **~321–618** | | |
|
||||
|
||||
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 ~323–621 h → ~321–618 h |
|
||||
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
|
||||
| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~323–621 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 |
|
||||
|
|
|
|||
|
|
@ -60,7 +60,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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue