diff --git a/packages/layout-engine/painters/dom/src/custom-geometry.test.ts b/packages/layout-engine/painters/dom/src/custom-geometry.test.ts new file mode 100644 index 000000000..e1bcc1bed --- /dev/null +++ b/packages/layout-engine/painters/dom/src/custom-geometry.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { createDomPainter } from './index.js'; +import type { DrawingBlock, DrawingMeasure, Layout } from '@superdoc/contracts'; + +describe('DomPainter custom geometry shapes', () => { + let mount: HTMLElement; + + beforeEach(() => { + mount = document.createElement('div'); + document.body.appendChild(mount); + }); + + afterEach(() => { + mount.remove(); + }); + + it('renders a:custGeom paths when kind is "custom" (no preset warning)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const drawingBlock: DrawingBlock = { + kind: 'drawing', + id: 'shape-custom-1', + drawingKind: 'vectorShape', + geometry: { width: 100, height: 80, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'custom', + fillColor: '#ff0000', + strokeColor: '#0000ff', + strokeWidth: 2, + attrs: { + customGeometry: { + paths: [{ d: 'M 0 0 L 100 0 L 100 80 L 0 80 Z' }], + }, + }, + }; + + const drawingMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 100, + height: 80, + scale: 1, + naturalWidth: 100, + naturalHeight: 80, + geometry: { width: 100, height: 80, rotation: 0, flipH: false, flipV: false }, + }; + + const drawingLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'drawing', + blockId: 'shape-custom-1', + drawingKind: 'vectorShape', + x: 20, + y: 30, + width: 100, + height: 80, + geometry: { width: 100, height: 80, rotation: 0, flipH: false, flipV: false }, + scale: 1, + }, + ], + }, + ], + }; + + const painter = createDomPainter({ blocks: [drawingBlock], measures: [drawingMeasure] }); + painter.paint(drawingLayout, mount); + + const path = mount.querySelector('svg path'); + expect(path).toBeTruthy(); + expect(path?.getAttribute('d')).toBe('M 0 0 L 100 0 L 100 80 L 0 80 Z'); + expect(path?.getAttribute('fill')).toBe('#ff0000'); + expect(path?.getAttribute('stroke')).toBe('#0000ff'); + expect(path?.getAttribute('stroke-width')).toBe('2'); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 5636bb845..e68970a34 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -159,6 +159,49 @@ type EffectExtent = { bottom: number; }; +type CustomGeometryPath = { + d: string; + fillRule?: string; + clipRule?: string; +}; + +type CustomGeometry = { + paths: CustomGeometryPath[]; +}; + +function isCustomGeometry(value: unknown): value is CustomGeometry { + if (!value || typeof value !== 'object') return false; + const v = value as { paths?: unknown }; + if (!Array.isArray(v.paths) || v.paths.length === 0) return false; + return v.paths.every((p) => { + if (!p || typeof p !== 'object') return false; + const path = p as { d?: unknown; fillRule?: unknown; clipRule?: unknown }; + if (typeof path.d !== 'string' || path.d.length === 0) return false; + if (path.fillRule != null && typeof path.fillRule !== 'string') return false; + if (path.clipRule != null && typeof path.clipRule !== 'string') return false; + return true; + }); +} + +function escapeXmlAttribute(value: string): string { + return value.replace(/[&"'<>]/g, (ch) => { + switch (ch) { + case '&': + return '&'; + case '"': + return '"'; + case "'": + return '''; + case '<': + return '<'; + case '>': + return '>'; + default: + return ch; + } + }); +} + type VectorShapeDrawingWithEffects = VectorShapeDrawing & { lineEnds?: LineEnds; effectExtent?: EffectExtent; @@ -2891,7 +2934,11 @@ export class DomPainter { contentContainer.style.width = `${innerWidth}px`; contentContainer.style.height = `${innerHeight}px`; - const svgMarkup = block.shapeKind ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) : null; + const svgMarkup = + this.tryCreateCustomGeometrySvg(block, innerWidth, innerHeight) ?? + (block.shapeKind && block.shapeKind !== 'custom' + ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) + : null); if (svgMarkup) { const svgElement = this.parseSafeSvg(svgMarkup); if (svgElement) { @@ -3181,6 +3228,38 @@ export class DomPainter { } } + private tryCreateCustomGeometrySvg( + block: VectorShapeDrawing, + widthOverride?: number, + heightOverride?: number, + ): string | null { + const attrs = block.attrs; + if (!attrs || typeof attrs !== 'object') return null; + + const customGeometryRaw = (attrs as Record).customGeometry; + if (!isCustomGeometry(customGeometryRaw)) return null; + + const width = widthOverride ?? block.geometry.width; + const height = heightOverride ?? block.geometry.height; + + const fill = block.fillColor === null ? 'none' : typeof block.fillColor === 'string' ? block.fillColor : '#000000'; + const stroke = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : 'none'; + const strokeWidth = + typeof block.strokeWidth === 'number' && Number.isFinite(block.strokeWidth) ? block.strokeWidth : 1; + + const pathMarkup = customGeometryRaw.paths + .map((p) => { + const d = escapeXmlAttribute(p.d); + const fillRule = p.fillRule ? ` fill-rule="${escapeXmlAttribute(p.fillRule)}"` : ''; + const clipRule = p.clipRule ? ` clip-rule="${escapeXmlAttribute(p.clipRule)}"` : ''; + return ``; + }) + .join(''); + + return `${pathMarkup}`; + } + private parseSafeSvg(markup: string): SVGElement | null { const DOMParserCtor = this.doc?.defaultView?.DOMParser ?? (typeof DOMParser !== 'undefined' ? DOMParser : null); if (!DOMParserCtor) { @@ -5687,9 +5766,17 @@ const deriveBlockVersion = (block: FlowBlock): string => { } if (block.drawingKind === 'vectorShape') { const vector = block as VectorShapeDrawing; + const customGeometryVersion = (() => { + const attrs = vector.attrs; + if (!attrs || typeof attrs !== 'object') return ''; + const customGeometryRaw = (attrs as Record).customGeometry; + if (!isCustomGeometry(customGeometryRaw)) return ''; + return customGeometryRaw.paths.map((p) => `${p.d}:${p.fillRule ?? ''}:${p.clipRule ?? ''}`).join('|'); + })(); return [ 'drawing:vector', vector.shapeKind ?? '', + customGeometryVersion, vector.fillColor ?? '', vector.strokeColor ?? '', vector.strokeWidth ?? '', diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index b4c21ecdc..247ab95de 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -449,9 +449,10 @@ const handleShapeDrawing = ( const spPr = wsp.elements.find((el) => el.name === 'wps:spPr'); const prstGeom = spPr?.elements.find((el) => el.name === 'a:prstGeom'); const shapeType = prstGeom?.attributes['prst']; + const hasCustomGeometry = Boolean(spPr?.elements?.some((el) => el.name === 'a:custGeom')); // For all other shapes (with or without text), or shapes with gradients, use the vector shape handler - if (shapeType) { + if (shapeType || hasCustomGeometry) { const result = getVectorShape({ params, node, graphicData, size, marginOffset, anchorData, wrap, isAnchor }); if (result?.attrs && isHidden) { result.attrs.hidden = true; @@ -1060,18 +1061,24 @@ export function getVectorShape({ params, node, graphicData, size, marginOffset, return null; } - // Extract shape kind + // Use wp:extent for dimensions (final displayed size from anchor) + // This is the correct size that Word displays the shape at + const width = size?.width ?? DEFAULT_SHAPE_WIDTH; + const height = size?.height ?? DEFAULT_SHAPE_HEIGHT; + + // Extract shape kind (preset geometry) and/or custom geometry (a:custGeom) const prstGeom = spPr.elements?.find((el) => el.name === 'a:prstGeom'); const shapeKind = prstGeom?.attributes?.['prst']; - if (!shapeKind) { + // Extract custom geometry (a:custGeom) when no preset is provided + const customGeometry = extractCustomGeometry(spPr, width, height); + if (!shapeKind && !customGeometry) { console.warn('Shape kind not found'); } - schemaAttrs.kind = shapeKind; - // Use wp:extent for dimensions (final displayed size from anchor) - // This is the correct size that Word displays the shape at - const width = size?.width ?? DEFAULT_SHAPE_WIDTH; - const height = size?.height ?? DEFAULT_SHAPE_HEIGHT; + schemaAttrs.kind = shapeKind || (customGeometry ? 'custom' : undefined); + if (customGeometry) { + schemaAttrs.customGeometry = customGeometry; + } // Extract transformations from a:xfrm (rotation and flips are still valid) const xfrm = spPr.elements?.find((el) => el.name === 'a:xfrm'); @@ -1125,3 +1132,95 @@ export function getVectorShape({ params, node, graphicData, size, marginOffset, }, }; } + +function extractCustomGeometry(spPr, targetWidthPx, targetHeightPx) { + const custGeom = spPr?.elements?.find((el) => el.name === 'a:custGeom'); + if (!custGeom?.elements) return null; + + const pathLst = custGeom.elements.find((el) => el.name === 'a:pathLst'); + const paths = pathLst?.elements?.filter((el) => el.name === 'a:path') || []; + if (!paths.length) return null; + + const toFiniteNumber = (value) => { + const numeric = typeof value === 'string' ? Number(value) : value; + return Number.isFinite(numeric) ? numeric : null; + }; + + const formatNumber = (value) => { + if (!Number.isFinite(value)) return '0'; + const rounded = Math.round(value * 1000) / 1000; + return String(rounded); + }; + + const buildPathD = (pathEl, scaleX, scaleY) => { + const commands = []; + const pathChildren = pathEl?.elements || []; + + const transformPoint = (ptEl) => { + const xEmu = toFiniteNumber(ptEl?.attributes?.x) ?? 0; + const yEmu = toFiniteNumber(ptEl?.attributes?.y) ?? 0; + const xPx = emuToPixels(xEmu) * scaleX; + const yPx = emuToPixels(yEmu) * scaleY; + return { x: xPx, y: yPx }; + }; + + for (const child of pathChildren) { + if (!child) continue; + if (child.name === 'a:moveTo' || child.name === 'a:lnTo') { + const pt = child.elements?.find((el) => el.name === 'a:pt'); + if (!pt) continue; + const { x, y } = transformPoint(pt); + commands.push(`${child.name === 'a:moveTo' ? 'M' : 'L'} ${formatNumber(x)} ${formatNumber(y)}`); + continue; + } + + if (child.name === 'a:quadBezTo') { + const pts = (child.elements || []).filter((el) => el.name === 'a:pt'); + if (pts.length < 2) continue; + const c = transformPoint(pts[0]); + const end = transformPoint(pts[1]); + commands.push(`Q ${formatNumber(c.x)} ${formatNumber(c.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`); + continue; + } + + if (child.name === 'a:cubicBezTo') { + const pts = (child.elements || []).filter((el) => el.name === 'a:pt'); + if (pts.length < 3) continue; + const c1 = transformPoint(pts[0]); + const c2 = transformPoint(pts[1]); + const end = transformPoint(pts[2]); + commands.push( + `C ${formatNumber(c1.x)} ${formatNumber(c1.y)} ${formatNumber(c2.x)} ${formatNumber(c2.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`, + ); + continue; + } + + if (child.name === 'a:close') { + commands.push('Z'); + continue; + } + } + + const d = commands.join(' '); + return d ? { d } : null; + }; + + const builtPaths = []; + for (const pathEl of paths) { + const intrinsicWEmu = toFiniteNumber(pathEl.attributes?.w); + const intrinsicHEmu = toFiniteNumber(pathEl.attributes?.h); + const intrinsicWPx = intrinsicWEmu ? emuToPixels(intrinsicWEmu) : null; + const intrinsicHPx = intrinsicHEmu ? emuToPixels(intrinsicHEmu) : null; + + const scaleX = + intrinsicWPx && intrinsicWPx > 0 && Number.isFinite(targetWidthPx) ? targetWidthPx / intrinsicWPx : 1; + const scaleY = + intrinsicHPx && intrinsicHPx > 0 && Number.isFinite(targetHeightPx) ? targetHeightPx / intrinsicHPx : 1; + + const built = buildPathD(pathEl, scaleX, scaleY); + if (built?.d) builtPaths.push(built); + } + + if (!builtPaths.length) return null; + return { paths: builtPaths }; +} diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index 2a623be1a..a2f5e9d8e 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -156,6 +156,75 @@ describe('handleImageNode', () => { }; }; + const makeCustomGeomShapeNode = () => ({ + attributes: { + distT: '1000', + distB: '2000', + distL: '3000', + distR: '4000', + }, + elements: [ + { name: 'wp:extent', attributes: { cx: '1000', cy: '2000' } }, + { + name: 'a:graphic', + elements: [ + { + name: 'a:graphicData', + attributes: { uri: shapeUri }, + elements: [ + { + name: 'wps:wsp', + elements: [ + { + name: 'wps:spPr', + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '1000', h: '2000' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: '1000', y: '0' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '0' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '2000' } }] }, + { + name: 'a:lnTo', + elements: [{ name: 'a:pt', attributes: { x: '1000', y: '2000' } }], + }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: '1000', y: '0' } }] }, + { name: 'a:close' }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { name: 'wp:docPr', attributes: { id: '99', name: 'Shape', descr: 'Custom geometry' } }, + { + name: 'wp:positionH', + attributes: { relativeFrom: 'page' }, + elements: [{ name: 'wp:posOffset', elements: [{ text: '7000' }] }], + }, + { + name: 'wp:positionV', + attributes: { relativeFrom: 'paragraph' }, + elements: [{ name: 'wp:posOffset', elements: [{ text: '8000' }] }], + }, + ], + }); + it('returns null if picture is missing', () => { const node = makeNode(); node.elements[1].elements[0].elements = []; @@ -456,6 +525,22 @@ describe('handleImageNode', () => { expect(extractStrokeWidth).toHaveBeenCalled(); }); + it('renders custom geometry shapes (a:custGeom) as vectorShapes', () => { + extractFillColor.mockReturnValue('#000000'); + extractStrokeColor.mockReturnValue(null); + extractStrokeWidth.mockReturnValue(1); + + const node = makeCustomGeomShapeNode(); + const result = handleImageNode(node, makeParams(), false); + + expect(result.type).toBe('vectorShape'); + expect(result.attrs.kind).toBe('custom'); + expect(result.attrs.width).toBe(1); + expect(result.attrs.height).toBe(2); + expect(result.attrs.customGeometry).toBeDefined(); + expect(result.attrs.customGeometry.paths[0].d).toContain('M'); + }); + it('renders textbox shapes as vectorShapes with text content', () => { const node = makeShapeNode({ includeTextbox: true }); const result = handleImageNode(node, makeParams(), false); diff --git a/packages/super-editor/src/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/extensions/vector-shape/VectorShapeView.js index 2cec0d41f..e402411c4 100644 --- a/packages/super-editor/src/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/extensions/vector-shape/VectorShapeView.js @@ -77,10 +77,21 @@ export class VectorShapeView { if (typeof globalThis !== 'undefined' && globalThis.requestAnimationFrame) { globalThis.requestAnimationFrame(() => { try { - const parent = this.root?.parentElement; - if (parent && parent.tagName === 'P') { - // Set parent paragraph as positioned so vector shape positions relative to it - parent.style.position = 'relative'; + let paragraph = null; + if (typeof this.root?.closest === 'function') { + paragraph = this.root.closest('p'); + } else if (this.root?.parentElement?.tagName === 'P') { + paragraph = this.root.parentElement; + } + if (paragraph) { + const currentPosition = + typeof globalThis.getComputedStyle === 'function' + ? globalThis.getComputedStyle(paragraph).position + : paragraph.style.position; + if (!currentPosition || currentPosition === 'static') { + // Set paragraph as positioned so vector shape positions relative to it + paragraph.style.position = 'relative'; + } } } catch (error) { // Silently handle DOM manipulation errors (e.g., detached node, read-only style) @@ -344,10 +355,32 @@ export class VectorShapeView { const stroke = strokeColor === null ? 'none' : strokeColor || 'none'; const strokeW = strokeColor === null ? 0 : strokeColor ? strokeWidth || 1 : 0; + // Custom geometry (a:custGeom) paths extracted from OOXML + const customGeometry = attrs.customGeometry; + if (customGeometry?.paths?.length) { + for (const { d, fillRule, clipRule } of customGeometry.paths) { + if (!d) continue; + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + path.setAttribute('fill', fill); + if (fillOpacity < 1) { + path.setAttribute('fill-opacity', fillOpacity.toString()); + } + path.setAttribute('stroke', stroke); + path.setAttribute('stroke-width', strokeW.toString()); + if (fillRule) path.setAttribute('fill-rule', fillRule); + if (clipRule) path.setAttribute('clip-rule', clipRule); + svg.appendChild(path); + } + return svg; + } + + const resolvedKind = kind || 'rect'; + // Create shape element based on kind let shapeElement; - switch (kind) { + switch (resolvedKind) { case 'rect': shapeElement = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); shapeElement.setAttribute('x', '0'); diff --git a/packages/super-editor/src/extensions/vector-shape/VectorShapeView.test.js b/packages/super-editor/src/extensions/vector-shape/VectorShapeView.test.js index 1f9c12306..a5ed4ab15 100644 --- a/packages/super-editor/src/extensions/vector-shape/VectorShapeView.test.js +++ b/packages/super-editor/src/extensions/vector-shape/VectorShapeView.test.js @@ -624,6 +624,52 @@ describe('VectorShapeView', () => { expect(element.style.left).toBe('50px'); }); + it('positions absolute vector shapes relative to the containing paragraph', () => { + const rafCallbacks = []; + const originalRAF = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (cb) => { + rafCallbacks.push(cb); + return 1; + }; + + const node = { + attrs: { + kind: 'rect', + width: 100, + height: 100, + wrap: { + type: 'None', + attrs: {}, + }, + marginOffset: { + horizontal: 10, + top: 5, + }, + }, + }; + + const view = new VectorShapeView({ + node, + editor: mockEditor, + getPos: mockGetPos, + decorations: [], + innerDecorations: [], + extension: {}, + htmlAttributes: {}, + }); + + const paragraph = document.createElement('p'); + const wrapper = document.createElement('span'); + paragraph.appendChild(wrapper); + wrapper.appendChild(view.dom); + + expect(paragraph.style.position).toBe(''); + rafCallbacks.forEach((cb) => cb()); + expect(paragraph.style.position).toBe('relative'); + + globalThis.requestAnimationFrame = originalRAF; + }); + it('applies center alignment for margin-relative anchors', () => { const centeredNode = { attrs: { diff --git a/packages/super-editor/src/extensions/vector-shape/vector-shape.js b/packages/super-editor/src/extensions/vector-shape/vector-shape.js index 10c577f7a..19f47b262 100644 --- a/packages/super-editor/src/extensions/vector-shape/vector-shape.js +++ b/packages/super-editor/src/extensions/vector-shape/vector-shape.js @@ -132,6 +132,11 @@ export const VectorShape = Node.create({ rendered: false, }, + customGeometry: { + default: null, + rendered: false, + }, + textContent: { default: null, rendered: false,