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