diff --git a/ROADMAP.md b/ROADMAP.md index b7cf74f..39529b7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -124,9 +124,16 @@ outline as a filled path when a variable profile is present. ### Text on path -Allow text to reference editable vector geometry while retaining independent -text and path semantics. Support the useful subset of SVG text-path -interoperability. +Text shapes can reference native path shapes without copying their geometry. +The attachment stores a local distance, alignment, side, and forward or reverse +direction, then uses the shared path metrics for glyph placement, bounds, hit +testing, and offset handles. Editing the path updates the attached text while +preserving the text record; the selection controls can attach, detach, and +change its layout settings. + +The SVG pipeline imports `textPath` references, including paths declared in +`defs`, and exports native references with the supported alignment, side, +offset, and reversal settings. ## Next: web installation and export workflows diff --git a/TODO.md b/TODO.md index 45890ed..8b5d917 100644 --- a/TODO.md +++ b/TODO.md @@ -182,14 +182,14 @@ selection, labels, and arrowheads. Build text-on-path behavior on the shared path metrics introduced for path-aware connectors rather than creating a second path-placement system. -- [ ] Define the relationship between a text object and its supporting path -- [ ] Represent text position as an offset along the supporting path -- [ ] Reuse shared path length, point, tangent, and normal queries for layout -- [ ] Support direction, alignment, side, and path reversal -- [ ] Keep the path independently editable without destroying attached text -- [ ] Add direct manipulation for text offset along the path -- [ ] Import and export representative SVG `textPath` content -- [ ] Add undo/redo and round-trip fixtures +- [x] Define the relationship between a text object and its supporting path +- [x] Represent text position as an offset along the supporting path +- [x] Reuse shared path length, point, tangent, and normal queries for layout +- [x] Support direction, alignment, side, and path reversal +- [x] Keep the path independently editable without destroying attached text +- [x] Add direct manipulation for text offset along the path +- [x] Import and export representative SVG `textPath` content +- [x] Add undo/redo and round-trip fixtures ## Clipboard and export workflows diff --git a/apps/web/src/content/docs/guide/vector-editing.md b/apps/web/src/content/docs/guide/vector-editing.md index 272a1af..57c44ef 100644 --- a/apps/web/src/content/docs/guide/vector-editing.md +++ b/apps/web/src/content/docs/guide/vector-editing.md @@ -50,6 +50,17 @@ exports from the same stored properties. SVG filters and mask forms outside this subset stay in a sanitized static fallback asset, with an import warning. They are not silently dropped. +## Text on a path + +Select one text shape and one path, then choose **Attach text to path** in the selection controls. The +text keeps its own content and style while the path remains an ordinary editable shape. Select the +attached text to change its alignment, side, or direction. Drag the handle on the path to change the +text offset. Choose **Detach text** to restore ordinary text placement. + +Text-on-path attachments use the path's local distance, so moving or editing the path keeps the text +attached. SVG import and export support `textPath` references, including supporting paths declared in +`defs`. + ## Imported SVG paths SVG paths and supported vector primitives become native Inkfinite geometry during import. Their diff --git a/crates/inkfinite-core/src/editor.rs b/crates/inkfinite-core/src/editor.rs index 09e9375..477d67e 100644 --- a/crates/inkfinite-core/src/editor.rs +++ b/crates/inkfinite-core/src/editor.rs @@ -530,6 +530,7 @@ pub fn native_properties(properties: &ShapeProperties) -> ShapeProperties { ("fontFamily", "font_family"), ("assetId", "asset_id"), ("referenceType", "reference_type"), + ("textPath", "text_path"), ] { if let Some(value) = result.remove(editor) { result.entry(native.into()).or_insert(value); @@ -549,6 +550,7 @@ fn editor_properties(properties: &ShapeProperties) -> ShapeProperties { ("font_family", "fontFamily"), ("asset_id", "assetId"), ("reference_type", "referenceType"), + ("text_path", "textPath"), ] { if let Some(value) = result.remove(native) { result.entry(editor.into()).or_insert(value); diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs index a3e1ad2..9386ecb 100644 --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -364,6 +364,9 @@ pub enum ShapePropertyError { property: String, message: String, }, + /// Text-on-path properties do not decode or fail attachment validation. + #[error("shape kind {kind} has invalid text path properties: {message}")] + InvalidText { kind: String, message: String }, /// Native path properties do not decode or fail path geometry validation. #[error("shape kind {kind} has invalid path geometry: {message}")] InvalidPath { kind: String, message: String }, @@ -1427,7 +1430,10 @@ pub fn validate_shape_properties(kind: &str, properties: &ShapeProperties) -> Re } } } - if kind == PATH_KIND { + if kind == TEXT_KIND { + validate_text_properties(properties) + .map_err(|message| ShapePropertyError::InvalidText { kind: kind.to_owned(), message })?; + } else if kind == PATH_KIND { let geometry = path_geometry_from_properties(properties) .map_err(|error| ShapePropertyError::InvalidPath { kind: kind.to_owned(), message: error.to_string() })?; validate_path_geometry(&geometry) @@ -1519,6 +1525,44 @@ pub fn normalize_shape_properties( Ok(normalized) } +fn validate_text_properties(properties: &ShapeProperties) -> Result<(), String> { + let Some(text_path) = properties.get("textPath").or_else(|| properties.get("text_path")) else { + return Ok(()); + }; + let object = text_path + .as_object() + .ok_or_else(|| "text path attachment must be an object".to_owned())?; + object + .get("pathId") + .or_else(|| object.get("path_id")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "text path attachment needs a path ID".to_owned())?; + let offset = object + .get("offset") + .and_then(Value::as_f64) + .ok_or_else(|| "text path offset must be a finite number".to_owned())?; + if !offset.is_finite() { + return Err("text path offset must be a finite number".into()); + } + if !matches!( + object.get("align").and_then(Value::as_str), + Some("start" | "center" | "end") + ) { + return Err("text path alignment must be start, center, or end".into()); + } + if !matches!(object.get("side").and_then(Value::as_str), Some("left" | "right")) { + return Err("text path side must be left or right".into()); + } + if !matches!( + object.get("direction").and_then(Value::as_str), + Some("forward" | "reverse") + ) { + return Err("text path direction must be forward or reverse".into()); + } + Ok(()) +} + fn validate_vector_effects(properties: &ShapeProperties) -> Result<(), String> { if let Some(value) = properties.get("clip_path") { let geometry: PathGeometry = serde_json::from_value(value.clone()) diff --git a/crates/inkfinite-core/src/render/mod.rs b/crates/inkfinite-core/src/render/mod.rs index 390923a..798221c 100644 --- a/crates/inkfinite-core/src/render/mod.rs +++ b/crates/inkfinite-core/src/render/mod.rs @@ -18,7 +18,7 @@ use crate::proto::Bounds; use crate::{ AssetId, AssetSource, BuiltinShapeKind, Document, DocumentSnapshot, FilterEffect, FilterPrimitive, GradientSpread, GradientUnits, LayerId, MaskEffect, MaskMode, PageId, Paint, PaintValue, PathFillRule, PathGeometry, PathSegment, - PathSubpath, ShapeId, ShapeRecord, Vec2, + PathSubpath, ShapeId, ShapeParent, ShapeRecord, Vec2, }; const DEFAULT_PADDING: f64 = 20.0; @@ -148,7 +148,9 @@ impl Renderer<'_> { let matrix = parent_matrix.then(Affine::from_transform(shape.transform)); let local_bounds = shape_local_bounds(self.document, shape)?; - let world_bounds = matrix.transform_bounds(local_bounds); + let world_bounds = self + .attached_text_bounds(shape, matrix) + .unwrap_or_else(|| matrix.transform_bounds(local_bounds)); let bound_arrow = shape.kind.as_str() == crate::ARROW_KIND && self .document @@ -204,6 +206,30 @@ impl Renderer<'_> { Ok(()) } + fn shape_matrix(&self, shape_id: &ShapeId) -> Option { + let shape = self.document.shapes.get(shape_id)?; + let local = Affine::from_transform(shape.transform); + match &shape.parent { + ShapeParent::Layer(_) => Some(local), + ShapeParent::Shape(parent_id) => self.shape_matrix(parent_id).map(|parent| parent.then(local)), + } + } + + fn attached_text_bounds(&self, shape: &ShapeRecord, _matrix: Affine) -> Option { + if shape.kind.as_str() != crate::TEXT_KIND { + return None; + } + let props: TextProps = properties(shape).ok()?; + let text_path = props.text_path?; + let path = self.document.shapes.get(&text_path.path_id)?; + if path.kind.as_str() != crate::PATH_KIND { + return None; + } + let geometry = crate::path_geometry_from_properties(&path.properties).ok()?; + let path_matrix = self.shape_matrix(&path.id)?; + Some(path_matrix.transform_bounds(path_bounds(&geometry))) + } + fn shape_element(&mut self, shape: &ShapeRecord, matrix: Affine) -> Result { let transform = affine_svg(matrix); let fill_opacity = number(f64::from( @@ -396,7 +422,8 @@ impl Renderer<'_> { })?; writeln!( output, - " ", + " ", + shape.id.as_str().replace(|character: char| !character.is_ascii_alphanumeric(), "-"), path_data(&geometry), paint(props.fill.as_ref(), &shape.id, "fill", &mut gradient_defs), path_fill_rule(props.fill_rule), @@ -482,6 +509,51 @@ impl Renderer<'_> { ) -> Result<(), SvgRenderError> { let props: TextProps = properties(shape)?; let font = self.font(shape, &props.font_family); + if let Some(text_path) = &props.text_path + && let Some(path) = self.document.shapes.get(&text_path.path_id) + && path.kind.as_str() == crate::PATH_KIND + { + let geometry = crate::path_geometry_from_properties(&path.properties).map_err(|error| { + SvgRenderError::InvalidShapeProperties { + shape_id: path.id.clone(), + kind: path.kind.to_string(), + message: error.to_string(), + } + })?; + let geometry = if text_path.direction == "reverse" { reverse_path_geometry(&geometry) } else { geometry }; + let path_matrix = self + .shape_matrix(&path.id) + .ok_or_else(|| SvgRenderError::InvalidShapeProperties { + shape_id: shape.id.clone(), + kind: shape.kind.to_string(), + message: "supporting path has no valid parent transform".into(), + })?; + let base_path_id = format!( + "inkfinite-path-{}", + path.id + .as_str() + .replace(|character: char| !character.is_ascii_alphanumeric(), "-") + ); + let path_id = + if text_path.direction == "reverse" { format!("{base_path_id}-reverse") } else { base_path_id }; + if text_path.direction == "reverse" { + writeln!( + output, + " ", + path_data(&geometry), + affine_svg(path_matrix) + ) + .expect("writing to a String cannot fail"); + } + let anchor = match text_path.align.as_str() { + "center" => "middle", + "end" => "end", + _ => "start", + }; + let color = paint(Some(&props.color), &shape.id, "fill", gradient_defs); + writeln!(output, " {}", escape_xml(font), number(props.font_size), number(text_path.offset), text_path.side, if text_path.direction == "reverse" { "rtl" } else { "ltr" }, escape_xml(&props.text)).expect("writing to a String cannot fail"); + return Ok(()); + } let lines = props.width.map_or_else( || vec![props.text.clone()], |width| wrap_text(&props.text, width, props.font_size), @@ -658,6 +730,19 @@ struct TextProps { color: PaintValue, #[serde(alias = "w")] width: Option, + #[serde(default, alias = "text_path")] + text_path: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TextPathProps { + #[serde(alias = "path_id")] + path_id: ShapeId, + offset: f64, + align: String, + side: String, + direction: String, } #[derive(Deserialize)] @@ -1082,6 +1167,46 @@ fn image_mask_path(mask: &ImageMask, width: f64, height: f64) -> String { } } +fn reverse_path_geometry(geometry: &PathGeometry) -> PathGeometry { + let subpaths = geometry + .subpaths + .iter() + .map(|subpath| { + let first = subpath.segments.first(); + if !matches!(first, Some(PathSegment::Move { .. })) || subpath.segments.len() < 2 { + return subpath.clone(); + } + let points: Vec = subpath + .segments + .iter() + .map(|segment| match segment { + PathSegment::Move { to } + | PathSegment::Line { to } + | PathSegment::Quadratic { to, .. } + | PathSegment::Cubic { to, .. } => *to, + }) + .collect(); + let mut segments = vec![PathSegment::Move { to: *points.last().unwrap_or(&Vec2 { x: 0.0, y: 0.0 }) }]; + for index in (1..subpath.segments.len()).rev() { + let segment = &subpath.segments[index]; + let to = points[index - 1]; + match segment { + PathSegment::Move { .. } => {} + PathSegment::Line { .. } => segments.push(PathSegment::Line { to }), + PathSegment::Quadratic { control, .. } => { + segments.push(PathSegment::Quadratic { control: *control, to }) + } + PathSegment::Cubic { control_1, control_2, .. } => { + segments.push(PathSegment::Cubic { control_1: *control_2, control_2: *control_1, to }) + } + } + } + PathSubpath { segments, closed: subpath.closed, handle_modes: None } + }) + .collect(); + PathGeometry { subpaths, fill_rule: geometry.fill_rule } +} + fn path_data(geometry: &PathGeometry) -> String { let mut output = String::new(); for subpath in &geometry.subpaths { diff --git a/crates/inkfinite-core/src/render/tests.rs b/crates/inkfinite-core/src/render/tests.rs index 8c8e83b..5892cde 100644 --- a/crates/inkfinite-core/src/render/tests.rs +++ b/crates/inkfinite-core/src/render/tests.rs @@ -598,6 +598,74 @@ fn renders_image_masks_captions_and_reference_cards() { assert!(rendered.svg.contains("https://example.com")); } +#[test] +fn renders_text_on_path_with_a_native_text_path_reference() { + let mut snapshot = fixture_snapshot(); + let layer_id = LayerId::from("layer:page:fixtures:default"); + let path_id = ShapeId::from("shape:text-path-support"); + let text_id = ShapeId::from("shape:text-path-label"); + add_shape( + &mut snapshot.document.shapes, + shape( + path_id.as_str(), + "path", + ShapeParent::Layer(layer_id.clone()), + 40.0, + 520.0, + 0.0, + props([ + ( + "subpaths", + serde_json::json!([{"segments":[{"type":"move","to":{"x":0.0,"y":0.0}},{"type":"line","to":{"x":220.0,"y":0.0}}],"closed":false}]), + ), + ("fill_rule", serde_json::json!("nonzero")), + ("fill", serde_json::Value::Null), + ("stroke", serde_json::json!("#111111")), + ("stroke_width", serde_json::json!(2.0)), + ]), + Vec::new(), + ), + ); + add_shape( + &mut snapshot.document.shapes, + shape( + text_id.as_str(), + "text", + ShapeParent::Layer(layer_id.clone()), + 0.0, + 0.0, + 0.0, + props([ + ("text", serde_json::json!("Label")), + ("font_size", serde_json::json!(16.0)), + ("font_family", serde_json::json!("sans-serif")), + ("color", serde_json::json!("#111111")), + ( + "text_path", + serde_json::json!({"pathId": path_id, "offset": 100.0, "align": "center", "side": "left", "direction": "reverse"}), + ), + ]), + Vec::new(), + ), + ); + snapshot + .document + .layers + .get_mut(&layer_id) + .unwrap() + .shape_ids + .extend([path_id, text_id]); + let rendered = render_svg( + &snapshot, + &SvgRenderOptions { page_id: Some(PageId::from("page:fixtures")), ..Default::default() }, + ) + .expect("text path fixture renders"); + assert!(rendered.svg.contains(", gradients: BTreeMap, effects: SvgEffects, + text_path_ids: BTreeSet, + text_path_lengths: BTreeMap, } impl ImportParser { @@ -440,12 +443,12 @@ impl ImportParser { if !style.visible { return Ok(None); } - let source_id = source_id(node); + let node_source_id = source_id(node); match element.as_str() { "g" => { let children = self.children(node, &style)?; let group = SvgGroup { - source_id, + source_id: node_source_id, transform: self.transform(node)?, style: style.native_style()?, properties: styled_group_properties(&style, &children), @@ -464,7 +467,44 @@ impl ImportParser { "image" => self.image(node, &style).map(|image| image.map(SvgImportNode::Image)), "defs" => { self.warn_definition_features(node); - Ok(None) + let mut paths = Vec::new(); + for definition in node + .descendants() + .filter(|candidate| candidate.is_element() && local_name(*candidate) == "path") + { + let Some(definition_id) = source_id(definition) else { continue }; + if !self.text_path_ids.contains(&definition_id) { + continue; + } + let definition_style = resolve_style( + parent_style, + definition, + &mut self.warnings, + &self.gradients, + &self.effects, + )?; + let mut shape = self.path(definition, &definition_style)?; + shape.properties.insert("fill".into(), Value::Null); + shape.properties.insert("stroke".into(), Value::Null); + shape.style.opacity = Opacity::new(0.0).expect("zero is a valid opacity"); + paths.push(SvgImportNode::Shape(shape)); + } + Ok((!paths.is_empty()).then_some(SvgImportNode::Group(Box::new(SvgGroup { + source_id: Some("text-path-definitions".into()), + transform: Transform { + translation: Vec2 { x: 0.0, y: 0.0 }, + rotation: 0.0, + scale_x: 1.0, + scale_y: 1.0, + }, + style: ShapeStyle { + opacity: Opacity::new(1.0).expect("one is a valid opacity"), + fill_opacity: None, + stroke_opacity: None, + }, + properties: properties([("width", json!(0.0)), ("height", json!(0.0))]), + children: paths, + })))) } "metadata" | "title" | "desc" => Ok(None), "style" => { @@ -702,19 +742,81 @@ impl ImportParser { // alphabetic baseline at `y`. Shift by the font size so imported labels // retain their expected vertical placement. let transform = self.transformed_geometry(node, x, y - style.font_size)?; + let mut properties = styled_properties( + style, + [ + ("text", json!(text)), + ("font_size", json!(style.font_size)), + ("font_family", json!(style.font_family.clone())), + ("color", paint_value(Some(color))), + ], + ); + if let Some(text_path) = node + .descendants() + .find(|descendant| descendant.is_element() && local_name(*descendant) == "textPath") + { + let href = text_path + .attribute("href") + .or_else(|| text_path.attribute(("http://www.w3.org/1999/xlink", "href"))); + if let Some(path_id) = href + .and_then(|value| value.strip_prefix('#')) + .filter(|value| !value.trim().is_empty()) + { + let offset = text_path + .attribute("startOffset") + .map(|value| { + if value.trim().ends_with('%') { + let fraction = parse_length(value, Some(1.0)) + .map_err(|_| invalid_attribute(text_path, "startOffset", value))?; + self.text_path_lengths + .get(path_id) + .map(|length| fraction * length) + .ok_or_else(|| invalid_attribute(text_path, "startOffset", value)) + } else { + parse_length(value, None).map_err(|_| invalid_attribute(text_path, "startOffset", value)) + } + }) + .transpose()? + .unwrap_or(0.0); + let align = match text_path + .attribute("text-anchor") + .or_else(|| node.attribute("text-anchor")) + { + Some("middle") => "center", + Some("end") => "end", + _ => "start", + }; + let side = match text_path.attribute("side") { + Some("right") => "right", + _ => "left", + }; + let direction = match text_path + .attribute("direction") + .or_else(|| node.attribute("direction")) + .or_else(|| text_path.attribute("dir")) + { + Some(value) if value.eq_ignore_ascii_case("rtl") || value.eq_ignore_ascii_case("reverse") => { + "reverse" + } + _ => "forward", + }; + properties.insert( + "text_path".into(), + json!({ + "pathId": path_id, + "offset": offset, + "align": align, + "side": side, + "direction": direction + }), + ); + } + } Ok(SvgShape { source_id: source_id(node), kind: ShapeKind::from(crate::TEXT_KIND), transform, - properties: styled_properties( - style, - [ - ("text", json!(text)), - ("font_size", json!(style.font_size)), - ("font_family", json!(style.font_family.clone())), - ("color", paint_value(Some(color))), - ], - ), + properties, style: style.native_style()?, }) } @@ -924,7 +1026,17 @@ pub fn parse_svg(source: &str) -> Result { let source_asset = make_source_asset(source.as_bytes()); let gradients = collect_gradients(root, view_box)?; let effects = collect_svg_effects(root, view_box)?; - let mut parser = ImportParser { assets: Vec::new(), warnings: Vec::new(), view_box, gradients, effects }; + let text_path_ids = collect_text_path_ids(root); + let text_path_lengths = collect_text_path_lengths(root); + let mut parser = ImportParser { + assets: Vec::new(), + warnings: Vec::new(), + view_box, + gradients, + effects, + text_path_ids, + text_path_lengths, + }; parser.warn_event_handlers(root); let root_style = resolve_style( &SvgStyle::default(), @@ -1350,6 +1462,31 @@ struct ResolvedGradient { stops: Vec, } +fn collect_text_path_ids(root: Node<'_, '_>) -> BTreeSet { + root.descendants() + .filter(|node| node.is_element() && local_name(*node) == "textPath") + .filter_map(|node| { + node.attribute("href") + .or_else(|| node.attribute(("http://www.w3.org/1999/xlink", "href"))) + .and_then(|value| value.strip_prefix('#')) + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + }) + .collect() +} + +fn collect_text_path_lengths(root: Node<'_, '_>) -> BTreeMap { + root.descendants() + .filter(|node| node.is_element() && local_name(*node) == "path") + .filter_map(|node| { + let id = node.attribute("id")?.trim(); + let data = node.attribute("d")?; + let geometry = normalize_path(data, PathFillRule::NonZero).ok()?; + Some((id.to_owned(), path_length(&geometry, DEFAULT_PATH_METRIC_TOLERANCE))) + }) + .collect() +} + fn collect_gradients( root: Node<'_, '_>, view_box: Option, ) -> Result, SvgImportError> { diff --git a/crates/inkfinite-core/src/svg_transaction.rs b/crates/inkfinite-core/src/svg_transaction.rs index 1a454c7..dc97dd5 100644 --- a/crates/inkfinite-core/src/svg_transaction.rs +++ b/crates/inkfinite-core/src/svg_transaction.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use serde_json::json; +use serde_json::{Value, json}; use thiserror::Error; use crate::proto::{Operation, TransactionDraft, TransactionId}; @@ -111,6 +111,9 @@ pub fn build_svg_import_transaction( let mut ids = ShapeIdAllocator::new(&snapshot.document.shapes, &import.source_asset.id); let root_id = ids.next(); + let mut node_ids = BTreeMap::new(); + let mut source_ids = BTreeMap::new(); + allocate_group_ids(&mut ids, &mut node_ids, &mut source_ids, &import.root, ""); let root_name = options .source_name .as_deref() @@ -133,10 +136,11 @@ pub fn build_svg_import_transaction( append_group( &mut operations, &mut shape_ids, - &mut ids, &import.root, &root_id, &options, + &SvgNodeIds { node_ids: &node_ids, source_ids: &source_ids }, + "", ); Ok(SvgImportTransaction { @@ -155,14 +159,37 @@ pub fn build_svg_import_transaction( }) } +fn allocate_group_ids( + ids: &mut ShapeIdAllocator, node_ids: &mut BTreeMap, source_ids: &mut BTreeMap, + group: &SvgGroup, prefix: &str, +) { + for (index, node) in group.children.iter().enumerate() { + let key = format!("{prefix}/{index}"); + let id = ids.next(); + node_ids.insert(key.clone(), id.clone()); + if let Some(source_id) = node.source_id() { + source_ids.insert(source_id.to_owned(), id); + } + if let SvgImportNode::Group(child) = node { + allocate_group_ids(ids, node_ids, source_ids, child, &key); + } + } +} + +struct SvgNodeIds<'a> { + node_ids: &'a BTreeMap, + source_ids: &'a BTreeMap, +} + fn append_group( - operations: &mut Vec, shape_ids: &mut Vec, ids: &mut ShapeIdAllocator, group: &SvgGroup, - parent_id: &ShapeId, options: &SvgImportTransactionOptions, + operations: &mut Vec, shape_ids: &mut Vec, group: &SvgGroup, parent_id: &ShapeId, + options: &SvgImportTransactionOptions, ids: &SvgNodeIds<'_>, prefix: &str, ) { - for node in &group.children { + for (index, node) in group.children.iter().enumerate() { + let key = format!("{prefix}/{index}"); + let id = ids.node_ids[&key].clone(); match node { SvgImportNode::Group(child) => { - let id = ids.next(); let name = child .source_id .clone() @@ -172,10 +199,32 @@ fn append_group( anchor: SiblingAnchor::Last, }); shape_ids.push(id.clone()); - append_group(operations, shape_ids, ids, child, &id, options); + append_group(operations, shape_ids, child, &id, options, ids, &key); } SvgImportNode::Shape(shape) => { - let id = ids.next(); + let mut properties = shape.properties.clone(); + if shape.kind.as_str() == crate::TEXT_KIND { + let path_id = properties + .get("text_path") + .and_then(Value::as_object) + .and_then(|value| value.get("pathId").or_else(|| value.get("path_id"))) + .and_then(Value::as_str) + .and_then(|source_id| { + let base_source_id = source_id.strip_suffix("-reverse"); + base_source_id + .filter(|value| value.starts_with("inkfinite-path-")) + .and_then(|value| ids.source_ids.get(value)) + .or_else(|| ids.source_ids.get(source_id)) + }); + if let Some(path_id) = path_id { + if let Some(value) = properties.get_mut("text_path").and_then(Value::as_object_mut) { + value.insert("pathId".into(), json!(path_id)); + value.remove("path_id"); + } + } else { + properties.remove("text_path"); + } + } operations.push(Operation::CreateShape { shape: ShapeRecord { id: id.clone(), @@ -184,7 +233,7 @@ fn append_group( transform: shape.transform, child_ids: Vec::new(), layout: None, - properties: shape.properties.clone(), + properties, metadata: metadata( shape.source_id.clone().unwrap_or_else(|| "Imported SVG shape".into()), options, @@ -197,7 +246,6 @@ fn append_group( shape_ids.push(id); } SvgImportNode::Image(image) => { - let id = ids.next(); let mut properties = image.properties.clone(); properties.insert("asset_id".into(), json!(image.asset_id)); operations.push(Operation::CreateShape { @@ -365,6 +413,52 @@ mod tests { ); } + #[test] + fn resolves_svg_text_path_source_ids_to_created_shape_ids() { + let source = r##"Label"##; + let import = import_svg(source).expect("SVG text path should import"); + let current = snapshot(); + let page_id = current.document.page_ids[0].clone(); + let layer_id = current.document.pages[&page_id].layer_ids[0].clone(); + let transaction = build_svg_import_transaction( + ¤t, + &import, + SvgImportTransactionOptions { + actor_id: ActorId::from("actor:test"), + origin: Origin::Human, + page_id, + layer_id, + transaction_id: TransactionId("transaction:text-path".into()), + description: "Import SVG text path".into(), + source_name: Some("text-path.svg".into()), + timestamp: Timestamp(1), + }, + ) + .expect("transaction should build"); + let path_id = transaction + .transaction + .operations + .iter() + .find_map(|operation| match operation { + Operation::CreateShape { shape, .. } if shape.kind.as_str() == crate::PATH_KIND => { + Some(shape.id.clone()) + } + _ => None, + }) + .expect("path should be created"); + let text = transaction + .transaction + .operations + .iter() + .find_map(|operation| match operation { + Operation::CreateShape { shape, .. } if shape.kind.as_str() == crate::TEXT_KIND => Some(shape), + _ => None, + }) + .expect("text should be created"); + assert_eq!(text.properties["text_path"]["pathId"], json!(path_id)); + assert_eq!(text.properties["text_path"]["offset"], json!(80.0)); + } + #[test] fn maps_embedded_svg_images_to_native_shapes_and_assets() { let source = r#""#; diff --git a/fixtures/native/rendering/path.svg b/fixtures/native/rendering/path.svg index ce2ffa6..c198927 100644 --- a/fixtures/native/rendering/path.svg +++ b/fixtures/native/rendering/path.svg @@ -4,7 +4,7 @@ - + diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index 0a4e83f..6d9ae35 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -1,31 +1,36 @@ -import { - arrowGeometryForShape, - getStrokeOutline, - localToWorld, - pathGeometryBounds, - shapeBounds, -} from "./geom"; -import { arrowHeadGeometry, arrowLabelPlacement, arrowShaftGeometry } from "./arrow-geometry"; -import { paintToSvg } from "./paint"; -import type { Box2 } from "./math"; -import { Box2 as Box2Ops } from "./math"; -import type { ArrowShape, ContainerShape, EllipseShape, LineShape, MarkdownShape, PathGeometry, PathShape, RectShape, ShapeRecord, TextShape } from "./model"; -import type { EditorState } from "./reactivity"; -import { getSelectedShapes, getShapesOnCurrentPage } from "./reactivity"; +import { arrowGeometryForShape, getStrokeOutline, localToWorld, pathGeometryBounds, shapeBoundsForState } from './geom'; +import { arrowHeadGeometry, arrowLabelPlacement, arrowShaftGeometry } from './arrow-geometry'; +import { paintToSvg } from './paint'; +import type { Box2 } from './math'; +import { Box2 as Box2Ops } from './math'; +import type { + ArrowShape, + ContainerShape, + EllipseShape, + LineShape, + MarkdownShape, + PathGeometry, + PathShape, + RectShape, + ShapeRecord, + TextShape +} from './model'; +import type { EditorState } from './reactivity'; +import { getSelectedShapes, getShapesOnCurrentPage } from './reactivity'; export type ExportOptions = { - /** - * Export only selected shapes (default: false - export all) - */ - selectedOnly?: boolean; - - /** - * Include camera transform in the SVG (default: false - export in world coordinates) - * - * When false, shapes are exported in their natural world coordinates. - * When true, the camera transform is baked into the SVG viewBox. - */ - includeCamera?: boolean; + /** + * Export only selected shapes (default: false - export all) + */ + selectedOnly?: boolean; + + /** + * Include camera transform in the SVG (default: false - export in world coordinates) + * + * When false, shapes are exported in their natural world coordinates. + * When true, the camera transform is baked into the SVG viewBox. + */ + includeCamera?: boolean; }; /** @@ -37,15 +42,15 @@ export type ExportOptions = { * @returns Promise resolving to PNG blob */ export async function exportViewportToPNG(canvas: HTMLCanvasElement): Promise { - return new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } else { - reject(new Error("Failed to export canvas to PNG")); - } - }, "image/png"); - }); + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to export canvas to PNG')); + } + }, 'image/png'); + }); } /** @@ -59,51 +64,51 @@ export async function exportViewportToPNG(canvas: HTMLCanvasElement): Promise void, + state: EditorState, + renderFunction: (context: CanvasRenderingContext2D, shapes: ShapeRecord[], bounds: Box2) => void ): Promise { - const shapes = getSelectedShapes(state); - if (shapes.length === 0) { - return null; - } - - const bounds = combineBounds(shapes.map((shape) => exportBounds(state, shape))); - if (!bounds) { - return null; - } - - const padding = 20; - const width = Box2Ops.width(bounds) + padding * 2; - const height = Box2Ops.height(bounds) + padding * 2; - - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get 2D context"); - } - - context.fillStyle = "white"; - context.fillRect(0, 0, width, height); - - context.save(); - context.translate(-bounds.min.x + padding, -bounds.min.y + padding); - - renderFunction(context, shapes, bounds); - - context.restore(); - - return new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } else { - reject(new Error("Failed to export selection to PNG")); - } - }, "image/png"); - }); + const shapes = getSelectedShapes(state); + if (shapes.length === 0) { + return null; + } + + const bounds = combineBounds(shapes.map((shape) => exportBounds(state, shape))); + if (!bounds) { + return null; + } + + const padding = 20; + const width = Box2Ops.width(bounds) + padding * 2; + const height = Box2Ops.height(bounds) + padding * 2; + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Failed to get 2D context'); + } + + context.fillStyle = 'white'; + context.fillRect(0, 0, width, height); + + context.save(); + context.translate(-bounds.min.x + padding, -bounds.min.y + padding); + + renderFunction(context, shapes, bounds); + + context.restore(); + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to export selection to PNG')); + } + }, 'image/png'); + }); } /** @@ -117,317 +122,420 @@ export async function exportSelectionToPNG( * @returns SVG string */ export function exportToSVG(state: EditorState, options: ExportOptions = {}): string { - const shapes = options.selectedOnly ? getExportSelection(state) : getShapesOnCurrentPage(state); - - if (shapes.length === 0) { - return ""; - } - - const bounds = combineBounds(shapes.map((shape) => exportBounds(state, shape))); - if (!bounds) { - return ""; - } - - const padding = 20; - const width = Box2Ops.width(bounds) + padding * 2; - const height = Box2Ops.height(bounds) + padding * 2; - const offsetX = bounds.min.x - padding; - const offsetY = bounds.min.y - padding; - - const elements: string[] = [``]; - const definitions: string[] = []; - - for (const shape of shapes) { - const svg = shapeToSVG(shape, state, definitions); - if (svg) { - elements.push(wrapSemanticMetadata(shape, svg)); - } - } - - const viewBox = `${offsetX} ${offsetY} ${width} ${height}`; - - return [ - ``, - ...(definitions.length > 0 ? [`${definitions.join('')}`] : []), - ...elements, - ``, - ].join("\n"); + const shapes = options.selectedOnly ? getExportSelection(state) : getShapesOnCurrentPage(state); + + if (shapes.length === 0) { + return ''; + } + + const bounds = combineBounds(shapes.map((shape) => exportBounds(state, shape))); + if (!bounds) { + return ''; + } + + const padding = 20; + const width = Box2Ops.width(bounds) + padding * 2; + const height = Box2Ops.height(bounds) + padding * 2; + const offsetX = bounds.min.x - padding; + const offsetY = bounds.min.y - padding; + + const elements: string[] = [ + `` + ]; + const definitions: string[] = []; + + for (const shape of shapes) { + const svg = shapeToSVG(shape, state, definitions); + if (svg) { + elements.push(wrapSemanticMetadata(shape, svg)); + } + } + + const viewBox = `${offsetX} ${offsetY} ${width} ${height}`; + + return [ + ``, + ...(definitions.length > 0 ? [`${definitions.join('')}`] : []), + ...elements, + `` + ].join('\n'); } /** * Convert a single shape to SVG markup. */ function shapeToSVG(shape: ShapeRecord, state: EditorState, definitions: string[]): string | null { - const transform = `translate(${shape.x},${shape.y})${ - shape.rot === 0 ? "" : ` rotate(${(shape.rot * 180) / Math.PI})` - }`; - - switch (shape.type) { - case "rect": { - return withSvgEffects(shape, rectToSVG(shape, transform, definitions), transform, definitions); - } - case "ellipse": { - return withSvgEffects(shape, ellipseToSVG(shape, transform, definitions), transform, definitions); - } - case "line": { - return withSvgEffects(shape, lineToSVG(shape, transform, definitions), transform, definitions); - } - case "arrow": { - return withSvgEffects(shape, arrowToSVG(shape, transform, state, definitions), transform, definitions); - } - case "container": { - return withSvgEffects(shape, containerToSVG(shape, transform, definitions), transform, definitions); - } - case "text": { - return withSvgEffects(shape, textToSVG(shape, transform, definitions), transform, definitions); - } - case "path": { - return withSvgEffects(shape, pathToSVG(shape, transform, definitions), transform, definitions); - } - case "stroke": { - return withSvgEffects(shape, strokeToSVG(shape, transform, definitions), transform, definitions); - } - case "image": { - const asset = state.doc.assets?.[shape.props.assetId]; - if (!asset) return null; - const { w, h, crop, mask, caption } = shape.props; - const encoded = encodeBase64(asset.bytes); - const maskId = `inkfinite-image-mask-${shape.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`; - const maskMarkup = mask - ? `${mask.kind === 'ellipse' ? `` : ``}` - : ''; - const clip = mask ? ` clip-path="url(#${maskId})"` : ''; - const image = crop - ? `` - : ``; - const captionMarkup = caption?.trim() - ? `${escapeXML(caption)}` - : ''; - return withSvgEffects(shape, `${maskMarkup}${image}${captionMarkup}`, transform, definitions); - } - case "reference": { - const { w, h, referenceType, value, label } = shape.props; - const accent = referenceType === 'url' ? '#2563eb' : referenceType === 'file' ? '#16a34a' : '#7c3aed'; - return withSvgEffects(shape, `${referenceType.toUpperCase()}${escapeXML(label || value)}`, transform, definitions); - } - case "markdown": { - return withSvgEffects(shape, markdownToSVG(shape, transform, definitions), transform, definitions); - } - default: { - return null; - } - } + const transform = `translate(${shape.x},${shape.y})${ + shape.rot === 0 ? '' : ` rotate(${(shape.rot * 180) / Math.PI})` + }`; + + switch (shape.type) { + case 'rect': { + return withSvgEffects(shape, rectToSVG(shape, transform, definitions), transform, definitions); + } + case 'ellipse': { + return withSvgEffects(shape, ellipseToSVG(shape, transform, definitions), transform, definitions); + } + case 'line': { + return withSvgEffects(shape, lineToSVG(shape, transform, definitions), transform, definitions); + } + case 'arrow': { + return withSvgEffects(shape, arrowToSVG(shape, transform, state, definitions), transform, definitions); + } + case 'container': { + return withSvgEffects(shape, containerToSVG(shape, transform, definitions), transform, definitions); + } + case 'text': { + return withSvgEffects(shape, textToSVG(shape, transform, state, definitions), transform, definitions); + } + case 'path': { + return withSvgEffects(shape, pathToSVG(shape, transform, definitions), transform, definitions); + } + case 'stroke': { + return withSvgEffects(shape, strokeToSVG(shape, transform, definitions), transform, definitions); + } + case 'image': { + const asset = state.doc.assets?.[shape.props.assetId]; + if (!asset) return null; + const { w, h, crop, mask, caption } = shape.props; + const encoded = encodeBase64(asset.bytes); + const maskId = `inkfinite-image-mask-${shape.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + const maskMarkup = mask + ? `${mask.kind === 'ellipse' ? `` : ``}` + : ''; + const clip = mask ? ` clip-path="url(#${maskId})"` : ''; + const image = crop + ? `` + : ``; + const captionMarkup = caption?.trim() + ? `${escapeXML(caption)}` + : ''; + return withSvgEffects( + shape, + `${maskMarkup}${image}${captionMarkup}`, + transform, + definitions + ); + } + case 'reference': { + const { w, h, referenceType, value, label } = shape.props; + const accent = referenceType === 'url' ? '#2563eb' : referenceType === 'file' ? '#16a34a' : '#7c3aed'; + return withSvgEffects( + shape, + `${referenceType.toUpperCase()}${escapeXML(label || value)}`, + transform, + definitions + ); + } + case 'markdown': { + return withSvgEffects(shape, markdownToSVG(shape, transform, definitions), transform, definitions); + } + default: { + return null; + } + } } function withSvgEffects(shape: ShapeRecord, content: string, transform: string, definitions: string[]): string { - const props = shape.props; - const safeId = shape.id.replace(/[^a-zA-Z0-9_-]/g, '-'); - const attributes: string[] = []; - if (props.clipPath) { - const id = `inkfinite-clip-${safeId}`; - definitions.push(``); - attributes.push(`clip-path="url(#${id})"`); - } - if (props.maskEffect) { - const id = `inkfinite-mask-${safeId}`; - definitions.push(``); - attributes.push(`mask="url(#${id})"`); - } - if (props.filter) { - const id = `inkfinite-filter-${safeId}`; - const primitives = props.filter.primitives.map((primitive, index) => filterPrimitiveToSvg(primitive, index, id)).join(''); - definitions.push(`${primitives}`); - attributes.push(`filter="url(#${id})"`); - } - return attributes.length > 0 ? `${content}` : content; + const props = shape.props; + const safeId = shape.id.replace(/[^a-zA-Z0-9_-]/g, '-'); + const attributes: string[] = []; + if (props.clipPath) { + const id = `inkfinite-clip-${safeId}`; + definitions.push( + `` + ); + attributes.push(`clip-path="url(#${id})"`); + } + if (props.maskEffect) { + const id = `inkfinite-mask-${safeId}`; + definitions.push( + `` + ); + attributes.push(`mask="url(#${id})"`); + } + if (props.filter) { + const id = `inkfinite-filter-${safeId}`; + const primitives = props.filter.primitives + .map((primitive, index) => filterPrimitiveToSvg(primitive, index, id)) + .join(''); + definitions.push(`${primitives}`); + attributes.push(`filter="url(#${id})"`); + } + return attributes.length > 0 ? `${content}` : content; } function filterPrimitiveToSvg( - primitive: NonNullable['primitives'][number], - index: number, - filterId: string + primitive: NonNullable['primitives'][number], + index: number, + filterId: string ): string { - const input = index === 0 ? 'SourceGraphic' : `${filterId}-${index}`; - const result = `${filterId}-${index + 1}`; - switch (primitive.type) { - case 'blur': return ``; - case 'drop_shadow': return ``; - case 'saturate': return ``; - case 'hue_rotate': return ``; - case 'grayscale': return ``; - case 'brightness': return ``; - case 'contrast': { - const intercept = 0.5 - 0.5 * primitive.amount; - return ``; - } - case 'invert': return ``; - case 'sepia': { - const amount = primitive.amount; - return ``; - } - case 'opacity': return ``; - } + const input = index === 0 ? 'SourceGraphic' : `${filterId}-${index}`; + const result = `${filterId}-${index + 1}`; + switch (primitive.type) { + case 'blur': + return ``; + case 'drop_shadow': + return ``; + case 'saturate': + return ``; + case 'hue_rotate': + return ``; + case 'grayscale': + return ``; + case 'brightness': + return ``; + case 'contrast': { + const intercept = 0.5 - 0.5 * primitive.amount; + return ``; + } + case 'invert': + return ``; + case 'sepia': { + const amount = primitive.amount; + return ``; + } + case 'opacity': + return ``; + } } function rectToSVG(shape: RectShape, transform: string, definitions: string[]): string { - const { w, h, fill, stroke, radius } = shape.props; - const fillAttribute = `fill="${paintToSvg(fill, `${shape.id}-fill`, definitions)}"`; - const strokeAttribute = stroke ? `stroke="${paintToSvg(stroke, `${shape.id}-stroke`, definitions)}" stroke-width="2"` : ""; - const radiusAttribute = radius > 0 ? `rx="${radius}" ry="${radius}"` : ""; - - return ``; + const { w, h, fill, stroke, radius } = shape.props; + const fillAttribute = `fill="${paintToSvg(fill, `${shape.id}-fill`, definitions)}"`; + const strokeAttribute = stroke + ? `stroke="${paintToSvg(stroke, `${shape.id}-stroke`, definitions)}" stroke-width="2"` + : ''; + const radiusAttribute = radius > 0 ? `rx="${radius}" ry="${radius}"` : ''; + + return ``; } function ellipseToSVG(shape: EllipseShape, transform: string, definitions: string[]): string { - const { w, h, fill, stroke } = shape.props; - const cx = w / 2; - const cy = h / 2; - const rx = w / 2; - const ry = h / 2; - const fillAttribute = `fill="${paintToSvg(fill, `${shape.id}-fill`, definitions)}"`; - const strokeAttribute = stroke ? `stroke="${paintToSvg(stroke, `${shape.id}-stroke`, definitions)}" stroke-width="2"` : ""; - - return ``; + const { w, h, fill, stroke } = shape.props; + const cx = w / 2; + const cy = h / 2; + const rx = w / 2; + const ry = h / 2; + const fillAttribute = `fill="${paintToSvg(fill, `${shape.id}-fill`, definitions)}"`; + const strokeAttribute = stroke + ? `stroke="${paintToSvg(stroke, `${shape.id}-stroke`, definitions)}" stroke-width="2"` + : ''; + + return ``; } function lineToSVG(shape: LineShape, transform: string, definitions: string[]): string { - const { a, b, stroke, width } = shape.props; + const { a, b, stroke, width } = shape.props; - return ``; + return ``; } function arrowToSVG(shape: ArrowShape, transform: string, state: EditorState, definitions: string[]): string { - const geometry = arrowGeometryForShape(state, shape); - if (!geometry) return ""; - const stroke = paintToSvg(shape.props.style.stroke, `${shape.id}-stroke`, definitions); - const width = svgNumber(shape.props.style.width); - const shaft = arrowShaftGeometry(geometry.path, shape.props.style); - const elements = pathGeometryIsPolyline(shaft) - ? pathGeometryToLines(shaft, stroke, width) - : [``]; - - const head = (atStart: boolean) => { - const resolved = arrowHeadGeometry(geometry.path, atStart); - if (!resolved) return; - const headStyle = atStart ? shape.props.style.headStartStyle : shape.props.style.headEndStyle; - const points = `M ${svgNumber(resolved.tip.x)} ${svgNumber(resolved.tip.y)} L ${svgNumber(resolved.left.x)} ${svgNumber(resolved.left.y)} L ${svgNumber(resolved.right.x)} ${svgNumber(resolved.right.y)}`; - elements.push( - headStyle === "triangle" - ? `` - : `` - ); - }; - - if (shape.props.style.headEnd !== false) head(false); - if (shape.props.style.headStart) head(true); - - const label = shape.props.label; - if (label?.text) { - const placement = arrowLabelPlacement(geometry.path, label); - if (placement) { - elements.push(`${escapeXML(label.text)}`); - } - } - return `${elements.join("")}`; + const geometry = arrowGeometryForShape(state, shape); + if (!geometry) return ''; + const stroke = paintToSvg(shape.props.style.stroke, `${shape.id}-stroke`, definitions); + const width = svgNumber(shape.props.style.width); + const shaft = arrowShaftGeometry(geometry.path, shape.props.style); + const elements = pathGeometryIsPolyline(shaft) + ? pathGeometryToLines(shaft, stroke, width) + : [``]; + + const head = (atStart: boolean) => { + const resolved = arrowHeadGeometry(geometry.path, atStart); + if (!resolved) return; + const headStyle = atStart ? shape.props.style.headStartStyle : shape.props.style.headEndStyle; + const points = `M ${svgNumber(resolved.tip.x)} ${svgNumber(resolved.tip.y)} L ${svgNumber(resolved.left.x)} ${svgNumber(resolved.left.y)} L ${svgNumber(resolved.right.x)} ${svgNumber(resolved.right.y)}`; + elements.push( + headStyle === 'triangle' + ? `` + : `` + ); + }; + + if (shape.props.style.headEnd !== false) head(false); + if (shape.props.style.headStart) head(true); + + const label = shape.props.label; + if (label?.text) { + const placement = arrowLabelPlacement(geometry.path, label); + if (placement) { + elements.push( + `${escapeXML(label.text)}` + ); + } + } + return `${elements.join('')}`; } function pathGeometryIsPolyline(geometry: PathGeometry): boolean { - return geometry.subpaths.every((subpath) => subpath.segments.every((segment) => segment.type === "move" || segment.type === "line")); + return geometry.subpaths.every((subpath) => + subpath.segments.every((segment) => segment.type === 'move' || segment.type === 'line') + ); } function pathGeometryToLines(geometry: PathGeometry, stroke: string, width: string): string[] { - return geometry.subpaths.flatMap((subpath) => { - const first = subpath.segments[0]; - if (!first || first.type !== "move") return []; - let from = first.to; - return subpath.segments.slice(1).flatMap((segment) => { - if (segment.type !== "line") return []; - const line = ``; - from = segment.to; - return [line]; - }); - }); + return geometry.subpaths.flatMap((subpath) => { + const first = subpath.segments[0]; + if (!first || first.type !== 'move') return []; + let from = first.to; + return subpath.segments.slice(1).flatMap((segment) => { + if (segment.type !== 'line') return []; + const line = ``; + from = segment.to; + return [line]; + }); + }); } function pathGeometryToSVG(geometry: PathGeometry): string { - return geometry.subpaths.flatMap((subpath) => subpath.segments.map((segment) => { - switch (segment.type) { - case "move": return `M ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; - case "line": return `L ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; - case "quadratic": return `Q ${svgNumber(segment.control.x)} ${svgNumber(segment.control.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; - case "cubic": return `C ${svgNumber(segment.control_1.x)} ${svgNumber(segment.control_1.y)} ${svgNumber(segment.control_2.x)} ${svgNumber(segment.control_2.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; - } - })).join(" "); + return geometry.subpaths + .flatMap((subpath) => + subpath.segments.map((segment) => { + switch (segment.type) { + case 'move': + return `M ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + case 'line': + return `L ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + case 'quadratic': + return `Q ${svgNumber(segment.control.x)} ${svgNumber(segment.control.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + case 'cubic': + return `C ${svgNumber(segment.control_1.x)} ${svgNumber(segment.control_1.y)} ${svgNumber(segment.control_2.x)} ${svgNumber(segment.control_2.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + } + }) + ) + .join(' '); } function wrapSemanticMetadata(shape: ShapeRecord, content: string): string { - const metadata = shape.metadata; - if (!metadata) return content; - const attributes = [ - metadata.name ? ` data-name="${escapeXML(metadata.name)}"` : '', - metadata.title ? ` data-card-title="${escapeXML(metadata.title)}"` : '', - metadata.body ? ` data-card-body="${escapeXML(metadata.body)}"` : '', - metadata.role ? ` data-role="${escapeXML(metadata.role)}"` : '', - metadata.description ? ` data-description="${escapeXML(metadata.description)}"` : '', - metadata.tags.length > 0 ? ` data-tags="${escapeXML(metadata.tags.join(','))}"` : '', - metadata.source ? ` data-source="${escapeXML(metadata.source)}"` : '', - metadata.link ? ` data-link="${escapeXML(metadata.link)}"` : '', - Object.keys(metadata.customMetadata).length > 0 - ? ` data-metadata="${escapeXML(JSON.stringify(metadata.customMetadata) ?? '')}"` - : '', - ].join(''); - return `${content}`; + const metadata = shape.metadata; + if (!metadata) return content; + const attributes = [ + metadata.name ? ` data-name="${escapeXML(metadata.name)}"` : '', + metadata.title ? ` data-card-title="${escapeXML(metadata.title)}"` : '', + metadata.body ? ` data-card-body="${escapeXML(metadata.body)}"` : '', + metadata.role ? ` data-role="${escapeXML(metadata.role)}"` : '', + metadata.description ? ` data-description="${escapeXML(metadata.description)}"` : '', + metadata.tags.length > 0 ? ` data-tags="${escapeXML(metadata.tags.join(','))}"` : '', + metadata.source ? ` data-source="${escapeXML(metadata.source)}"` : '', + metadata.link ? ` data-link="${escapeXML(metadata.link)}"` : '', + Object.keys(metadata.customMetadata).length > 0 + ? ` data-metadata="${escapeXML(JSON.stringify(metadata.customMetadata) ?? '')}"` + : '' + ].join(''); + return `${content}`; } function containerToSVG(shape: ContainerShape, transform: string, definitions: string[]): string { - const { w = 0, h = 0, title, fill, stroke, radius = 0 } = shape.props; - const fillValue = paintToSvg(fill, `${shape.id}-fill`, definitions); - const strokeValue = paintToSvg(stroke, `${shape.id}-stroke`, definitions); - const elements = [``]; - if (title) elements.push(`${escapeXML(title)}`); - return elements.join(""); + const { w = 0, h = 0, title, fill, stroke, radius = 0 } = shape.props; + const fillValue = paintToSvg(fill, `${shape.id}-fill`, definitions); + const strokeValue = paintToSvg(stroke, `${shape.id}-stroke`, definitions); + const elements = [ + `` + ]; + if (title) + elements.push( + `${escapeXML(title)}` + ); + return elements.join(''); } -function textToSVG(shape: TextShape, transform: string, definitions: string[]): string { - const { text, fontSize, fontFamily, color } = shape.props; +function textToSVG(shape: TextShape, transform: string, state: EditorState, definitions: string[]): string { + const { text, fontSize, fontFamily, color, textPath } = shape.props; + const fill = paintToSvg(color, `${shape.id}-fill`, definitions); + if (!textPath) { + return `${escapeXML(text)}`; + } + const path = state.doc.shapes[textPath.pathId]; + if (!path || path.type !== 'path') { + return `${escapeXML(text)}`; + } + const basePathId = `inkfinite-path-${path.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + const pathId = textPath.direction === 'reverse' ? `${basePathId}-reverse` : basePathId; + if (textPath.direction === 'reverse' && !definitions.some((definition) => definition.includes(`id="${pathId}"`))) { + definitions.push( + `` + ); + } + const anchor = textPath.align === 'center' ? 'middle' : textPath.align; + return `${escapeXML(text)}`; +} - return `${escapeXML(text)}`; +function shapeTransformToSvg(shape: ShapeRecord): string { + return `translate(${svgNumber(shape.x)},${svgNumber(shape.y)})${shape.rot === 0 ? '' : ` rotate(${svgNumber((shape.rot * 180) / Math.PI)})`}`; } -function strokeToSVG(shape: Extract, transform: string, definitions: string[]): string { - const outline = getStrokeOutline(shape); - if (outline.length === 0) return ''; - const commands = outline.map((point, index) => `${index === 0 ? 'M' : 'L'} ${svgNumber(point.x)} ${svgNumber(point.y)}`).join(' '); - const fill = paintToSvg(shape.props.style.color, `${shape.id}-stroke`, definitions); - const opacity = svgNumber(shape.strokeOpacity ?? shape.props.style.opacity); - return ``; +function reversePathGeometry(geometry: PathGeometry): PathGeometry { + return { + ...geometry, + subpaths: geometry.subpaths.map((subpath) => { + const first = subpath.segments[0]; + if (!first || first.type !== 'move' || subpath.segments.length < 2) return { ...subpath }; + const points = subpath.segments.map((segment) => segment.to); + const segments: PathGeometry['subpaths'][number]['segments'] = [{ type: 'move', to: points.at(-1)! }]; + for (let index = subpath.segments.length - 1; index >= 1; index -= 1) { + const segment = subpath.segments[index]!; + const to = points[index - 1]!; + if (segment.type === 'line') segments.push({ type: 'line', to }); + else if (segment.type === 'quadratic') + segments.push({ type: 'quadratic', control: segment.control, to }); + else if (segment.type === 'cubic') + segments.push({ type: 'cubic', control_1: segment.control_2, control_2: segment.control_1, to }); + } + return { ...subpath, segments }; + }) + }; +} + +function strokeToSVG( + shape: Extract, + transform: string, + definitions: string[] +): string { + const outline = getStrokeOutline(shape); + if (outline.length === 0) return ''; + const commands = outline + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${svgNumber(point.x)} ${svgNumber(point.y)}`) + .join(' '); + const fill = paintToSvg(shape.props.style.color, `${shape.id}-stroke`, definitions); + const opacity = svgNumber(shape.strokeOpacity ?? shape.props.style.opacity); + return ``; } function pathToSVG(shape: PathShape, transform: string, definitions: string[]): string { - const commands = shape.props.subpaths.map((subpath) => { - const segments = subpath.segments.map((segment) => { - switch (segment.type) { - case "move": return `M ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; - case "line": return `L ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; - case "quadratic": return `Q ${svgNumber(segment.control.x)} ${svgNumber(segment.control.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; - case "cubic": return `C ${svgNumber(segment.control_1.x)} ${svgNumber(segment.control_1.y)} ${svgNumber(segment.control_2.x)} ${svgNumber(segment.control_2.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; - } - }); - if (subpath.closed) segments.push("Z"); - return segments.join(" "); - }).join(" "); - const fill = paintToSvg(shape.props.fill, `${shape.id}-fill`, definitions); - const stroke = shape.props.stroke ? ` stroke="${paintToSvg(shape.props.stroke, `${shape.id}-stroke`, definitions)}" stroke-width="${svgNumber(shape.props.stroke_width ?? 2)}"` : ""; - return ``; + const commands = shape.props.subpaths + .map((subpath) => { + const segments = subpath.segments.map((segment) => { + switch (segment.type) { + case 'move': + return `M ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + case 'line': + return `L ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + case 'quadratic': + return `Q ${svgNumber(segment.control.x)} ${svgNumber(segment.control.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + case 'cubic': + return `C ${svgNumber(segment.control_1.x)} ${svgNumber(segment.control_1.y)} ${svgNumber(segment.control_2.x)} ${svgNumber(segment.control_2.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + } + }); + if (subpath.closed) segments.push('Z'); + return segments.join(' '); + }) + .join(' '); + const fill = paintToSvg(shape.props.fill, `${shape.id}-fill`, definitions); + const stroke = shape.props.stroke + ? ` stroke="${paintToSvg(shape.props.stroke, `${shape.id}-stroke`, definitions)}" stroke-width="${svgNumber(shape.props.stroke_width ?? 2)}"` + : ''; + const id = `inkfinite-path-${shape.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + return ``; } function svgNumber(value: number): string { - if (Object.is(value, -0) || value === 0) return "0"; - return value.toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); + if (Object.is(value, -0) || value === 0) return '0'; + return value.toFixed(6).replace(/0+$/, '').replace(/\.$/, ''); } /** @@ -438,106 +546,113 @@ function svgNumber(value: number): string { * For broader interoperability, the markdown is exported as plain text with basic formatting preserved. */ function markdownToSVG(shape: MarkdownShape, transform: string, definitions: string[]): string { - const { md, w, h, fontSize, fontFamily, color, bg, border } = shape.props; - const width = w; - const height = h ?? fontSize * 10; - - const bgStyle = `background: ${paintToSvg(bg, `${shape.id}-background`, definitions)};`; - const borderStyle = border ? `border: 1px solid ${paintToSvg(border, `${shape.id}-border`, definitions)};` : ""; - - const escapedMarkdown = escapeXML(md); - - return [ - ``, - `
`, - ` ${escapedMarkdown}`, - `
`, - `
`, - ].join("\n"); + const { md, w, h, fontSize, fontFamily, color, bg, border } = shape.props; + const width = w; + const height = h ?? fontSize * 10; + + const bgStyle = `background: ${paintToSvg(bg, `${shape.id}-background`, definitions)};`; + const borderStyle = border ? `border: 1px solid ${paintToSvg(border, `${shape.id}-border`, definitions)};` : ''; + + const escapedMarkdown = escapeXML(md); + + return [ + ``, + `
`, + ` ${escapedMarkdown}`, + `
`, + `
` + ].join('\n'); } function encodeBase64(bytes: number[]): string { - if (typeof btoa !== 'function') return ''; - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); + if (typeof btoa !== 'function') return ''; + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); } /** * Escape special XML characters in strings. */ function escapeXML(string_: string): string { - return string_.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """) - .replaceAll("'", "'"); + return string_ + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } function exportBounds(state: EditorState, shape: ShapeRecord): Box2 { - if (shape.type !== "arrow") return shapeBounds(shape); - const geometry = arrowGeometryForShape(state, shape); - if (!geometry) return shapeBounds(shape); - const bounds = pathGeometryBounds(geometry.path); - const points = [ - bounds.min, - { x: bounds.max.x, y: bounds.min.y }, - bounds.max, - { x: bounds.min.x, y: bounds.max.y } - ]; - if (shape.props.style.headEnd !== false) { - const head = arrowHeadGeometry(geometry.path, false); - if (head) points.push(head.tip, head.left, head.right); - } - if (shape.props.style.headStart) { - const head = arrowHeadGeometry(geometry.path, true); - if (head) points.push(head.tip, head.left, head.right); - } - if (shape.props.label?.text) { - const placement = arrowLabelPlacement(geometry.path, shape.props.label); - if (placement) { - const halfWidth = (shape.props.label.text.length * 7 + 8) / 2; - points.push( - { x: placement.point.x - halfWidth, y: placement.point.y - 9 }, - { x: placement.point.x + halfWidth, y: placement.point.y - 9 }, - { x: placement.point.x - halfWidth, y: placement.point.y + 9 }, - { x: placement.point.x + halfWidth, y: placement.point.y + 9 } - ); - } - } - return Box2Ops.fromPoints(points.map((point) => localToWorld(shape, point))); + if (shape.type !== 'arrow') return shapeBoundsForState(state, shape); + const geometry = arrowGeometryForShape(state, shape); + if (!geometry) return shapeBoundsForState(state, shape); + const bounds = pathGeometryBounds(geometry.path); + const points = [bounds.min, { x: bounds.max.x, y: bounds.min.y }, bounds.max, { x: bounds.min.x, y: bounds.max.y }]; + if (shape.props.style.headEnd !== false) { + const head = arrowHeadGeometry(geometry.path, false); + if (head) points.push(head.tip, head.left, head.right); + } + if (shape.props.style.headStart) { + const head = arrowHeadGeometry(geometry.path, true); + if (head) points.push(head.tip, head.left, head.right); + } + if (shape.props.label?.text) { + const placement = arrowLabelPlacement(geometry.path, shape.props.label); + if (placement) { + const halfWidth = (shape.props.label.text.length * 7 + 8) / 2; + points.push( + { x: placement.point.x - halfWidth, y: placement.point.y - 9 }, + { x: placement.point.x + halfWidth, y: placement.point.y - 9 }, + { x: placement.point.x - halfWidth, y: placement.point.y + 9 }, + { x: placement.point.x + halfWidth, y: placement.point.y + 9 } + ); + } + } + return Box2Ops.fromPoints(points.map((point) => localToWorld(shape, point))); } function getExportSelection(state: EditorState): ShapeRecord[] { - const selected = new Set(state.ui.selectionIds); - return getShapesOnCurrentPage(state).filter((shape) => selected.has(shape.id) || hasSelectedAncestor(shape, selected, state)); + const selected = new Set(state.ui.selectionIds); + for (const shape of getShapesOnCurrentPage(state)) { + if (shape.type === 'text' && shape.props.textPath && selected.has(shape.id)) + selected.add(shape.props.textPath.pathId); + } + return getShapesOnCurrentPage(state).filter( + (shape) => selected.has(shape.id) || hasSelectedAncestor(shape, selected, state) + ); } function hasSelectedAncestor(shape: ShapeRecord, selected: ReadonlySet, state: EditorState): boolean { - let parentId = shape.groupId; - while (parentId) { - if (selected.has(parentId)) return true; - parentId = state.doc.shapes[parentId]?.groupId; - } - return false; + let parentId = shape.groupId; + while (parentId) { + if (selected.has(parentId)) return true; + parentId = state.doc.shapes[parentId]?.groupId; + } + return false; } /** * Combine multiple bounding boxes into a single bounding box. */ function combineBounds(boxes: Box2[]): Box2 | null { - if (boxes.length === 0) { - return null; - } - - let combined = Box2Ops.clone(boxes[0]); - for (let index = 1; index < boxes.length; index++) { - const box = boxes[index]; - combined = { - min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) }, - max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) }, - }; - } - return combined; + if (boxes.length === 0) { + return null; + } + + let combined = Box2Ops.clone(boxes[0]); + for (let index = 1; index < boxes.length; index++) { + const box = boxes[index]; + combined = { + min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) }, + max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) } + }; + } + return combined; } diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 8564c65..3c220fc 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -25,7 +25,13 @@ import type { } from './model'; import type { EditorState } from './reactivity'; import { getInteractiveShapesOnCurrentPage, getShapesOnCurrentPage } from './reactivity'; -import { flattenPath, nearestPointOnPath, transformPathGeometry } from './path-metrics'; +import { + flattenPath, + layoutTextOnPath, + nearestPointOnPath, + transformPathGeometry, + type TextPathLayout +} from './path-metrics'; const strokeOutlineCache = new WeakMap(); @@ -139,6 +145,57 @@ export function shapeBounds(shape: ShapeRecord): Box2 { return transformLocalBounds(shape, localShapeBounds(shape)); } +/** Return the supporting path for an attached text shape, if it still exists. */ +export function supportingPathForText(state: EditorState, shape: TextShape): PathShape | null { + const pathId = shape.props.textPath?.pathId; + const path = pathId ? state.doc.shapes[pathId] : undefined; + return path?.type === 'path' ? path : null; +} + +/** Return local text-on-path layout using the current supporting path geometry. */ +export function textPathLayoutForShape( + state: EditorState, + shape: TextShape, + measureText?: (value: string) => number +): { path: PathShape; layout: TextPathLayout } | null { + const path = supportingPathForText(state, shape); + const attachment = shape.props.textPath; + if (!path || !attachment) return null; + return { + path, + layout: layoutTextOnPath(path.props, shape.props.text, shape.props.fontSize, attachment, measureText) + }; +} + +/** Return the world-space bounds of a shape, resolving attached text through its path. */ +export function shapeBoundsForState(state: EditorState, shape: ShapeRecord): Box2 { + if (shape.type !== 'text') return shapeBounds(shape); + const attached = textPathLayoutForShape(state, shape); + if (!attached) return shapeBounds(shape); + return transformLocalBounds(attached.path, attached.layout.bounds); +} + +/** Return the world-space anchor used by a text-path offset handle. */ +export function textPathAnchorForShape(state: EditorState, shape: TextShape): Vec2 | null { + const attached = textPathLayoutForShape(state, shape); + return attached?.layout.anchor ? localToWorld(attached.path, attached.layout.anchor.point) : null; +} + +/** Test a world point against the approximate glyph bounds of attached text. */ +export function pointInTextPath(point: Vec2, state: EditorState, shape: TextShape, tolerance = 5): boolean { + const attached = textPathLayoutForShape(state, shape); + if (!attached) return false; + const localPoint = worldToLocal(point, attached.path); + for (const glyph of attached.layout.glyphs) { + const bounds = { + min: { x: glyph.bounds.min.x - tolerance, y: glyph.bounds.min.y - tolerance }, + max: { x: glyph.bounds.max.x + tolerance, y: glyph.bounds.max.y + tolerance } + }; + if (Box2Ops.containsPoint(bounds, localPoint)) return true; + } + return false; +} + /** Return exact local bounds for path endpoints and Bézier extrema. */ export function pathGeometryBounds(geometry: PathGeometry): Box2 { const points: Vec2[] = []; @@ -794,7 +851,9 @@ function hitTestShape(state: EditorState, shape: ShapeRecord, worldPoint: Vec2, return false; } case 'text': - return pointInText(worldPoint, shape); + return shape.props.textPath + ? pointInTextPath(worldPoint, state, shape, tolerance) + : pointInText(worldPoint, shape); case 'markdown': return pointInMarkdown(worldPoint, shape); case 'stroke': @@ -803,7 +862,7 @@ function hitTestShape(state: EditorState, shape: ShapeRecord, worldPoint: Vec2, return hitTestPath(worldPoint, shape, tolerance); case 'container': case 'reference': - return Box2Ops.containsPoint(shapeBounds(shape), worldPoint); + return Box2Ops.containsPoint(shapeBoundsForState(state, shape), worldPoint); } return false; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f02576c..1ff7ddb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ export type { } from '@inkfinite/bindings/model'; export * from './path-topology'; export * from './path-metrics'; +export * from './text-path'; export * from './paint'; export * from './persistence/desktop'; export * from './persistence/canonical'; diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index 8adcc6a..8273ef9 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -1,4 +1,4 @@ -import { shapeBounds } from './geom'; +import { shapeBoundsForState } from './geom'; import { Box2, type Box2 as Box2Type, type Vec2 } from './math'; import { createId, ShapeRecord, type ContainerShape, type ShapeRecord as Shape } from './model'; import type { EditorState } from './reactivity'; @@ -19,22 +19,25 @@ export function alignShapes(state: EditorState, shapeIds: readonly string[], ali const items = layoutItems(state, shapeIds, 2); if (items.length === 0) return state; - const target = alignmentTarget(items.map((item) => item.bounds), alignment); + const target = alignmentTarget( + items.map((item) => item.bounds), + alignment + ); const deltas = new Map(); for (const item of items) deltas.set(item.shape.id, alignmentDelta(item.bounds, alignment, target)); - return translateSelectedRoots(state, items.map((item) => item.shape), deltas); + return translateSelectedRoots( + state, + items.map((item) => item.shape), + deltas + ); } /** Places at least two selected shapes into a deterministic row-major grid. */ -export function gridShapes( - state: EditorState, - shapeIds: readonly string[], - gap = 24, - columns?: number -): EditorState { +export function gridShapes(state: EditorState, shapeIds: readonly string[], gap = 24, columns?: number): EditorState { const items = layoutItems(state, shapeIds, 2); if (items.length === 0) return state; - const requestedColumns = columns !== undefined && Number.isFinite(columns) ? Math.floor(columns) : Math.ceil(Math.sqrt(items.length)); + const requestedColumns = + columns !== undefined && Number.isFinite(columns) ? Math.floor(columns) : Math.ceil(Math.sqrt(items.length)); const columnCount = Math.max(1, Math.min(items.length, requestedColumns)); return arrangeGrid(state, items, columnCount, gap, gap); } @@ -90,11 +93,7 @@ export function graphLayout( if (source && target && source !== target) edges.add(`${source}\\u0000${target}`); } const positions = layoutGraphPositions( - items.map(({ shape, bounds }) => ({ - id: shape.id, - width: Box2.width(bounds), - height: Box2.height(bounds) - })), + items.map(({ shape, bounds }) => ({ id: shape.id, width: Box2.width(bounds), height: Box2.height(bounds) })), [...edges].map((edge) => { const [source, target] = edge.split('\\u0000'); return { source: source!, target: target! }; @@ -117,16 +116,15 @@ export function graphLayout( y: origin.y + position.y - item.bounds.min.y }); } - return translateSelectedRoots(state, items.map((item) => item.shape), deltas); + return translateSelectedRoots( + state, + items.map((item) => item.shape), + deltas + ); } /** Stacks at least two selected shapes along one axis and centers the cross-axis bounds. */ -export function stackShapes( - state: EditorState, - shapeIds: readonly string[], - axis: LayoutAxis, - gap = 24 -): EditorState { +export function stackShapes(state: EditorState, shapeIds: readonly string[], axis: LayoutAxis, gap = 24): EditorState { const items = layoutItems(state, shapeIds, 2); if (items.length === 0) return state; const ordered = items.slice().sort((left, right) => layoutOrder(left, right, axis)); @@ -140,7 +138,11 @@ export function stackShapes( deltas.set(item.shape.id, combineAxisDelta(axis, axisDelta, crossDelta)); cursor += axisSize(item.bounds, axis) + spacing(gap); } - return translateSelectedRoots(state, items.map((item) => item.shape), deltas); + return translateSelectedRoots( + state, + items.map((item) => item.shape), + deltas + ); } /** Distributes at least three selected shapes with equal gaps on one axis. */ @@ -158,7 +160,11 @@ export function distributeShapes(state: EditorState, shapeIds: readonly string[] deltas.set(item.shape.id, axisDelta(axis, cursor - axisPosition(item.bounds, axis))); cursor += axisSize(item.bounds, axis) + gap; } - return translateSelectedRoots(state, items.map((item) => item.shape), deltas); + return translateSelectedRoots( + state, + items.map((item) => item.shape), + deltas + ); } function arrangeGrid( @@ -168,12 +174,14 @@ function arrangeGrid( columnGap: number, rowGap: number ): EditorState { - const ordered = items.slice().sort( - (left, right) => - left.bounds.min.y - right.bounds.min.y || - left.bounds.min.x - right.bounds.min.x || - left.shape.id.localeCompare(right.shape.id) - ); + const ordered = items + .slice() + .sort( + (left, right) => + left.bounds.min.y - right.bounds.min.y || + left.bounds.min.x - right.bounds.min.x || + left.shape.id.localeCompare(right.shape.id) + ); const cellWidth = Math.max(...ordered.map((item) => Box2.width(item.bounds))); const cellHeight = Math.max(...ordered.map((item) => Box2.height(item.bounds))); const rowCount = Math.ceil(ordered.length / columns); @@ -190,12 +198,13 @@ function arrangeGrid( for (const [index, item] of ordered.entries()) { const column = index % columns; const row = Math.floor(index / columns); - deltas.set(item.shape.id, { - x: columnX[column] - item.bounds.min.x, - y: rowY[row] - item.bounds.min.y - }); + deltas.set(item.shape.id, { x: columnX[column] - item.bounds.min.x, y: rowY[row] - item.bounds.min.y }); } - return translateSelectedRoots(state, items.map((item) => item.shape), deltas); + return translateSelectedRoots( + state, + items.map((item) => item.shape), + deltas + ); } /** Groups selected root shapes in a new frame without changing their world positions. */ @@ -210,7 +219,7 @@ export function groupShapes(state: EditorState, shapeIds: readonly string[]): Ed const rootLayerIds = new Set(roots.map((shape) => shape.layerId).filter((id): id is string => Boolean(id))); if (rootLayerIds.size > 1) return state; - const bounds = combineBounds(roots.map(shapeBounds)); + const bounds = combineBounds(roots.map((shape) => shapeBoundsForState(state, shape))); if (!bounds) return state; const containerId = createId('shape'); const firstLayerId = roots.find((shape) => shape.layerId)?.layerId; @@ -422,7 +431,11 @@ function layoutItems(state: EditorState, shapeIds: readonly string[], minimum: n }) .sort((left, right) => left.id.localeCompare(right.id)); if (roots.length < minimum) return []; - return roots.map((shape) => ({ shape, bounds: shapeBounds(shape), locked: shapeIsLocked(state, shape) })); + return roots.map((shape) => ({ + shape, + bounds: shapeBoundsForState(state, shape), + locked: shapeIsLocked(state, shape) + })); } function shapeIsLocked(state: EditorState, shape: Shape): boolean { @@ -470,9 +483,7 @@ function axisDelta(axis: LayoutAxis, delta: number): Vec2 { } function combineAxisDelta(axis: LayoutAxis, axisDeltaValue: number, crossDelta: number): Vec2 { - return axis === 'horizontal' - ? { x: axisDeltaValue, y: crossDelta } - : { x: crossDelta, y: axisDeltaValue }; + return axis === 'horizontal' ? { x: axisDeltaValue, y: crossDelta } : { x: crossDelta, y: axisDeltaValue }; } function spacing(value: number): number { @@ -612,7 +623,9 @@ function layoutGraphPositions( const group = grouped.get(rank)!; group.sort((left, right) => ordered[left]!.id.localeCompare(ordered[right]!.id)); const rankExtent = Math.max( - ...group.map((index) => (direction === 'top-to-bottom' ? ordered[index]!.height : ordered[index]!.width)), + ...group.map((index) => + direction === 'top-to-bottom' ? ordered[index]!.height : ordered[index]!.width + ), 0 ); let crossCursor = 0; diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index dff77a7..bc546f3 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -209,12 +209,27 @@ export type ArrowProps = ShapeEffects & { label?: ArrowLabel; }; +/** Attachment settings for text laid out along a native path. + * + * `offset` is measured in the supporting path's local coordinate system. For + * centered and end-aligned text it identifies the corresponding text anchor; + * `direction: 'reverse'` traverses the path from its end to its start. + */ +export type TextPath = { + pathId: string; + offset: number; + align: 'start' | 'center' | 'end'; + side: 'left' | 'right'; + direction: 'forward' | 'reverse'; +}; + export type TextProps = ShapeEffects & { text: string; fontSize: number; fontFamily: string; color: PaintValue; w?: number; + textPath?: TextPath; }; /** Shape used to clip an image while it is rendered. */ @@ -526,6 +541,17 @@ export const ShapeRecord = { } }; } + if (shape.type === 'text') { + return { + ...shape, + ...(metadata ? { metadata } : {}), + props: { + ...shape.props, + ...cloneShapeEffects(shape.props), + textPath: shape.props.textPath ? { ...shape.props.textPath } : undefined + } + }; + } if (shape.type === 'markdown') { return { ...shape, @@ -878,6 +904,27 @@ export function validateDoc(document: Document): ValidationResult { if (shape.props.w !== undefined && shape.props.w < 0) { errors.push(`Text shape '${shapeId}' has negative width`); } + if (shape.props.textPath) { + const attachment = shape.props.textPath; + const path = document.shapes[attachment.pathId]; + if (!path || path.type !== 'path') { + errors.push( + `Text shape '${shapeId}' references a missing supporting path '${attachment.pathId}'` + ); + } + if (!Number.isFinite(attachment.offset)) { + errors.push(`Text shape '${shapeId}' has a non-finite path offset`); + } + if (!['start', 'center', 'end'].includes(attachment.align)) { + errors.push(`Text shape '${shapeId}' has invalid path alignment`); + } + if (!['left', 'right'].includes(attachment.side)) { + errors.push(`Text shape '${shapeId}' has invalid path side`); + } + if (!['forward', 'reverse'].includes(attachment.direction)) { + errors.push(`Text shape '${shapeId}' has invalid path direction`); + } + } break; } diff --git a/packages/core/src/path-metrics.ts b/packages/core/src/path-metrics.ts index 606495b..ebe06fd 100644 --- a/packages/core/src/path-metrics.ts +++ b/packages/core/src/path-metrics.ts @@ -1,6 +1,6 @@ -import type { Mat3 } from './math'; -import { Mat3 as Mat3Ops } from './math'; -import type { PathGeometry, PathSegment, PathSubpath } from './model'; +import type { Mat3, Box2 } from './math'; +import { Box2 as Box2Ops, Mat3 as Mat3Ops } from './math'; +import type { PathGeometry, PathSegment, PathSubpath, TextPath } from './model'; import type { Vec2 } from './math'; /** Default geometric error used by interactive path measurements. */ @@ -116,6 +116,79 @@ export function tangentAtPathDistance( return pointAtPathDistance(geometry, distance, tolerance)?.tangent ?? null; } +/** A glyph positioned against a supporting path. */ +export type TextPathGlyph = { character: string; point: Vec2; angle: number; advance: number; bounds: Box2 }; + +/** Layout result shared by the canvas renderer, hit testing, and SVG export. */ +export type TextPathLayout = { glyphs: TextPathGlyph[]; anchor: PathMetricPoint | null; bounds: Box2; length: number }; + +/** + * Lay out a single-line text run along native path geometry. + * + * Distances and font size use the geometry's local coordinate system. Callers + * can transform the resulting positions with the supporting shape's transform, + * which keeps path attachments stable when that shape is moved or edited. + */ +export function layoutTextOnPath( + geometry: PathGeometry, + text: string, + fontSize: number, + attachment: Pick, + measureText: (value: string) => number = (value) => fontSize * (value === ' ' ? 0.33 : 0.6) +): TextPathLayout { + const length = pathLength(geometry); + const safeFontSize = Number.isFinite(fontSize) && fontSize > 0 ? fontSize : 1; + const safeOffset = Number.isFinite(attachment.offset) ? attachment.offset : 0; + const characters = Array.from(text.replaceAll(/\r?\n/g, ' ')); + const advances = characters.map((character) => { + const measured = measureText(character); + return Number.isFinite(measured) && measured > 0 ? measured : safeFontSize * 0.6; + }); + const textWidth = advances.reduce((total, advance) => total + advance, 0); + const start = + attachment.align === 'center' + ? safeOffset - textWidth / 2 + : attachment.align === 'end' + ? safeOffset - textWidth + : safeOffset; + const orientedAnchorDistance = Math.max(0, Math.min(length, safeOffset)); + const anchorDistance = + attachment.direction === 'reverse' ? length - orientedAnchorDistance : orientedAnchorDistance; + const anchor = length > 0 ? pointAtPathDistance(geometry, anchorDistance) : null; + const glyphs: TextPathGlyph[] = []; + let advanceOffset = 0; + for (const [index, character] of characters.entries()) { + const advance = advances[index]!; + const orientedDistance = start + advanceOffset + advance / 2; + advanceOffset += advance; + if (length <= 0 || orientedDistance < 0 || orientedDistance > length) continue; + const distance = attachment.direction === 'reverse' ? length - orientedDistance : orientedDistance; + const metric = pointAtPathDistance(geometry, distance); + if (!metric) continue; + const tangent = + attachment.direction === 'reverse' ? { x: -metric.tangent.x, y: -metric.tangent.y } : metric.tangent; + const angle = Math.atan2(tangent.y, tangent.x); + const leftNormal = { x: tangent.y, y: -tangent.x }; + const normal = attachment.side === 'left' ? leftNormal : { x: -leftNormal.x, y: -leftNormal.y }; + const baseline = { + x: metric.point.x + normal.x * (attachment.side === 'right' ? safeFontSize : 0), + y: metric.point.y + normal.y * (attachment.side === 'right' ? safeFontSize : 0) + }; + const top = { x: Math.sin(angle) * safeFontSize * 0.9, y: -Math.cos(angle) * safeFontSize * 0.9 }; + const axis = { x: (tangent.x * advance) / 2, y: (tangent.y * advance) / 2 }; + const corners = [ + { x: baseline.x - axis.x, y: baseline.y - axis.y }, + { x: baseline.x + axis.x, y: baseline.y + axis.y }, + { x: baseline.x - axis.x + top.x, y: baseline.y - axis.y + top.y }, + { x: baseline.x + axis.x + top.x, y: baseline.y + axis.y + top.y } + ]; + glyphs.push({ character, point: baseline, angle, advance, bounds: Box2Ops.fromPoints(corners) }); + } + const points = glyphs.flatMap((glyph) => [glyph.bounds.min, glyph.bounds.max]); + if (anchor) points.push(anchor.point); + return { glyphs, anchor, bounds: Box2Ops.fromPoints(points), length }; +} + /** Find the closest point on a flattened path and its distance along the path. */ export function nearestPointOnPath( geometry: PathGeometry, diff --git a/packages/core/src/persistence/canonical.ts b/packages/core/src/persistence/canonical.ts index 839253f..0476349 100644 --- a/packages/core/src/persistence/canonical.ts +++ b/packages/core/src/persistence/canonical.ts @@ -610,6 +610,7 @@ function editorProperties(properties: ShapeProperties): ShapeProperties { ['reference_type', 'referenceType'], ['clip_path', 'clipPath'], ['mask_effect', 'maskEffect'], + ['text_path', 'textPath'], ['width_profile', 'widthProfile'] ] as const) { if (native in result && !(editor in result)) result[editor] = result[native]; @@ -805,6 +806,10 @@ function nativePropertiesForShape(shape: ShapeRecord): ShapeProperties { properties.mask_effect = properties.maskEffect; delete properties.maskEffect; } + if ('textPath' in properties) { + properties.text_path = properties.textPath; + delete properties.textPath; + } if (shape.type !== 'container' && !shape.groupId) return properties; if ('w' in properties) { properties.width = properties.w; diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index dbe28ed..b98089a 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -126,7 +126,7 @@ function duplicateState(state: EditorState, offset: DuplicateConnectOffset): Dup const copy = ShapeRecord.clone(source); const id = mapping.get(source.id)!; const parentId = source.groupId ? mapping.get(source.groupId) : undefined; - shapes[id] = { + const copied = { ...copy, id, x: copy.x + offset.x, @@ -140,6 +140,11 @@ function duplicateState(state: EditorState, offset: DuplicateConnectOffset): Dup : undefined, ...(parentId ? { groupId: parentId } : { groupId: undefined }) }; + shapes[id] = copied; + if (copied.type === 'text' && copied.props.textPath) { + const pathId = mapping.get(copied.props.textPath.pathId); + if (pathId) copied.props = { ...copied.props, textPath: { ...copied.props.textPath, pathId } }; + } } const pages = { ...state.doc.pages }; diff --git a/packages/core/src/text-path.ts b/packages/core/src/text-path.ts new file mode 100644 index 0000000..33158b6 --- /dev/null +++ b/packages/core/src/text-path.ts @@ -0,0 +1,47 @@ +import { pathLength } from './path-metrics'; +import type { EditorState } from './reactivity'; +import { getSelectedShapes } from './reactivity'; +import type { PathShape, TextShape } from './model'; + +/** Return the selected text and supporting path when the attachment command applies. */ +export function textPathSelectionTargets(state: EditorState): { text: TextShape; path: PathShape } | null { + if (state.ui.selectionIds.length !== 2) return null; + const selected = getSelectedShapes(state); + const text = selected.find((shape): shape is TextShape => shape.type === 'text'); + const path = selected.find((shape): shape is PathShape => shape.type === 'path'); + return text && path && text.pageId === path.pageId ? { text, path } : null; +} + +/** Whether the current selection can attach text to a native path. */ +export function canTextPathSelection(state: EditorState): boolean { + return textPathSelectionTargets(state) !== null; +} + +/** Attach the selected text to the selected path as one immutable editor update. */ +export function attachTextPathSelection(state: EditorState): EditorState | null { + const targets = textPathSelectionTargets(state); + if (!targets) return null; + const { text, path } = targets; + const textPath = { + pathId: path.id, + offset: pathLength(path.props) / 2, + align: 'center' as const, + side: 'left' as const, + direction: 'forward' as const + }; + const nextText = { ...text, props: { ...text.props, textPath } }; + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [text.id]: nextText } } }; +} + +/** Remove an existing text-path attachment without changing its text content. */ +export function detachTextPath(state: EditorState, textId: string): EditorState | null { + const text = state.doc.shapes[textId]; + if (!text || text.type !== 'text' || !text.props.textPath) return null; + return { + ...state, + doc: { + ...state.doc, + shapes: { ...state.doc.shapes, [textId]: { ...text, props: { ...text.props, textPath: undefined } } } + } + }; +} diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts index 09a8daf..c7df455 100644 --- a/packages/core/src/tools/select.ts +++ b/packages/core/src/tools/select.ts @@ -10,11 +10,14 @@ import { localToWorld, resolveArrowEndpoints, shapeBounds, + shapeBoundsForState, localShapeBounds, + supportingPathForText, + textPathAnchorForShape, shapeTransform, worldToLocal } from '../geom'; -import { nearestPointOnPath } from '../path-metrics'; +import { nearestPointOnPath, pathLength } from '../path-metrics'; import { Box2, clamp, Mat3, type Vec2, Vec2 as Vec2Ops } from '../math'; import { duplicateAndConnectSelection } from '../selection'; import { BindingRecord, createId, ShapeRecord } from '../model'; @@ -69,7 +72,8 @@ type HandleKind = | 'line-end' | `arrow-point-${number}` | 'arrow-bend' - | 'arrow-label'; + | 'arrow-label' + | 'text-path-offset'; /** Context passed to the selection snapper at each movement preview. */ export type SelectSnapContext = { @@ -219,14 +223,12 @@ export class SelectTool implements Tool { // the axis-aligned bounds. Accept that point while the visible handle uses // the shape's transformed local bounds. if ( - shape.type === 'rect' || - shape.type === 'ellipse' || - shape.type === 'text' || + (shape.type === 'text' ? !shape.props.textPath : shape.type === 'rect' || shape.type === 'ellipse') || shape.type === 'markdown' || shape.type === 'image' || shape.type === 'container' ) { - const bounds = shapeBounds(shape); + const bounds = shapeBoundsForState(state, shape); const legacyRotate = { x: (bounds.min.x + bounds.max.x) / 2, y: bounds.min.y - ROTATE_HANDLE_OFFSET }; if (Vec2Ops.dist(point, legacyRotate) <= HANDLE_HIT_RADIUS) return { handle: 'rotate', shape }; } @@ -431,6 +433,8 @@ export class SelectTool implements Tool { updated = this.rotateShape(state, initialShape, snappedPoint, action.modifiers.shift); } else if (this.toolState.activeHandle === 'arrow-label') { updated = this.adjustArrowLabel(state, initialShape, snappedPoint); + } else if (this.toolState.activeHandle === 'text-path-offset') { + updated = this.adjustTextPath(state, initialShape, snappedPoint); } else if ( this.toolState.activeHandle === 'line-start' || this.toolState.activeHandle === 'line-end' || @@ -509,6 +513,15 @@ export class SelectTool implements Tool { */ private handleDragMove(state: EditorState, action: Action): EditorState { if (action.type !== 'pointer-move' || !this.toolState.dragStartWorld) return state; + if (state.ui.selectionIds.length === 1) { + const selectedId = state.ui.selectionIds[0]; + const initial = selectedId ? this.toolState.initialShapes.get(selectedId) : undefined; + if (initial?.type === 'text' && initial.props.textPath) { + const updated = this.adjustTextPath(state, initial, action.world); + if (updated) + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [updated.id]: updated } } }; + } + } let delta = Vec2Ops.sub(action.world, this.toolState.dragStartWorld); if (action.modifiers.shift) { @@ -631,7 +644,7 @@ export class SelectTool implements Tool { const selectedIds: string[] = []; for (const shape of getSelectionScopeShapes(state)) { - const bounds = shapeBounds(shape); + const bounds = shapeBoundsForState(state, shape); if (Box2.intersectsBox(marqueeBox, bounds)) selectedIds.push(shape.id); } @@ -710,6 +723,12 @@ export class SelectTool implements Tool { for (const shapeId of shapesToDelete) { delete newShapes[shapeId]; } + for (const shape of Object.values(newShapes)) { + if (shape.type !== 'text' || !shape.props.textPath) continue; + if (shapesToDelete.has(shape.props.textPath.pathId)) { + newShapes[shape.id] = { ...shape, props: { ...shape.props, textPath: undefined } }; + } + } for (const [bindingId, binding] of Object.entries(newBindings)) { if (shapesToDelete.has(binding.fromShapeId) || shapesToDelete.has(binding.toShapeId)) { @@ -809,7 +828,10 @@ export class SelectTool implements Tool { private getHandlePositions(state: EditorState, shape: ShapeRecord): Array<{ id: HandleKind; position: Vec2 }> { const handles: Array<{ id: HandleKind; position: Vec2 }> = []; - if ( + if (shape.type === 'text' && shape.props.textPath) { + const position = textPathAnchorForShape(state, shape); + if (position) handles.push({ id: 'text-path-offset', position }); + } else if ( shape.type === 'rect' || shape.type === 'ellipse' || shape.type === 'text' || @@ -1001,6 +1023,19 @@ export class SelectTool implements Tool { }; } + private adjustTextPath(state: EditorState, initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { + if (initial.type !== 'text' || !initial.props.textPath) return null; + const path = supportingPathForText(state, initial); + if (!path) return null; + const localPointer = worldToLocal(pointer, path); + const nearest = nearestPointOnPath(path.props, localPointer); + if (!nearest) return null; + const total = pathLength(path.props); + const offset = + initial.props.textPath.direction === 'reverse' ? Math.max(0, total - nearest.distance) : nearest.distance; + return { ...initial, props: { ...initial.props, textPath: { ...initial.props.textPath, offset } } }; + } + private resizeLineShape( state: EditorState, initial: ShapeRecord, diff --git a/packages/core/src/ui/statusbar.ts b/packages/core/src/ui/statusbar.ts index 3f21178..fbac5cc 100644 --- a/packages/core/src/ui/statusbar.ts +++ b/packages/core/src/ui/statusbar.ts @@ -1,71 +1,74 @@ -import type { CursorState } from "../cursor"; -import { shapeBounds } from "../geom"; -import { type Box2, Box2 as Box2Ops, type Vec2, Vec2 as Vec2Ops } from "../math"; -import type { EditorState, ToolId } from "../reactivity"; -import { getSelectedShapes } from "../reactivity"; +import type { CursorState } from '../cursor'; +import { shapeBoundsForState } from '../geom'; +import { type Box2, Box2 as Box2Ops, type Vec2, Vec2 as Vec2Ops } from '../math'; +import type { EditorState, ToolId } from '../reactivity'; +import { getSelectedShapes } from '../reactivity'; export type SelectionSummary = { count: number; kind?: string; bounds?: { w: number; h: number } }; export type SnapSummary = { enabled: boolean; gridSize?: number; angleStepDeg?: number }; export type PersistenceStatus = { - backend: "indexeddb" | "filesystem"; - state: "saved" | "saving" | "error"; - lastSavedAt?: number; - pendingWrites?: number; - errorMsg?: string; + backend: 'indexeddb' | 'filesystem'; + state: 'saved' | 'saving' | 'error'; + lastSavedAt?: number; + pendingWrites?: number; + errorMsg?: string; }; export type StatusBarVM = { - cursorWorld: Vec2; - cursorScreen?: Vec2; - toolId: ToolId; - mode: "idle" | "dragging" | "panning" | "text-edit" | string; - selection: SelectionSummary; - snap: SnapSummary; - persistence: PersistenceStatus; + cursorWorld: Vec2; + cursorScreen?: Vec2; + toolId: ToolId; + mode: 'idle' | 'dragging' | 'panning' | 'text-edit' | string; + selection: SelectionSummary; + snap: SnapSummary; + persistence: PersistenceStatus; }; /** * Convert the current camera zoom factor into a human-friendly percentage. */ export function getZoomPct(state: EditorState): number { - const pct = state.camera.zoom * 100; - if (!Number.isFinite(pct)) { - return 100; - } - return Math.round(pct); + const pct = state.camera.zoom * 100; + if (!Number.isFinite(pct)) { + return 100; + } + return Math.round(pct); } /** * Get the active tool identifier from UI state. */ export function getToolId(state: EditorState): ToolId { - return state.ui.toolId; + return state.ui.toolId; } /** * Summarize the current selection for display. */ export function getSelectionSummary(state: EditorState): SelectionSummary { - const shapes = getSelectedShapes(state); - const count = shapes.length; + const shapes = getSelectedShapes(state); + const count = shapes.length; - if (count === 0) { - return { count: 0 }; - } + if (count === 0) { + return { count: 0 }; + } - const combinedBounds = combineBounds(shapes.map((shape) => shapeBounds(shape))); + const combinedBounds = combineBounds(shapes.map((shape) => shapeBoundsForState(state, shape))); - const kind = count === 1 - ? shapes[0].type - : (shapes.every((shape) => shape.type === shapes[0].type) ? shapes[0].type : "mixed"); + const kind = + count === 1 + ? shapes[0].type + : shapes.every((shape) => shape.type === shapes[0].type) + ? shapes[0].type + : 'mixed'; - return { - count, - kind, - bounds: combinedBounds ? { w: Box2Ops.width(combinedBounds), h: Box2Ops.height(combinedBounds) } : undefined, - }; + return { + count, + kind, + bounds: combinedBounds ? { w: Box2Ops.width(combinedBounds), h: Box2Ops.height(combinedBounds) } : undefined + }; } const SNAP_DEFAULT: SnapSummary = { enabled: false }; @@ -74,40 +77,40 @@ const SNAP_DEFAULT: SnapSummary = { enabled: false }; * Provide safe defaults for snap/grid summary until features are enabled. */ export function getSnapSummary(_: EditorState): SnapSummary { - return { ...SNAP_DEFAULT }; + return { ...SNAP_DEFAULT }; } /** * Compose the full StatusBar view model from editor/cursor/persistence state. */ export function buildStatusBarVM( - editorState: EditorState, - cursorState: CursorState, - persistence: PersistenceStatus, - mode: StatusBarVM["mode"] = "idle", + editorState: EditorState, + cursorState: CursorState, + persistence: PersistenceStatus, + mode: StatusBarVM['mode'] = 'idle' ): StatusBarVM { - return { - cursorWorld: Vec2Ops.clone(cursorState.cursorWorld), - cursorScreen: cursorState.cursorScreen ? Vec2Ops.clone(cursorState.cursorScreen) : undefined, - toolId: getToolId(editorState), - mode, - selection: getSelectionSummary(editorState), - snap: getSnapSummary(editorState), - persistence: { ...persistence }, - }; + return { + cursorWorld: Vec2Ops.clone(cursorState.cursorWorld), + cursorScreen: cursorState.cursorScreen ? Vec2Ops.clone(cursorState.cursorScreen) : undefined, + toolId: getToolId(editorState), + mode, + selection: getSelectionSummary(editorState), + snap: getSnapSummary(editorState), + persistence: { ...persistence } + }; } function combineBounds(boxes: Box2[]): Box2 | null { - if (boxes.length === 0) { - return null; - } - let combined = Box2Ops.clone(boxes[0]); - for (let index = 1; index < boxes.length; index++) { - const box = boxes[index]; - combined = { - min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) }, - max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) }, - }; - } - return combined; + if (boxes.length === 0) { + return null; + } + let combined = Box2Ops.clone(boxes[0]); + for (let index = 1; index < boxes.length; index++) { + const box = boxes[index]; + combined = { + min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) }, + max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) } + }; + } + return combined; } diff --git a/packages/core/tests/export.test.ts b/packages/core/tests/export.test.ts index 7caf419..0744a92 100644 --- a/packages/core/tests/export.test.ts +++ b/packages/core/tests/export.test.ts @@ -1,334 +1,406 @@ -import { describe, expect, it } from "vitest"; -import { exportToSVG } from "../src/export"; -import { PageRecord, ShapeRecord } from "../src/model"; -import { EditorState } from "../src/reactivity"; +import { describe, expect, it } from 'vitest'; +import { exportToSVG } from '../src/export'; +import { PageRecord, ShapeRecord } from '../src/model'; +import { EditorState } from '../src/reactivity'; function createTestState() { - const state = EditorState.create(); - const page = PageRecord.create("Test Page"); - state.doc.pages[page.id] = page; - state.ui.currentPageId = page.id; - return { state, pageId: page.id }; + const state = EditorState.create(); + const page = PageRecord.create('Test Page'); + state.doc.pages[page.id] = page; + state.ui.currentPageId = page.id; + return { state, pageId: page.id }; } -describe("exportToSVG", () => { - it("should export an empty SVG when no shapes exist", () => { - const { state } = createTestState(); - const svg = exportToSVG(state); - - expect(svg).toContain(""); - }); - - it("should export variable-width strokes as outlined paths", () => { - const { state, pageId } = createTestState(); - const stroke = ShapeRecord.createStroke(pageId, 0, 0, { - points: [[0, 0], [100, 0]], - brush: { size: 10, thinning: 0, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, - style: { color: "#123456", opacity: 0.75 }, - widthProfile: [{ offset: 0, width: 4 }, { offset: 1, width: 24 }] - }); - state.doc.shapes[stroke.id] = stroke; - state.doc.pages[pageId].shapeIds.push(stroke.id); - - const svg = exportToSVG(state); - expect(svg).toContain(' { - const { state, pageId } = createTestState(); - - const rect = ShapeRecord.createRect(pageId, 10, 20, { w: 100, h: 50, fill: "red", stroke: "black", radius: 0 }); - - state.doc.shapes[rect.id] = rect; - state.doc.pages[pageId].shapeIds.push(rect.id); - - const svg = exportToSVG(state); - expect(svg).toContain(""); - expect(svg).toContain(" { - const { state, pageId } = createTestState(); - const rect = ShapeRecord.createRect(pageId, 0, 0, { - w: 100, - h: 50, - fill: { - kind: "linear_gradient", - x1: 0, - y1: 0, - x2: 1, - y2: 0, - units: "object_bounding_box", - transform: { a: 1, b: 0, c: 0, d: 1, e: 3, f: 4 }, - spread: "reflect", - stops: [ - { offset: 0, color: "#111111", opacity: 1 }, - { offset: 1, color: "#ffffff", opacity: 0.4 } - ] - }, - stroke: "none", - radius: 0 - }); - state.doc.shapes[rect.id] = rect; - state.doc.pages[pageId].shapeIds.push(rect.id); - - const svg = exportToSVG(state); - expect(svg).toContain(" { - const { state, pageId } = createTestState(); - const rect = ShapeRecord.createRect(pageId, 10, 20, { - w: 100, - h: 50, - fill: "red", - stroke: "none", - radius: 0, - clipPath: { - subpaths: [{ - segments: [ - { type: "move", to: { x: 0, y: 0 } }, - { type: "line", to: { x: 100, y: 0 } }, - { type: "line", to: { x: 50, y: 50 } } - ], - closed: true - }], - fill_rule: "nonzero" - }, - maskEffect: { - mode: "alpha", - geometry: { - subpaths: [{ - segments: [ - { type: "move", to: { x: 0, y: 0 } }, - { type: "line", to: { x: 100, y: 0 } }, - { type: "line", to: { x: 100, y: 50 } }, - { type: "line", to: { x: 0, y: 50 } } - ], - closed: true - }], - fill_rule: "nonzero" - }, - opacity: 0.8 - }, - filter: { primitives: [{ type: "blur", radius: 2 }, { type: "sepia", amount: 1 }] } - }); - state.doc.shapes[rect.id] = rect; - state.doc.pages[pageId].shapeIds.push(rect.id); - - const svg = exportToSVG(state); - expect(svg).toContain(" { - const { state, pageId } = createTestState(); - const rect = ShapeRecord.createRect(pageId, 10, 20, { w: 100, h: 50, fill: "red", stroke: "black", radius: 0 }); - rect.metadata = { - name: "Gateway", - title: null, - role: "architecture.service", - description: "Routes requests", - body: null, - tags: ["api", "critical"], - source: "architecture.md", - link: "https://example.com/gateway", - customMetadata: { owner: "platform" }, - locked: false, - agentEditable: true, - provenance: { - actorId: "actor:test", - origin: "human", - timestamp: 42, - source: "seed", - }, - }; - state.doc.shapes[rect.id] = rect; - state.doc.pages[pageId].shapeIds.push(rect.id); - - const svg = exportToSVG(state); - expect(svg).toContain('data-name="Gateway"'); - expect(svg).toContain('data-role="architecture.service"'); - expect(svg).toContain('data-description="Routes requests"'); - expect(svg).toContain('data-tags="api,critical"'); - expect(svg).toContain('data-metadata="{"owner":"platform"}"'); - }); - - it("should export SVG with an ellipse shape", () => { - const { state, pageId } = createTestState(); - - const ellipse = ShapeRecord.createEllipse(pageId, 10, 20, { w: 100, h: 50, fill: "blue", stroke: "green" }); - - state.doc.shapes[ellipse.id] = ellipse; - state.doc.pages[pageId].shapeIds.push(ellipse.id); - - const svg = exportToSVG(state); - expect(svg).toContain(" { - const { state, pageId } = createTestState(); - - const line = ShapeRecord.createLine(pageId, 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 100 }, - stroke: "red", - width: 2, - }); - - state.doc.shapes[line.id] = line; - state.doc.pages[pageId].shapeIds.push(line.id); - - const svg = exportToSVG(state); - expect(svg).toContain(" { - const { state, pageId } = createTestState(); - - const arrow = ShapeRecord.createArrow(pageId, 0, 0, { - points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], - start: { kind: "free" }, - end: { kind: "free" }, - style: { stroke: "black", width: 2, headEnd: true }, - routing: { kind: "straight" }, - }); - - state.doc.shapes[arrow.id] = arrow; - state.doc.pages[pageId].shapeIds.push(arrow.id); - - const svg = exportToSVG(state); - expect(svg).toContain(" { - const { state, pageId } = createTestState(); - const arrow = ShapeRecord.createArrow(pageId, 0, 0, { - points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], - start: { kind: "free" }, - end: { kind: "free" }, - style: { stroke: "black", width: 2 }, - routing: { kind: "curved", bend: 20 }, - }); - - state.doc.shapes[arrow.id] = arrow; - state.doc.pages[pageId].shapeIds.push(arrow.id); - - const svg = exportToSVG(state); - expect(svg).toContain(' { - const { state, pageId } = createTestState(); - - const text = ShapeRecord.createText(pageId, 10, 20, { - text: "Hello World", - fontSize: 16, - fontFamily: "Arial", - color: "black", - }); - - state.doc.shapes[text.id] = text; - state.doc.pages[pageId].shapeIds.push(text.id); - - const svg = exportToSVG(state); - expect(svg).toContain("Hello World"); - }); - - it("should export native path commands and fill rules", () => { - const { state, pageId } = createTestState(); - const path = ShapeRecord.createPath(pageId, 10, 20, { - subpaths: [{ - segments: [ - { type: "move", to: { x: 0, y: 0 } }, - { type: "line", to: { x: 40, y: 0 } }, - { type: "quadratic", control: { x: 50, y: 10 }, to: { x: 40, y: 20 } }, - { type: "cubic", control_1: { x: 40, y: 30 }, control_2: { x: 0, y: 30 }, to: { x: 0, y: 20 } } - ], - closed: true - }], - fill_rule: "evenodd", - fill: "#fff", - stroke: "#000", - stroke_width: 3 - }, "path:1"); - state.doc.shapes[path.id] = path; - state.doc.pages[pageId].shapeIds.push(path.id); - - const svg = exportToSVG(state); - expect(svg).toContain(" { - const { state, pageId } = createTestState(); - - const rect1 = ShapeRecord.createRect(pageId, 0, 0, { w: 50, h: 50, fill: "red", stroke: "black", radius: 0 }); - const rect2 = ShapeRecord.createRect(pageId, 100, 100, { w: 50, h: 50, fill: "blue", stroke: "black", radius: 0 }); - - state.doc.shapes[rect1.id] = rect1; - state.doc.shapes[rect2.id] = rect2; - state.doc.pages[pageId].shapeIds.push(rect1.id, rect2.id); - - state.ui.selectionIds = [rect1.id]; - - const svg = exportToSVG(state, { selectedOnly: true }); - expect(svg).toContain("fill=\"red\""); - expect(svg).not.toContain("fill=\"blue\""); - }); - - it("should escape XML special characters in shape properties", () => { - const { state, pageId } = createTestState(); - - const text = ShapeRecord.createText(pageId, 0, 0, { - text: "", - fontSize: 16, - fontFamily: "Arial", - color: "black", - }); - - state.doc.shapes[text.id] = text; - state.doc.pages[pageId].shapeIds.push(text.id); - - const svg = exportToSVG(state); - expect(svg).toContain("<script>"); - expect(svg).not.toContain("", + fontSize: 16, + fontFamily: 'Arial', + color: 'black' + }); + + state.doc.shapes[text.id] = text; + state.doc.pages[pageId].shapeIds.push(text.id); + + const svg = exportToSVG(state); + expect(svg).toContain('<script>'); + expect(svg).not.toContain('