diff --git a/crates/inkfinite-core/src/editor.rs b/crates/inkfinite-core/src/editor.rs index a1d5270..7802940 100644 --- a/crates/inkfinite-core/src/editor.rs +++ b/crates/inkfinite-core/src/editor.rs @@ -506,22 +506,38 @@ fn append_projected_shape( #[must_use] pub fn native_properties(properties: &ShapeProperties) -> ShapeProperties { let mut result = properties.clone(); - if let Some(width) = result.remove("w") { - result.insert("width".into(), width); - } - if let Some(height) = result.remove("h") { - result.insert("height".into(), height); + for (editor, native) in [ + ("w", "width"), + ("h", "height"), + ("md", "markdown"), + ("bg", "background"), + ("fontSize", "font_size"), + ("fontFamily", "font_family"), + ("assetId", "asset_id"), + ("referenceType", "reference_type"), + ] { + if let Some(value) = result.remove(editor) { + result.entry(native.into()).or_insert(value); + } } result } fn editor_properties(properties: &ShapeProperties) -> ShapeProperties { let mut result = properties.clone(); - if let Some(width) = result.remove("width") { - result.insert("w".into(), width); - } - if let Some(height) = result.remove("height") { - result.insert("h".into(), height); + for (native, editor) in [ + ("width", "w"), + ("height", "h"), + ("markdown", "md"), + ("background", "bg"), + ("font_size", "fontSize"), + ("font_family", "fontFamily"), + ("asset_id", "assetId"), + ("reference_type", "referenceType"), + ] { + if let Some(value) = result.remove(native) { + result.entry(editor.into()).or_insert(value); + } } result } @@ -1033,6 +1049,41 @@ mod tests { assert!(!child.props.contains_key("width")); } + #[test] + fn projection_translates_native_property_names_for_editor_clients() { + let mut snapshot = nested_snapshot(); + let child = snapshot + .document + .shapes + .get_mut(&ShapeId::from("shape:child")) + .expect("child fixture"); + child.properties.extend([ + ("markdown".into(), Value::from("# Notes")), + ("background".into(), Value::from("#ffffff")), + ("font_size".into(), Value::from(16.0)), + ("font_family".into(), Value::from("sans-serif")), + ("asset_id".into(), Value::from("asset:one")), + ("reference_type".into(), Value::from("url")), + ]); + let native = child.properties.clone(); + + let projection = project_editor(&snapshot); + let props = &projection.shapes[&ShapeId::from("shape:child")].props; + for editor_name in [ + "w", + "h", + "md", + "bg", + "fontSize", + "fontFamily", + "assetId", + "referenceType", + ] { + assert!(props.contains_key(editor_name), "missing {editor_name}"); + } + assert_eq!(native_properties(props), native); + } + #[test] fn projection_preserves_semantic_metadata() { let mut snapshot = nested_snapshot(); diff --git a/crates/inkfinite-core/src/render/mod.rs b/crates/inkfinite-core/src/render/mod.rs index bc7f2a7..fb5ef3c 100644 --- a/crates/inkfinite-core/src/render/mod.rs +++ b/crates/inkfinite-core/src/render/mod.rs @@ -613,7 +613,9 @@ struct ArrowProps { #[serde(rename_all = "camelCase")] struct TextProps { text: String, + #[serde(alias = "font_size")] font_size: f64, + #[serde(alias = "font_family")] font_family: String, color: String, #[serde(alias = "w")] @@ -629,7 +631,9 @@ struct MarkdownProps { width: f64, #[serde(alias = "h")] height: Option, + #[serde(alias = "font_size")] font_size: f64, + #[serde(alias = "font_family")] font_family: String, color: String, #[serde(alias = "bg")] diff --git a/crates/inkfinite-core/src/render/tests.rs b/crates/inkfinite-core/src/render/tests.rs index bf9157d..5b44265 100644 --- a/crates/inkfinite-core/src/render/tests.rs +++ b/crates/inkfinite-core/src/render/tests.rs @@ -682,6 +682,30 @@ fn render_is_deterministic_and_covers_every_visual_builtin() { ); } +#[test] +fn renders_imported_snake_case_text_properties() { + let mut snapshot = fixture_snapshot(); + let shape = snapshot + .document + .shapes + .get_mut(&ShapeId::from("shape:text")) + .expect("text fixture"); + shape.properties.remove("fontSize"); + shape.properties.remove("fontFamily"); + shape.properties.insert("font_size".into(), serde_json::json!(18)); + shape + .properties + .insert("font_family".into(), serde_json::json!("Inter")); + + let output = render_svg( + &snapshot, + &SvgRenderOptions { page_id: Some(PageId::from("page:fixtures")), ..SvgRenderOptions::default() }, + ) + .expect("imported text renders"); + + assert!(output.svg.contains("data-shape-id=\"shape:text\"")); +} + #[test] fn frame_child_order_controls_svg_presentation_order() { let mut snapshot = fixture_snapshot(); diff --git a/crates/inkfinite-core/src/svg_import.rs b/crates/inkfinite-core/src/svg_import.rs index ca86582..7145ec2 100644 --- a/crates/inkfinite-core/src/svg_import.rs +++ b/crates/inkfinite-core/src/svg_import.rs @@ -653,7 +653,10 @@ impl ImportParser { .filter_map(|descendant| descendant.text()) .collect::(); let color = style.fill.clone().unwrap_or_else(|| "none".into()); - let transform = self.transformed_geometry(node, x, y)?; + // Native text uses a top-aligned origin, while SVG text positions the + // 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)?; Ok(SvgShape { source_id: source_id(node), kind: ShapeKind::from(crate::TEXT_KIND), @@ -1859,7 +1862,7 @@ mod tests { .expect("text should import"); let text = shape(&import, 0); assert_eq!(text.kind.as_str(), crate::TEXT_KIND); - assert_eq!(text.transform.translation, Vec2 { x: 12.0, y: 24.0 }); + assert_eq!(text.transform.translation, Vec2 { x: 12.0, y: 4.0 }); assert_eq!(text.properties["text"], json!("Hello world")); assert_eq!(text.properties["font_family"], json!("Inter")); } diff --git a/packages/core/src/persistence/canonical.test.ts b/packages/core/src/persistence/canonical.test.ts index c76b374..75085fd 100644 --- a/packages/core/src/persistence/canonical.test.ts +++ b/packages/core/src/persistence/canonical.test.ts @@ -1,8 +1,80 @@ import { describe, expect, it } from 'vitest'; import { LayerRecord, PageRecord, ShapeRecord } from '../model'; -import { fromCanonicalDocumentSnapshot, toCanonicalDocumentSnapshot } from './canonical'; +import { fromCanonicalDocumentSnapshot, fromEditorProjection, toCanonicalDocumentSnapshot } from './canonical'; +import type { EditorProjection } from '@inkfinite/bindings/editor'; describe('canonical editor projection', () => { + it('normalizes native property names from the Rust editor projection', () => { + const projection = { + pages: { + 'page:one': { id: 'page:one', name: 'Page 1', shape_ids: ['shape:markdown'], layer_ids: ['layer:one'] } + }, + layers: { + 'layer:one': { + id: 'layer:one', + page_id: 'page:one', + name: 'Default', + shape_ids: ['shape:markdown'], + visible: true, + locked: false, + opacity: 1 + } + }, + shapes: { + 'shape:markdown': { + id: 'shape:markdown', + type: 'markdown', + page_id: 'page:one', + transform: { a: 1, b: 0, c: 0, d: 1, e: 20, f: 30 }, + x: 20, + y: 30, + rot: 0, + group_id: null, + layer_id: 'layer:one', + opacity: 1, + fill_opacity: null, + stroke_opacity: null, + locked: false, + agent_editable: true, + metadata: { + name: null, + title: null, + role: null, + description: null, + body: null, + tags: [], + source: null, + link: null, + custom_metadata: {}, + locked: false, + agent_editable: true, + provenance: { actor_id: 'actor:test', origin: 'system', timestamp: 0, source: null } + }, + props: { + width: 320, + height: 180, + markdown: '# Notes', + background: '#ffffff', + font_size: 16, + font_family: 'sans-serif' + } + } + }, + bindings: {}, + order: { page_ids: ['page:one'], shape_order: { 'page:one': ['shape:markdown'] }, layers: {} } + } as unknown as EditorProjection; + + const projected = fromEditorProjection(projection); + expect(projected.shapes['shape:markdown'].props).toMatchObject({ + w: 320, + h: 180, + md: '# Notes', + bg: '#ffffff', + fontSize: 16, + fontFamily: 'sans-serif' + }); + }); + it('traverses imported root containers and retains independently addressable descendants', () => { const pageId = 'page:svg'; const layerId = 'layer:svg'; diff --git a/packages/core/src/persistence/canonical.ts b/packages/core/src/persistence/canonical.ts index 7a41951..065c7d4 100644 --- a/packages/core/src/persistence/canonical.ts +++ b/packages/core/src/persistence/canonical.ts @@ -181,7 +181,7 @@ export function fromEditorProjection(projection: EditorProjection, snapshot?: Na locked: shape.locked, agentEditable: shape.agent_editable, metadata: fromNativeMetadata(shape.metadata), - props: shape.props as ShapeRecord['props'], + props: editorProperties(shape.props as ShapeProperties) as ShapeRecord['props'], ...(shape.resolved_geometry ? { resolvedGeometry: shape.resolved_geometry } : {}) } as ShapeRecord; } @@ -599,21 +599,18 @@ function toNativeMetadata( function editorProperties(properties: ShapeProperties): ShapeProperties { const result = JSON.parse(JSON.stringify(properties)) as ShapeProperties; - if ('width' in result) { - result.w = result.width; - delete result.width; - } - if ('height' in result) { - result.h = result.height; - delete result.height; - } - if ('asset_id' in result && !('assetId' in result)) { - result.assetId = result.asset_id; - delete result.asset_id; - } - if ('reference_type' in result && !('referenceType' in result)) { - result.referenceType = result.reference_type; - delete result.reference_type; + for (const [native, editor] of [ + ['width', 'w'], + ['height', 'h'], + ['markdown', 'md'], + ['background', 'bg'], + ['font_size', 'fontSize'], + ['font_family', 'fontFamily'], + ['asset_id', 'assetId'], + ['reference_type', 'referenceType'] + ] as const) { + if (native in result && !(editor in result)) result[editor] = result[native]; + delete result[native]; } return result; } diff --git a/packages/editor/src/renderer.ts b/packages/editor/src/renderer.ts index 352b46a..9d67e30 100644 --- a/packages/editor/src/renderer.ts +++ b/packages/editor/src/renderer.ts @@ -1270,12 +1270,12 @@ function drawPath(context: CanvasRenderingContext2D, shape: PathShape) { } if (subpath.closed) context.closePath(); } - if (fill) { + if (fill && fill !== 'none' && fill !== 'transparent') { context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); context.fillStyle = fill; context.fill(fillRule); } - if (stroke) { + if (stroke && stroke !== 'none' && stroke !== 'transparent') { context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); context.strokeStyle = stroke; context.lineWidth = Math.max(0, strokeWidth ?? 2); diff --git a/packages/editor/tests/renderer.test.ts b/packages/editor/tests/renderer.test.ts index fd4b385..199e571 100644 --- a/packages/editor/tests/renderer.test.ts +++ b/packages/editor/tests/renderer.test.ts @@ -460,6 +460,53 @@ describe('Renderer', () => { renderer.dispose(); }); + it('does not fill a native path whose fill is none', () => { + const scheduledFrames: FrameRequestCallback[] = []; + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + scheduledFrames.push(callback); + return scheduledFrames.length; + }); + const page = PageRecord.create('Page 1', 'page:1'); + const path = ShapeRecord.createPath( + page.id, + 0, + 0, + { + subpaths: [ + { + segments: [ + { type: 'move', to: { x: 0, y: 20 } }, + { type: 'line', to: { x: 40, y: 0 } } + ], + closed: false + } + ], + fill_rule: 'nonzero', + fill: 'none', + stroke: '#0f766e', + stroke_width: 3 + }, + 'path:none-fill' + ); + const store = new Store(); + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [path.id] } }, + shapes: { [path.id]: path }, + bindings: {} + }, + ui: { ...state.ui, currentPageId: page.id } + })); + + const renderer = createRenderer(canvas, store); + scheduledFrames.shift()?.(0); + + expect(context.fill).not.toHaveBeenCalledWith('nonzero'); + expect(context.stroke).toHaveBeenCalled(); + renderer.dispose(); + }); + it('should render scene with line shape', async () => { const store = new Store();