collective

Browse / github.com/storytold/figcraft / patch

Enforce Rust coding standards via Clippy

Enforce Rust coding standards via Clippy Denies `clippy::cognitive_complexity` (threshold 30, matches Sonar rust:S3776), `too_many_arguments` and `wildcard_imports` workspace-wide. Existing violations are fixed in follow-up commits. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Chores** * Updated development checks to flag excessive complexity, long parameter lists, and wildcard imports, with consistent enforcement across the workspace. * **Refactor** * Reorganized flow and text layout calculations while preserving existing wrapping,

Status
open
Score
0.00
Adopters
0 other operators
Author
dylanmtaylor (imported from pull request #1403, open)
Base
8b591f703d0106666abfe3642395e72006b4bd5e
Size
+665 −417 in 28 files
Review
pending
Shared
2026-10-28
Id
d0635270873fec6ec9de4145

How it went

No other operator has reported using it yet.

Diff

raw · CLAUDE.md Cargo.toml clippy.toml crates/doc/src/node.rs crates/format/src/fig/assets_tests.rs crates/format/src/fig/export/assets.rs crates/format/src/fig/export/common.rs crates/format/src/fig/export/component.rs crates/format/src/fig/export/layer.rs crates/format/src/fig/export/paint.rs crates/format/src/fig/export/prototype.rs crates/format/src/fig/export/shape.rs crates/format/src/fig/kiwi.rs crates/format/src/fig/map/component.rs crates/format/src/fig/map/connector.rs crates/format/src/fig/map/document.rs crates/format/src/fig/map/layer.rs crates/format/src/fig/map/layout.rs crates/format/src/fig/map/paint.rs crates/format/src/fig/map/proto.rs crates/format/src/fig/map/text.rs crates/format/src/fig/map/variables.rs crates/format/src/fig/source_identity_tests.rs crates/format/src/fig/tests.rs crates/format/src/rest/proto_variable_tests.rs crates/layout/src/lib.rs crates/text/src/layout.rs xtask/Cargo.toml

diff --git a/CLAUDE.md b/CLAUDE.md--- a/CLAUDE.md+++ b/CLAUDE.md@@ -62,6 +62,7 @@ code. - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code   (workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for   data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.+- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document   is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are   safety nets, not substitutes for `Result`.diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -102,6 +102,9 @@ webbrowser = "1" unsafe_code = "forbid"  [workspace.lints.clippy]+cognitive_complexity = "deny"+too_many_arguments = "deny"+wildcard_imports = "deny" dbg_macro = "warn" # Style lints from newer toolchains that fight index-heavy pixel code. needless_range_loop = "allow"diff --git a/clippy.toml b/clippy.toml--- a/clippy.toml+++ b/clippy.toml@@ -7,3 +7,4 @@ too-many-arguments-threshold = 10 disallowed-types = [     { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ]+cognitive-complexity-threshold = 30diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs--- a/crates/doc/src/node.rs+++ b/crates/doc/src/node.rs@@ -1,7 +1,10 @@ //! Nodes: one struct with the properties every node can have, and a kind with per-type data.  use crate::NodeId;-use crate::props::*;+use crate::props::{+    AutoLayout, ChildLayout, Constraints, Corners, Effect, EffectKind, ExportSetting, LayoutGrid, SPREAD_MITER_LIMIT, StrokeAlign, StrokeCap,+    StrokeProps,+}; use crate::proto::{Interaction, Overflow, ScrollPosition}; use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId}; use crate::text::TextData;diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs--- a/crates/format/src/fig/assets_tests.rs+++ b/crates/format/src/fig/assets_tests.rs@@ -274,19 +274,30 @@ fn variables_modes_and_bindings_round_trip() { #[test] fn variables_are_written_as_nodes_on_a_hidden_page() {     let nodes = written(&themed());-    let hidden = named(&nodes, "Internal Only Canvas");+    assert_hidden_theme(&nodes);+    assert_variable_values(&nodes);+    assert_variable_bindings(&nodes);+}++fn assert_hidden_theme(nodes: &[Value]) {+    let hidden = named(nodes, "Internal Only Canvas");     assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false)));-    let set = named(&nodes, "Theme");+    let set = named(nodes, "Theme");     assert_eq!(set["type"], "VARIABLE_SET");     assert_eq!(set["parentIndex"]["guid"], hidden["guid"]);     let modes = set["variableSetModes"].as_array().unwrap();     assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Light", "Dark"]);-    let bg = named(&nodes, "color/bg");+}++fn assert_variable_values(nodes: &[Value]) {+    let set = named(nodes, "Theme");+    let modes = set["variableSetModes"].as_array().unwrap();+    let bg = named(nodes, "color/bg");     assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR")));     assert_eq!(bg["variableSetID"]["guid"], set["guid"]);     assert_eq!(bg["description"], "Behind everything");     assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"]));-    assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));+    assert_eq!(named(nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));     let values = bg["variableDataValues"]["entries"].as_array().unwrap();     assert_eq!(values.len(), 2);     assert_eq!(values[1]["modeID"], modes[1]["id"]);@@ -295,19 +306,25 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {         (Some("COLOR"), Some(0.25))     );     // An alias names the variable's node; every value says the type it resolves to.-    let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"];+    let accent = &named(nodes, "color/accent")["variableDataValues"]["entries"];     assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS");     assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR");-    assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]);-    assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");-    assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);-    assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);+    assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(nodes, "color/fg")["guid"]);+    assert_eq!(named(nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");+    assert_eq!(named(nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);+    assert_eq!(named(nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);+}++fn assert_variable_bindings(nodes: &[Value]) {+    let bg = named(nodes, "color/bg");+    let set = named(nodes, "Theme");+    let modes = set["variableSetModes"].as_array().unwrap();     // Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map.-    let card = named(&nodes, "Card");+    let card = named(nodes, "Card");     assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);     assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]);     assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]);-    assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]);+    assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(nodes, "space/gap")["guid"]);     let fields: Vec<&str> =         card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect();     for f in@@ -315,7 +332,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {     {         assert!(fields.contains(&f), "{f} in {fields:?}");     }-    let swatch = named(&nodes, "Swatch");+    let swatch = named(nodes, "Swatch");     assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none());     assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);     assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2);@@ -324,7 +341,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {         assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}");     }     // The page sets a mode too.-    assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]);+    assert_eq!(named(nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(nodes, "Spacing")["guid"]); }  #[test]diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs--- a/crates/format/src/fig/export/assets.rs+++ b/crates/format/src/fig/export/assets.rs@@ -1,5 +1,8 @@ use super::common::{layout_grid, node_field, prop_type, resolved_type, scope_name, variant_pairs};-use super::*;+use super::{+    AUX, Action, Affine, Bound, Color, Exporter, Fields, Kv, LAYERS, MAX_NODES, Node, NodeId, NodeKind, Overlay, OverlayPosition, Paint, StyleKind,+    TextData, VariableValue, arr, b, color, e, fl, guid, matrix, o, position, root_fields, s, u, xy,+};  impl<'d> Exporter<'d> {     /// GUIDs for every layer (pages first, then depth-first), component property definitions,diff --git a/crates/format/src/fig/export/common.rs b/crates/format/src/fig/export/common.rs--- a/crates/format/src/fig/export/common.rs+++ b/crates/format/src/fig/export/common.rs@@ -1,4 +1,8 @@-use super::*;+use super::{+    Bound, ExportSize, Fields, GAMEPAD_BUTTONS, GridAlign, GridKind, KeyMods, Kv, LetterSpacing, LineHeight, MAX_NETWORK, Node, NodeKind, Paint,+    PropDef, SCHEMA, TextCase, TextDecoration, TextStyle, VariableType, VectorNetwork, WindingRule, arr, b, color, e, face_style, fl, guid, int, o,+    s, xy,+};  /// Longest kept description of such a layer (bytes of JSON). const MAX_STASH: usize = 1 << 20;diff --git a/crates/format/src/fig/export/component.rs b/crates/format/src/fig/export/component.rs--- a/crates/format/src/fig/export/component.rs+++ b/crates/format/src/fig/export/component.rs@@ -1,7 +1,7 @@ use super::common::{     bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs, };-use super::*;+use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s};  impl<'d> Exporter<'d> {     /// A component property definition of `owner` (a component, or a set), `index`-th in its list.diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs--- a/crates/format/src/fig/export/layer.rs+++ b/crates/format/src/fig/export/layer.rs@@ -1,5 +1,9 @@ use super::common::{export_setting, layout_grid, outlined, prop_type, variant_pairs};-use super::*;+use super::{+    AUX, AutoLayout, BoolOp, Bound, ContentAlign, CrossAlign, Exporter, Fields, HConstraint, Kv, LAYERS, LayoutMode, MAX_DEPTH, MainAlign, MaskType,+    Node, NodeId, NodeKind, PropDef, STASH, Sizing, TextData, TextStyle, VConstraint, WindingRule, arr, b, blend_name, color, e, fl, grid_sizing,+    guid, int, matrix, o, position, root_fields, s, u, xy,+};  impl<'d> Exporter<'d> {     /// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree.diff --git a/crates/format/src/fig/export/paint.rs b/crates/format/src/fig/export/paint.rs--- a/crates/format/src/fig/export/paint.rs+++ b/crates/format/src/fig/export/paint.rs@@ -1,5 +1,8 @@ use super::common::one_sided;-use super::*;+use super::{+    Affine, Bound, EffectKind, Exporter, Fields, GradientKind, ImageScaleMode, Kv, MAX_IMAGE, Node, NodeKind, NoiseMode, Paint, PaintKind,+    StrokeAlign, StrokeCap, arr, b, blend_name, cap_name, color, e, fl, join_name, matrix, o, s, sha1_hex, xy,+};  impl<'d> Exporter<'d> {     /// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them.diff --git a/crates/format/src/fig/export/prototype.rs b/crates/format/src/fig/export/prototype.rs--- a/crates/format/src/fig/export/prototype.rs+++ b/crates/format/src/fig/export/prototype.rs@@ -1,5 +1,8 @@ use super::common::key_codes;-use super::*;+use super::{+    Action, Direction, Easing, Exporter, Fields, Interaction, Kv, Node, NodeId, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, arr,+    b, color, e, fl, int, o, position, s,+};  impl<'d> Exporter<'d> {     /// Interactions, scrolling, flow starts and the overlay settings this layer is opened with.diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs--- a/crates/format/src/fig/export/shape.rs+++ b/crates/format/src/fig/export/shape.rs@@ -1,5 +1,9 @@ use super::common::{style_fields, unsupported_decoration_look};-use super::*;+use super::{+    Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,+    StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,+    join_name, network, o, path_blob, s, u, xy,+};  impl<'d> Exporter<'d> {     /// Text: its style, box settings, and characters with their styled runs. `fills`: the layer'sdiff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs--- a/crates/format/src/fig/kiwi.rs+++ b/crates/format/src/fig/kiwi.rs@@ -400,7 +400,7 @@ impl<'s> Decoder<'s, '_> { /// An encoder for the same format: the `.fig` export writes its schema and message with it (and /// test fixtures are built with it). Values are checked against the schema; it never panics. pub mod encode {-    use super::*;+    use super::{BUILTINS, Field, Kind, KiwiError, Kv, MAX_DEPTH, Schema, Type, err};      #[derive(Default)]     pub struct Writer(pub Vec<u8>);diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs--- a/crates/format/src/fig/map/component.rs+++ b/crates/format/src/fig/map/component.rs@@ -1,6 +1,6 @@ use super::layer::own_text; use super::text::char_styles;-use super::*;+use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json};  /// An override row names a layer of the main by its current GUID or by the `overrideKey` the /// layer had when the row was written: the key becomes the current GUID, unless the layer isdiff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs--- a/crates/format/src/fig/map/connector.rs+++ b/crates/format/src/fig/map/connector.rs@@ -1,5 +1,5 @@ use super::layer::{cap_name, own_text, svg_path, valid_size};-use super::*;+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, guid, json, matrix, network, num, rows};  impl<'a> Mapper<'a> {     /// A connector's end caps, and when it has no saved outline or network a straight linediff --git a/crates/format/src/fig/map/document.rs b/crates/format/src/fig/map/document.rs--- a/crates/format/src/fig/map/document.rs+++ b/crates/format/src/fig/map/document.rs@@ -1,7 +1,7 @@ use super::component::{assigned, prop_guid}; use super::paint::color; use super::variables::variable_value;-use super::*;+use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows};  impl<'a> Mapper<'a> {     /// The DOCUMENT with its pages.diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs--- a/crates/format/src/fig/map/layer.rs+++ b/crates/format/src/fig/map/layer.rs@@ -2,7 +2,10 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref}; use super::connector::connector_has_saved_label; use super::layout::{layout_grid, tracks}; use super::paint::{effect, export_setting, paint, paint_bound, stroke};-use super::*;+use super::{+    Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows,+    size,+};  /// How the layers of a resized container follow it when the file gives no derived layout for /// them: a frame's by their constraints, from its main's size to its own; a group's scale with it.@@ -61,6 +64,22 @@ impl Fit {     } } +struct LayerChildren<'s, 'a> {+    id: &'s str,+    inst: Option<&'s Inst<'a>>,+    base: &'s Kv<'a>,+    child_space: Affine,+    view: &'s View<'a>,+    object: &'s mut Obj,+    ty: &'s str,+    rest_id: &'s str,+    connector_snapshot: bool,+    connector_terminal: bool,+    depth: usize,+    included: Option<&'s BTreeSet<String>>,+    slot_content: Option<String>,+}+ impl<'a> Mapper<'a> {     /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the     /// parent's coordinates to the enclosing frame's (groups and booleans place their children in@@ -136,16 +155,7 @@ impl<'a> Mapper<'a> {             connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());         // A supported connector snapshot has its own note below instead of the generic stand-in one.         let stand = stand_in(own).filter(|_| !connector_snapshot);-        let ty = match own {-            "ROUNDED_RECTANGLE" => "RECTANGLE",-            "SYMBOL" => "COMPONENT",-            "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET",-            "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP",-            "VARIABLE" | "VARIABLE_SET" => return None,-            "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR",-            "CONNECTOR" if connector_snapshot => "FRAME",-            t => stand.map_or(t, |s| s.0),-        };+        let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?;         if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {             self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));         }@@ -225,18 +235,59 @@ impl<'a> Mapper<'a> {             source.insert("slotOverride".into(), json!(true));         }         let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };-        let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() };+        let mut kids = self.map_layer_children(LayerChildren {+            id,+            inst,+            base,+            child_space,+            view: &view,+            object: &mut o,+            ty,+            rest_id: &rest_id,+            connector_snapshot,+            connector_terminal,+            depth,+            included,+            slot_content,+        });+        if stand.is_some_and(|s| s.0 == "FRAME") {+            kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));+            kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));+        }+        if !kids.is_empty() {+            o.insert("children".into(), Value::Array(kids));+        }+        Some(Value::Object(o))+    }++    fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec<Value> {+        let LayerChildren {+            id,+            inst,+            base,+            child_space,+            view,+            object: o,+            ty,+            rest_id,+            connector_snapshot,+            connector_terminal,+            depth,+            included,+            slot_content,+        } = context;+        let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(view, rest_id) } else { Vec::new() };         if connector_terminal {             // The saved vector is flattened into the connector node when no child slot remains.         } else if ty == "INSTANCE" {-            if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {+            if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) {                 let main = self.nodes.get(&sym).map(|m| View::of(m));                 let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());                 // An instance that records no auto layout of its own has its main's.                 let view = match main {                     Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {                         let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };-                        self.auto_layout(&mut o, &layered);+                        self.auto_layout(o, &layered);                         layered                     }                     _ => view.clone(),@@ -252,7 +303,7 @@ impl<'a> Mapper<'a> {                 content_inst.slot_content = true;             }             for c in self.children(&content) {-                kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included));+                kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included));             }         } else {             // Inside an instance a layer's children are its main's: they follow it from the main's@@ -263,17 +314,29 @@ impl<'a> Mapper<'a> {                 (Some(_), _) => Fit::frame(View::of(base).size(), view.size()),             };             for c in self.children(id) {-                kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included));+                kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included));             }         }-        if stand.is_some_and(|s| s.0 == "FRAME") {-            kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));-            kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));-        }-        if !kids.is_empty() {-            o.insert("children".into(), Value::Array(kids));+        kids+    }++    fn mapped_type<'s>(+        own: &'s str,+        view: &View<'a>,+        connector_snapshot: bool,+        connector_terminal: bool,+        stand: Option<(&'static str, &'static str)>,+    ) -> Option<&'s str> {+        match own {+            "ROUNDED_RECTANGLE" => Some("RECTANGLE"),+            "SYMBOL" => Some("COMPONENT"),+            "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"),+            "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"),+            "VARIABLE" | "VARIABLE_SET" => None,+            "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"),+            "CONNECTOR" if connector_snapshot => Some("FRAME"),+            t => Some(stand.map_or(t, |s| s.0)),         }-        Some(Value::Object(o))     }      /// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up itsdiff --git a/crates/format/src/fig/map/layout.rs b/crates/format/src/fig/map/layout.rs--- a/crates/format/src/fig/map/layout.rs+++ b/crates/format/src/fig/map/layout.rs@@ -1,5 +1,5 @@ use super::paint::{bound, color};-use super::*;+use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num};  impl<'a> Mapper<'a> {     pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs--- a/crates/format/src/fig/map/paint.rs+++ b/crates/format/src/fig/map/paint.rs@@ -1,6 +1,6 @@ use super::layer::cap_name; use super::variables::alias;-use super::*;+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2};  fn filter_number(value: &Kv) -> Option<f64> {     let number = match value {diff --git a/crates/format/src/fig/map/proto.rs b/crates/format/src/fig/map/proto.rs--- a/crates/format/src/fig/map/proto.rs+++ b/crates/format/src/fig/map/proto.rs@@ -1,6 +1,6 @@ use super::paint::color; use super::variables::variable_data;-use super::*;+use super::{Kv, MAX_ACTION_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, num, ref_guid};  impl<'a> Mapper<'a> {     pub(super) fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs--- a/crates/format/src/fig/map/text.rs+++ b/crates/format/src/fig/map/text.rs@@ -1,5 +1,5 @@ use super::paint::{paint, style_link};-use super::*;+use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};  /// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through. fn text_num(v: &View, key: &str) -> Option<f64> {diff --git a/crates/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs--- a/crates/format/src/fig/map/variables.rs+++ b/crates/format/src/fig/map/variables.rs@@ -1,5 +1,5 @@ use super::paint::color;-use super::*;+use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid};  impl<'a> Mapper<'a> {     pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs--- a/crates/format/src/fig/source_identity_tests.rs+++ b/crates/format/src/fig/source_identity_tests.rs@@ -983,6 +983,11 @@ fn assert_external_namespace(doc: &Document) {     let copied_frame = child(doc, copied_body, 0);     assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame");     assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame));+    assert_external_slots(doc, frame, copied_frame, main);+    assert_external_copy_state(doc, frame, copied_frame);+}++fn assert_external_slots(doc: &Document, frame: NodeId, copied_frame: NodeId, main: NodeId) {     for index in 0..2 {         let slot = child(doc, frame, index);         let copied_slot = child(doc, copied_frame, index);@@ -999,6 +1004,9 @@ fn assert_external_namespace(doc: &Document) {         assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0)));         assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0);     }+}++fn assert_external_copy_state(doc: &Document, frame: NodeId, copied_frame: NodeId) {     let old = doc.node(child(doc, frame, 0)).unwrap();     let current = doc.node(child(doc, copied_frame, 0)).unwrap();     assert!(!old.visible && old.is_mask && old.opacity < 1.0);diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs--- a/crates/format/src/fig/tests.rs+++ b/crates/format/src/fig/tests.rs@@ -630,63 +630,82 @@ fn imports_the_fixture() {     assert!(is_fig(&bytes));     let (doc, warnings) = import(&bytes, "fallback").unwrap();     assert_eq!(doc.name, "Fixture");+    assert_fixture_layers(&doc);+    assert_fixture_styles(&doc);+    assert_fixture_variables(&doc);+    assert_fixture_components(&doc);+    assert_fixture_prototype(&doc, &warnings);+}++fn assert_fixture_layers(doc: &Document) {     // The internal page is skipped.     assert_eq!(doc.pages.len(), 1);     let page = doc.node(doc.pages[0]).unwrap();     assert_eq!(page.name, "Page A");     // Siblings by position: the frame comes first even though it's listed after the component.     assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen");-    let screen = named(&doc, "Screen");+    let screen = named(doc, "Screen");     let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();     assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]);     // Image fill from the archive.-    let photo = named(&doc, "Photo");+    let photo = named(doc, "Photo");     let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };     assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));     assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0));     // Text with a bold run over "the".-    let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };+    let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") };     assert_eq!(t.characters, "Hi there");     assert_eq!(t.style.font_size, 16.0);     assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)");     assert_eq!(t.runs.len(), 1);     assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold"));     // The group's children keep their place on the page (group at 100, 100).-    assert!(matches!(named(&doc, "Group").kind, NodeKind::Group));-    let tri = named(&doc, "Tri");+    assert!(matches!(named(doc, "Group").kind, NodeKind::Group));+    let tri = named(doc, "Tri");     assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty()));     let abs = doc.world_transform(tri.id).translation();     assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}");-    assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. }));-    // The linked style supplies the paint.-    let styled = named(&doc, "Styled");+    assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. }));+}++fn assert_fixture_styles(doc: &Document) {+    let styled = named(doc, "Styled");     assert_eq!(styled.styles.fill.as_deref(), Some("1:3"));     assert!(doc.styles.iter().any(|s| s.name == "Brand"));     let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") };     assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0));-    // Variables, the fill binding and the explicit mode.+}++fn assert_fixture_variables(doc: &Document) {+    let screen = named(doc, "Screen");     assert_eq!(doc.variables.collections.len(), 1);     assert_eq!(doc.variables.variables[0].kind, VariableType::Color);-    assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));+    assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));     assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91"));-    // The component keeps its property; the instance is expanded with the value applied.-    let button = named(&doc, "Button");+}++fn assert_fixture_components(doc: &Document) {+    let button = named(doc, "Button");     assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon")));-    let inst = named(&doc, "Button copy");+    let inst = named(doc, "Button copy");     assert!(         matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false)))     );     let icon = doc.node(inst.children[0]).unwrap();     assert_eq!(icon.name, "Renamed icon");     assert!(!icon.visible);-    // Prototype: the click navigates to the component; the flow starts at the screen.-    let it = &photo.interactions[0];+}++fn assert_fixture_prototype(doc: &Document, warnings: &[String]) {+    let button = named(doc, "Button");+    let screen = named(doc, "Screen");+    let it = &named(doc, "Photo").interactions[0];     assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id));     assert_eq!(it.duration_ms, 250);     let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") };     assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id));     // The sticky note comes in as a frame, with a warning.-    assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame));+    assert!(matches!(named(doc, "Note").kind, NodeKind::Frame));     assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}"); } diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs--- a/crates/format/src/rest/proto_variable_tests.rs+++ b/crates/format/src/rest/proto_variable_tests.rs@@ -209,8 +209,14 @@ fn embedded_variables_match_separate_ones() { #[test] fn interactions_triggers_actions_and_transitions() {     let (doc, warnings) = import_with(&file_json(), Some(&variables_json()), &no_images).unwrap();-    let id = |n: &str| named(&doc, n).id;-    let button = named(&doc, "Button");+    assert_interactions(&doc, &warnings);+    assert_legacy_transition(&doc);+    assert_interactions_without_variables();+}++fn assert_interactions(doc: &Document, warnings: &[String]) {+    let id = |n: &str| named(doc, n).id;+    let button = named(doc, "Button");     let its = &button.interactions;     assert_eq!(its.len(), 8, "{its:#?}"); @@ -239,18 +245,22 @@ fn interactions_triggers_actions_and_transitions() {     assert_eq!(its[7].trigger, Trigger::MouseUp);     assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped"); -    // The legacy single transition.-    let details = named(&doc, "Details");+    assert!(!has(warnings, "modifier keys"), "{warnings:?}");+    for w in ["video triggers", "without a destination"] {+        assert!(has(warnings, w), "{w}: {warnings:?}");+    }+}++fn assert_legacy_transition(doc: &Document) {+    let id = |n: &str| named(doc, n).id;+    let details = named(doc, "Details");     assert_eq!(details.interactions.len(), 1);     let legacy = &details.interactions[0];     assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..]));     assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn));+} -    assert!(!has(&warnings, "modifier keys"), "{warnings:?}");-    for w in ["video triggers", "without a destination"] {-        assert!(has(&warnings, w), "{w}: {warnings:?}");-    }-    // Without variables, set-variable actions have nothing to set.+fn assert_interactions_without_variables() {     let (doc, warnings) = import(&file_json(), &no_images).unwrap();     assert_eq!(named(&doc, "Button").interactions.len(), 6);     assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}");diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs--- a/crates/layout/src/lib.rs+++ b/crates/layout/src/lib.rs@@ -333,6 +333,154 @@ struct Item {     base_off: f64, } +/// Split flow items into lines, preserving a single empty line when wrapping is off.+fn flow_lines(items: &[Item], wraps: bool, gap: f64, inner_main: f64) -> Vec<Vec<usize>> {+    if !wraps {+        return vec![(0..items.len()).collect()];+    }+    let mut lines = Vec::new();+    let mut current = Vec::new();+    let mut used = 0.0;+    for (i, item) in items.iter().enumerate() {+        let size = if item.fill_main { item.min_main.unwrap_or(0.0) } else { item.main };+        let added = if current.is_empty() { size } else { gap.max(0.0) + size };+        if !current.is_empty() && used + added > inner_main + 1e-6 {+            lines.push(std::mem::take(&mut current));+            used = size;+        } else {+            used += added;+        }+        current.push(i);+    }+    if !current.is_empty() {+        lines.push(current);+    }+    lines+}++/// Resolve each line's main extent and baseline-adjusted cross extent.+fn flow_line_extents(items: &mut [Item], lines: &[Vec<usize>], gap: f64) -> Vec<(f64, f64)> {+    let mut extents = Vec::with_capacity(lines.len());+    for line in lines {+        let gaps = gap * line.len().saturating_sub(1) as f64;+        let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|item| item.baseline).fold(0.0, f64::max);+        for i in line {+            if let Some(item) = items.get_mut(*i) {+                item.base_off = item.baseline.map(|baseline| line_base - baseline).unwrap_or(0.0);+            }+        }+        let in_line = || line.iter().filter_map(|i| items.get(*i));+        let main_used = in_line().map(|item| item.main).sum::<f64>() + gaps;+        let all_fill = in_line().all(|item| item.fill_cross);+        let cross = in_line().filter(|item| all_fill || !item.fill_cross).map(|item| item.base_off + item.cross).fold(0.0, f64::max);+        extents.push((main_used, cross));+    }+    extents+}++/// Resolve cross-axis line spacing and alignment before positioning the flow items.+fn align_flow_lines(items: &[Item], extents: &mut [(f64, f64)], al: &AutoLayout, inner_cross: f64, cross_gap: f64) -> (f64, f64) {+    if extents.len() <= 1 {+        return (0.0, cross_gap);+    }+    let lines_cross: f64 = extents.iter().map(|line| line.1).sum();+    let between = (extents.len() - 1) as f64;+    let spread = (inner_cross - lines_cross) / between;+    if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {+        return (0.0, spread);+    }+    let cross_gap = if al.content_align == ContentAlign::SpaceBetween { 0.0 } else { cross_gap };+    let free = inner_cross - lines_cross - cross_gap * between;+    if free > 0.0 && items.iter().all(|item| item.fill_cross) {+        let extra = free / extents.len() as f64;+        extents.iter_mut().for_each(|line| line.1 += extra);+        return (0.0, cross_gap);+    }+    let offset = match al.cross_align {+        CrossAlign::Min | CrossAlign::Baseline => 0.0,+        CrossAlign::Center => free / 2.0,+        CrossAlign::Max => free,+    };+    (offset, cross_gap)+}++/// Collect the size and sizing constraints of the children in flow order.+fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool, hug_cross: bool) -> Vec<Item> {+    let mut items = Vec::with_capacity(kids.len());+    for child in kids {+        let Some(node) = doc.node(*child) else { continue };+        let (w, h) = outer_size(doc, *child);+        let cl = &node.child_layout;+        let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };+        let (min_main, max_main, min_cross, max_cross) =+            if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };+        items.push(Item {+            id: *child,+            main: if horizontal { w } else { h },+            cross: if horizontal { h } else { w },+            // Fill needs room to fill: on an axis the frame hugs it reads as fixed.+            fill_main: sm == Sizing::Fill && !hug_main,+            fill_cross: sc == Sizing::Fill && !hug_cross,+            min_main,+            max_main,+            min_cross,+            max_cross,+            baseline: None,+            base_off: 0.0,+        });+    }+    items+}++/// Position one resolved flow line, resizing wrapped fill children across the line.+fn place_flow_line(doc: &mut Document, items: &[Item], line: &[usize], al: &AutoLayout, m: &dyn Measure, geometry: FlowLineGeometry) {+    let FlowLineGeometry { horizontal, wraps, main_start, inner_main, cross_pos, line_cross, used, gap } = geometry;+    let total = used - gap * line.len().saturating_sub(1) as f64;+    let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);+    pos += main_start;+    for i in line {+        let Some(it) = items.get(*i) else { continue };+        let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };+        let off = match al.cross_align {+            CrossAlign::Min => 0.0,+            // Baseline applies to horizontal layouts; elsewhere it reads as top/left.+            CrossAlign::Baseline => it.base_off,+            CrossAlign::Center => (line_cross - cross_size) / 2.0,+            CrossAlign::Max => line_cross - cross_size,+        };+        if it.fill_cross && wraps {+            let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };+            resize_child(doc, it.id, w, h, m);+        }+        let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };+        place(doc, it.id, x, y);+        pos += it.main + step_gap;+    }+}++struct FlowLineGeometry {+    horizontal: bool,+    wraps: bool,+    main_start: f64,+    inner_main: f64,+    cross_pos: f64,+    line_cross: f64,+    used: f64,+    gap: f64,+}++/// Distribute a line's remaining main-axis space among its fill children.+fn distribute_flow_lines(items: &mut [Item], lines: &[Vec<usize>], inner_main: f64, gap: f64, auto_gap: bool) {+    for line in lines {+        let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|item| !item.fill_main).map(|item| item.main).sum();+        let gaps = gap * line.len().saturating_sub(1) as f64;+        let fills: Vec<usize> = line.iter().copied().filter(|i| items.get(*i).is_some_and(|item| item.fill_main)).collect();+        if !fills.is_empty() {+            distribute(items, fills, inner_main - fixed - if auto_gap { 0.0 } else { gaps });+        }+    }+}+ /// Lay out one auto layout frame's children and size the frame if it hugs. pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {     let Some(_nested) = Nested::enter() else { return };@@ -364,29 +512,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {     let hug_cross = if horizontal { hug_h } else { hug_w };     let baseline = horizontal && al.cross_align == CrossAlign::Baseline; -    let mut items: Vec<Item> = Vec::with_capacity(kids.len());-    for c in &kids {-        let Some(n) = doc.node(*c) else { continue };-        let (w, h) = outer_size(doc, *c);-        let cl = &n.child_layout;-        let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };-        let (min_main, max_main, min_cross, max_cross) =-            if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };-        items.push(Item {-            id: *c,-            main: if horizontal { w } else { h },-            cross: if horizontal { h } else { w },-            // Fill needs room to fill: on an axis the frame hugs it reads as fixed.-            fill_main: sm == Sizing::Fill && !hug_main,-            fill_cross: sc == Sizing::Fill && !hug_cross,-            min_main,-            max_main,-            min_cross,-            max_cross,-            baseline: None,-            base_off: 0.0,-        });-    }+    let mut items = flow_items(doc, &kids, horizontal, hug_main, hug_cross);      let gap = finite_or_zero(al.gap);     let auto_gap = al.main_align.is_auto_gap();@@ -405,37 +531,10 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {     }      // Split into lines along the main axis (rows of a horizontal layout, columns of a vertical one).-    let mut lines: Vec<Vec<usize>> = Vec::new();-    if wraps {-        let mut cur: Vec<usize> = Vec::new();-        let mut used = 0.0;-        for (i, it) in items.iter().enumerate() {-            let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main };-            let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz };-            if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 {-                lines.push(std::mem::take(&mut cur));-                used = sz;-            } else {-                used += add;-            }-            cur.push(i);-        }-        if !cur.is_empty() {-            lines.push(cur);-        }-    } else {-        lines.push((0..items.len()).collect());-    }+    let lines = flow_lines(&items, wraps, gap, inner_main_fixed);      // Share each line's free space among its fill children.-    for line in &lines {-        let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum();-        let gaps = gap * line.len().saturating_sub(1) as f64;-        let fills: Vec<usize> = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect();-        if !fills.is_empty() {-            distribute(&mut items, fills, inner_main_fixed - fixed - if auto_gap { 0.0 } else { gaps });-        }-    }+    distribute_flow_lines(&mut items, &lines, inner_main_fixed, gap, auto_gap);     // Fill children take their main size now, so their cross size (wrapped text, a hugging frame)     // is final before the lines and the frame are sized.     for it in items.iter_mut().filter(|it| it.fill_main) {@@ -452,23 +551,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {     }      // Line extents: (main used, cross size).-    let mut line_extents: Vec<(f64, f64)> = Vec::new();-    for line in &lines {-        let gaps = gap * line.len().saturating_sub(1) as f64;-        // Baseline alignment: the line's baseline sits at the deepest first baseline.-        let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|it| it.baseline).fold(0.0, f64::max);-        for i in line {-            if let Some(it) = items.get_mut(*i) {-                it.base_off = it.baseline.map(|b| line_base - b).unwrap_or(0.0);-            }-        }-        let in_line = || line.iter().filter_map(|i| items.get(*i));-        let main_used: f64 = in_line().map(|it| it.main).sum::<f64>() + gaps;-        // A line of nothing but fill-across children keeps their own size.-        let all_fill = in_line().all(|it| it.fill_cross);-        let cross: f64 = in_line().filter(|it| all_fill || !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max);-        line_extents.push((main_used, cross));-    }+    let mut line_extents = flow_line_extents(&mut items, &lines, gap);      // Container size (on a hugging axis fill children can't fill: they count with their own size).     let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max);@@ -500,57 +583,15 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {     // Position. Several lines share the free space across the flow: packed lines sit where the     // cross alignment says (lines of nothing but fill children stretch over it instead), or the     // space goes between them.-    let mut cross_pos = if horizontal { pt } else { pl };+    let (cross_offset, cross_gap) = align_flow_lines(&items, &mut line_extents, &al, inner_cross, cross_gap);+    let mut cross_pos = (if horizontal { pt } else { pl }) + cross_offset;     let single = lines.len() == 1;-    let mut cross_gap = cross_gap;-    if lines.len() > 1 {-        let lines_cross: f64 = line_extents.iter().map(|l| l.1).sum();-        let between = (lines.len() - 1) as f64;-        let spread = (inner_cross - lines_cross) / between;-        if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {-            cross_gap = spread;-        } else {-            if al.content_align == ContentAlign::SpaceBetween {-                cross_gap = 0.0;-            }-            let free = inner_cross - lines_cross - cross_gap * between;-            if free > 0.0 && items.iter().all(|it| it.fill_cross) {-                let extra = free / lines.len() as f64;-                line_extents.iter_mut().for_each(|l| l.1 += extra);-            } else {-                cross_pos += match al.cross_align {-                    CrossAlign::Min | CrossAlign::Baseline => 0.0,-                    CrossAlign::Center => free / 2.0,-                    CrossAlign::Max => free,-                };-            }-        }-    }     for (li, line) in lines.iter().enumerate() {         let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0));         let line_cross = if single { inner_cross } else { line_cross };-        let total = used - gap * line.len().saturating_sub(1) as f64;-        let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);-        pos += if horizontal { pl } else { pt };-        for i in line {-            let Some(it) = items.get(*i) else { continue };-            let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };-            let off = match al.cross_align {-                CrossAlign::Min => 0.0,-                // Baseline applies to horizontal layouts; elsewhere it reads as top/left.-                CrossAlign::Baseline => it.base_off,-                CrossAlign::Center => (line_cross - cross_size) / 2.0,-                CrossAlign::Max => line_cross - cross_size,-            };-            // Fill across a wrapped line: its size is known only now.-            if it.fill_cross && wraps {-                let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };-                resize_child(doc, it.id, w, h, m);-            }-            let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };-            place(doc, it.id, x, y);-            pos += it.main + step_gap;-        }+        let geometry =+            FlowLineGeometry { horizontal, wraps, main_start: if horizontal { pl } else { pt }, inner_main, cross_pos, line_cross, used, gap };+        place_flow_line(doc, &items, line, &al, m, geometry);         cross_pos += line_cross + cross_gap;     } }diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs--- a/crates/text/src/layout.rs+++ b/crates/text/src/layout.rs@@ -322,9 +322,16 @@ pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {     order } -/// 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 {+struct ShapedText {+    chars: Vec<char>,+    styles: Vec<TextStyle>,+    faces: Vec<Option<Arc<Face>>>,+    fallbacks: Vec<Arc<Face>>,+    items: Vec<Item>,+    bidi: Option<Bidi>,+}++fn shape_text(text: &TextData) -> ShapedText {     let chars: Vec<char> = text.characters.chars().collect();     let spans = text.spans();     let mut styles: Vec<TextStyle> = Vec::new();@@ -365,7 +372,6 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)     // Shape each span, split by paragraph, bidi run and fallback face.     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());@@ -431,40 +437,21 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)         }     } -    // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the-    // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent-    // 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 lists: Vec<ParaList> = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect();-    let markers = list_markers(&lists);-    let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref());-    let hang_list = if text.hanging_list { step } 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.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 }-    };-    let list_of = |p: usize| lists.get(p).copied().unwrap_or_default();-    // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`).-    let line_indent_of = |p: usize, first: bool, ci: usize| {-        let pl = list_of(p);-        match pl.list {-            ListStyle::None if first => indent_at(ci),-            ListStyle::None => 0.0,-            _ => step * (f64::from(pl.indent) + 1.0) - hang_list,-        }-    };-    // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge-    // of right-aligned and justified ones (left-to-right paragraphs).-    let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified);-    let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified);+    ShapedText { chars, styles, faces, fallbacks, items, bidi }+} -    // Line breaking.-    let wrap = match text.auto_resize {-        TextAutoResize::WidthAndHeight => None,-        _ => box_width.filter(|w| w.is_finite() && *w > 0.0),-    };+fn line_break_after(items: &[Item], end: usize) -> bool {+    end.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|item| is_line_break(item.ch))+}++fn break_lines(+    items: &[Item],+    wrap: Option<f64>,+    line_indent_of: &impl Fn(usize, bool, usize) -> f64,+    rtl_para_at: &impl Fn(usize) -> bool,+    hang_start: bool,+    hang_end: bool,+) -> Vec<(usize, usize, bool, usize)> {     let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph     let mut line_start = 0;     let mut i = 0;@@ -521,11 +508,259 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)         }         i += 1;     }-    let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch));-    if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) {+    if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || line_break_after(items, l.1)) {         lines.push((line_start, items.len(), true, para));     } +    lines+}++#[derive(Clone, Copy)]+struct LayoutFinish<'a> {+    text: &'a TextData,+    box_width: Option<f64>,+    box_height: Option<f64>,+    wrap: Option<f64>,+    lines: &'a [(usize, usize, bool, usize)],+    items: &'a [Item],+    styles: &'a [TextStyle],+    faces: &'a [Option<Arc<Face>>],+    first_cap: f64,+    flow_height: f64,+}++fn glyph_at(styles: &[TextStyle], item: &Item, pen: f64, baseline: f64) -> Option<Glyph> {+    let size = styles.get(item.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * item.scale;+    (item.gid != 0 || !item.is_space()).then(|| Glyph {+        face: item.face.clone(),+        coords: item.coords.clone(),+        gid: item.gid,+        x: pen + item.dx,+        y: baseline - item.dy,+        size,+        advance: item.adv,+        char_start: item.char_start,+        char_end: item.char_end,+        style: item.style,+        rtl: item.level % 2 == 1,+        warp: Affine::IDENTITY,+    })+}++fn truncate_layout(out: &mut TextLayout, finish: &LayoutFinish<'_>) {+    let LayoutFinish { text, box_height, wrap, lines, items, styles, .. } = *finish;+    let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();+    let bottom = |line: &Line| if trim { line.baseline } else { line.top + line.height };+    // 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| 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_or(0.0, bottom);+        }+    }+    if out.truncated {+        // The last line shown runs on into the line cut after it, so the ellipsis fills the line+        // ("Lorem consec…") instead of following the last whole word ("Lorem…").+        let last = out.lines.len().checked_sub(1);+        let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));+        if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)+            && !para_end+            && !line_break_after(items, e)+            && !line.rtl+        {+            let baseline = line.baseline;+            let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);+            for it in items.get(ns..ne).unwrap_or(&[]) {+                if pen > maxw {+                    break;+                }+                if !is_line_break(it.ch) {+                    out.glyphs.extend(glyph_at(styles, it, pen, baseline));+                    pen += it.adv;+                }+            }+            let gend = out.glyphs.len();+            if let Some(l) = out.lines.last_mut() {+                l.glyph_end = gend;+            }+        }+        add_ellipsis(out, wrap);+        // Carets and selections reach every char the line shows, borrowed ones included.+        if let Some(l) = out.lines.last() {+            let shown =+                out.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start).map(|g| g.char_end).max();+            if let (Some(shown), Some(l)) = (shown, out.lines.last_mut()) {+                l.char_end = l.char_end.max(shown);+            }+        }+    }+}++fn finish_layout(out: &mut TextLayout, finish: LayoutFinish<'_>) {+    let LayoutFinish { text, box_width, faces, first_cap, flow_height, .. } = finish;+    // 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(flow_height, bottom).max(0.0);++    truncate_layout(out, &finish);++    // Horizontal alignment within the box.+    let align_width = match text.auto_resize {+        TextAutoResize::WidthAndHeight => out.width,+        _ => box_width.unwrap_or(out.width),+    };+    for li in 0..out.lines.len() {+        let Some(line) = out.lines.get(li) else { continue };+        let dx = match text.align_h {+            TextAlignH::Left | TextAlignH::Justified => 0.0,+            TextAlignH::Center => (align_width - line.width) / 2.0,+            TextAlignH::Right => align_width - line.width,+        };+        if dx != 0.0 {+            let (gs, ge) = (line.glyph_start, line.glyph_end);+            for g in out.glyphs.get_mut(gs..ge).unwrap_or(&mut []) {+                g.x += dx;+            }+            if let Some(l) = out.lines.get_mut(li) {+                l.x = dx;+            }+        }+    }++    // Decorations per contiguous glyph run of the same style on a line; a decoration colour+    // paints with a style of its own, added after the glyphs' styles.+    let mut decos = Vec::new();+    let mut deco_styles: Vec<TextStyle> = Vec::new();+    for line in &out.lines {+        let gl = out.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);+        let mut j = 0;+        while j < gl.len() {+            let Some(g0) = gl.get(j) else { break };+            // List markers and the ellipsis are not decorated.+            if g0.char_end == g0.char_start {+                j += 1;+                continue;+            }+            let st = out.styles.get(g0.style).cloned().unwrap_or_default();+            let mut k = j + 1;+            while gl.get(k).is_some_and(|g| g.style == g0.style && g.char_end > g.char_start) {+                k += 1;+            }+            if st.decoration != TextDecoration::None+                && let Some(last) = gl.get(k - 1)+            {+                let x0 = g0.x;+                let x1 = last.x + last.advance;+                // 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 size = sanitize_size(st.font_size);+                let s = size / f.upem;+                let look = st.look();+                let th = deco_len(look.thickness, size, (f.underline_thickness * s).max(0.5)).clamp(0.1, size);+                let underline = st.decoration == TextDecoration::Underline;+                // The line's top edge (an underline's own offset is measured from the baseline).+                let top = match look.offset {+                    DecorationLength::Auto => line.baseline - if underline { f.underline_pos } else { f.strike_pos } * s - th / 2.0,+                    m if underline => line.baseline + deco_len(m, size, 0.0).clamp(-size, size),+                    _ => line.baseline - f.strike_pos * s - th / 2.0,+                };+                let style = match look.color {+                    Some(c) => {+                        let own = TextStyle { fills: Some(vec![figcraft_doc::color::Paint::solid(c)]), ..st.clone() };+                        let k = deco_styles.iter().position(|d| *d == own).unwrap_or_else(|| {+                            deco_styles.push(own);+                            deco_styles.len() - 1+                        });+                        out.styles.len() + k+                    }+                    None => g0.style,+                };+                let swing = if look.style == DecorationStyle::Wavy { wave(th).0 } else { 0.0 };+                // Skip ink: the underline stops a little before each glyph crossing it.+                let gap = th.max(1.0);+                let cuts = if underline && look.skip_ink {+                    ink_cuts(gl.get(j..k).unwrap_or(&[]), top - gap / 2.0 - swing, top + th + gap / 2.0 + swing, gap)+                } else {+                    Vec::new()+                };+                let mut from = x0;+                for (c0, c1) in cuts.into_iter().chain([(x1, x1)]) {+                    if c0.min(x1) > from + 0.5 {+                        let rect = Rect::new(from, top - swing, c0.min(x1), top + th + swing);+                        decos.push(Decoration { rect, style, kind: look.style, thickness: th });+                    }+                    from = from.max(c1);+                }+            }+            j = k;+        }+    }+    out.decorations = decos;+    out.styles.extend(deco_styles);+    if let Some(tp) = &text.path {+        crate::onpath::place(out, tp, text.align_h);+    }+}++/// 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 {+    let ShapedText { chars, styles, faces, fallbacks, items, bidi } = shape_text(text);+    let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false);++    // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the+    // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent+    // 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 lists: Vec<ParaList> = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect();+    let markers = list_markers(&lists);+    let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref());+    let hang_list = if text.hanging_list { step } 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.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 }+    };+    let list_of = |p: usize| lists.get(p).copied().unwrap_or_default();+    // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`).+    let line_indent_of = |p: usize, first: bool, ci: usize| {+        let pl = list_of(p);+        match pl.list {+            ListStyle::None if first => indent_at(ci),+            ListStyle::None => 0.0,+            _ => step * (f64::from(pl.indent) + 1.0) - hang_list,+        }+    };+    // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge+    // of right-aligned and justified ones (left-to-right paragraphs).+    let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified);+    let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified);++    let wrap = match text.auto_resize {+        TextAutoResize::WidthAndHeight => None,+        _ => box_width.filter(|w| w.is_finite() && *w > 0.0),+    };+    let lines = break_lines(&items, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end);     // Line metrics and positions.     let default_style = styles.first().cloned().unwrap_or_default();     let default_face = faces.first().cloned().flatten();@@ -546,25 +781,6 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)             None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7],         }     };-    // An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space-    // the font has no glyph for).-    let glyph_at = |it: &Item, pen: f64, baseline: f64| {-        let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;-        (it.gid != 0 || !it.is_space()).then(|| Glyph {-            face: it.face.clone(),-            coords: it.coords.clone(),-            gid: it.gid,-            x: pen + it.dx,-            y: baseline - it.dy,-            size,-            advance: it.adv,-            char_start: it.char_start,-            char_end: it.char_end,-            style: it.style,-            rtl: it.level % 2 == 1,-            warp: Affine::IDENTITY,-        })-    };     let mut first_cap = 0.0;     for (li, (s, e, para_end, para)) in lines.iter().enumerate() {         if li >= max_lines {@@ -613,7 +829,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)         // Justify: extra space per gap.         let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count();         let justify_extra = match (text.align_h, wrap) {-            (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => {+            (TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => {                 ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0)             }             _ => 0.0,@@ -704,7 +920,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)             if is_line_break(it.ch) {                 continue;             }-            out.glyphs.extend(glyph_at(it, pen, baseline));+            out.glyphs.extend(glyph_at(&styles, it, pen, baseline));             pen += it.adv;             if it.stretches() {                 pen += justify_extra;@@ -723,176 +939,18 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)             width: width + line_indent - hang_l - hang_r,             x: 0.0,             para_end: *para_end,-            break_after: break_after(*e),+            break_after: line_break_after(&items, *e),             rtl: rtl_line,         });         y += lh;         para_start_line = *para_end;     }     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| 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_or(0.0, bottom);-        }-    }-    if out.truncated {-        // The last line shown runs on into the line cut after it, so the ellipsis fills the line-        // ("Lorem consec…") instead of following the last whole word ("Lorem…").-        let last = out.lines.len().checked_sub(1);-        let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));-        if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)-            && !para_end-            && !break_after(e)-            && !line.rtl-        {-            let baseline = line.baseline;-            let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);-            for it in items.get(ns..ne).unwrap_or(&[]) {-                if pen > maxw {-                    break;-                }-                if !is_line_break(it.ch) {-                    out.glyphs.extend(glyph_at(it, pen, baseline));-                    pen += it.adv;-                }-            }-            let gend = out.glyphs.len();-            if let Some(l) = out.lines.last_mut() {-                l.glyph_end = gend;-            }-        }-        add_ellipsis(&mut out, wrap);-        // Carets and selections reach every char the line shows, borrowed ones included.-        if let Some(l) = out.lines.last() {-            let shown =-                out.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start).map(|g| g.char_end).max();-            if let (Some(shown), Some(l)) = (shown, out.lines.last_mut()) {-                l.char_end = l.char_end.max(shown);-            }-        }-    }+    finish_layout(+        &mut out,+        LayoutFinish { text, box_width, box_height, wrap, lines: &lines, items: &items, styles: &styles, faces: &faces, first_cap, flow_height: y },+    ); -    // Horizontal alignment within the box.-    let align_width = match text.auto_resize {-        TextAutoResize::WidthAndHeight => out.width,-        _ => box_width.unwrap_or(out.width),-    };-    for li in 0..out.lines.len() {-        let Some(line) = out.lines.get(li) else { continue };-        let dx = match text.align_h {-            TextAlignH::Left | TextAlignH::Justified => 0.0,-            TextAlignH::Center => (align_width - line.width) / 2.0,-            TextAlignH::Right => align_width - line.width,-        };-        if dx != 0.0 {-            let (gs, ge) = (line.glyph_start, line.glyph_end);-            for g in out.glyphs.get_mut(gs..ge).unwrap_or(&mut []) {-                g.x += dx;-            }-            if let Some(l) = out.lines.get_mut(li) {-                l.x = dx;-            }-        }-    }--    // Decorations per contiguous glyph run of the same style on a line; a decoration colour-    // paints with a style of its own, added after the glyphs' styles.-    let mut decos = Vec::new();-    let mut deco_styles: Vec<TextStyle> = Vec::new();-    for line in &out.lines {-        let gl = out.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);-        let mut j = 0;-        while j < gl.len() {-            let Some(g0) = gl.get(j) else { break };-            // List markers and the ellipsis are not decorated.-            if g0.char_end == g0.char_start {-                j += 1;-                continue;-            }-            let st = out.styles.get(g0.style).cloned().unwrap_or_default();-            let mut k = j + 1;-            while gl.get(k).is_some_and(|g| g.style == g0.style && g.char_end > g.char_start) {-                k += 1;-            }-            if st.decoration != TextDecoration::None-                && let Some(last) = gl.get(k - 1)-            {-                let x0 = g0.x;-                let x1 = last.x + last.advance;-                // 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 size = sanitize_size(st.font_size);-                let s = size / f.upem;-                let look = st.look();-                let th = deco_len(look.thickness, size, (f.underline_thickness * s).max(0.5)).clamp(0.1, size);-                let underline = st.decoration == TextDecoration::Underline;-                // The line's top edge (an underline's own offset is measured from the baseline).-                let top = match look.offset {-                    DecorationLength::Auto => line.baseline - if underline { f.underline_pos } else { f.strike_pos } * s - th / 2.0,-                    m if underline => line.baseline + deco_len(m, size, 0.0).clamp(-size, size),-                    _ => line.baseline - f.strike_pos * s - th / 2.0,-                };-                let style = match look.color {-                    Some(c) => {-                        let own = TextStyle { fills: Some(vec![figcraft_doc::color::Paint::solid(c)]), ..st.clone() };-                        let k = deco_styles.iter().position(|d| *d == own).unwrap_or_else(|| {-                            deco_styles.push(own);-                            deco_styles.len() - 1-                        });-                        out.styles.len() + k-                    }-                    None => g0.style,-                };-                let swing = if look.style == DecorationStyle::Wavy { wave(th).0 } else { 0.0 };-                // Skip ink: the underline stops a little before each glyph crossing it.-                let gap = th.max(1.0);-                let cuts = if underline && look.skip_ink {-                    ink_cuts(gl.get(j..k).unwrap_or(&[]), top - gap / 2.0 - swing, top + th + gap / 2.0 + swing, gap)-                } else {-                    Vec::new()-                };-                let mut from = x0;-                for (c0, c1) in cuts.into_iter().chain([(x1, x1)]) {-                    if c0.min(x1) > from + 0.5 {-                        let rect = Rect::new(from, top - swing, c0.min(x1), top + th + swing);-                        decos.push(Decoration { rect, style, kind: look.style, thickness: th });-                    }-                    from = from.max(c1);-                }-            }-            j = k;-        }-    }-    out.decorations = decos;-    out.styles.extend(deco_styles);-    if let Some(tp) = &text.path {-        crate::onpath::place(&mut out, tp, text.align_h);-    }     out } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml--- a/xtask/Cargo.toml+++ b/xtask/Cargo.toml@@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true publish = false +[lints]+workspace = true+ [dependencies] serde_json = { workspace = true } figcraft-engine = { workspace = true }