From 2e66ec434c06460a44a1d7062f49d213a3754833 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 20 Feb 2026 09:17:05 -0300 Subject: [PATCH 1/2] feat(shapes): add custom geometry (a:custGeom) SVG rendering Parse OOXML custom geometry paths (a:custGeom/a:pathLst) and render them as SVG instead of falling back to solid CSS background fills. This fixes shapes that use custom path commands (moveTo, lineTo, cubicBezTo, close) rendering as solid black rectangles covering their entire bounding box. End-to-end pipeline: DOCX import parses a:custGeom path commands into SVG d-strings, stores them as customGeometry attribute through PM schema and layout contracts, and DomPainter generates proper SVG elements with viewBox scaling. Applies to both standalone vectorShape nodes and shapeGroup children. --- packages/layout-engine/contracts/src/index.ts | 5 + .../painters/dom/src/renderer.ts | 52 ++++- .../pm-adapter/src/converters/shapes.ts | 2 + .../layout-engine/pm-adapter/src/utilities.ts | 24 +++ .../wp/helpers/encode-image-node-helpers.js | 27 ++- .../helpers/encode-image-node-helpers.test.js | 7 +- .../wp/helpers/vector-shape-helpers.js | 81 ++++++++ .../wp/helpers/vector-shape-helpers.test.js | 179 ++++++++++++++++++ .../extensions/vector-shape/vector-shape.js | 5 + 9 files changed, 369 insertions(+), 13 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index f344c8c28..829c631cc 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -666,6 +666,9 @@ export type EffectExtent = { bottom: number; }; +export type CustomGeometryPath = { d: string; fill: string }; +export type CustomGeometry = { paths: CustomGeometryPath[]; width: number; height: number }; + export type VectorShapeStyle = { fillColor?: FillColor; strokeColor?: StrokeColor; @@ -700,6 +703,7 @@ export type ShapeGroupVectorChild = { attrs: PositionedDrawingGeometry & VectorShapeStyle & { kind?: string; + customGeometry?: CustomGeometry; shapeId?: string; shapeName?: string; }; @@ -741,6 +745,7 @@ export type VectorShapeDrawing = DrawingBlockBase & { drawingKind: 'vectorShape'; geometry: DrawingGeometry; shapeKind?: string; + customGeometry?: CustomGeometry; fillColor?: FillColor; strokeColor?: StrokeColor; strokeWidth?: number; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index cb6b4ef03..ef02b75c7 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -43,6 +43,7 @@ import type { TableAttrs, TableCellAttrs, PositionMapping, + CustomGeometry, } from '@superdoc/contracts'; import { calculateJustifySpacing, computeLinePmRange, shouldApplyJustify, SPACE_CHARS } from '@superdoc/contracts'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; @@ -2796,7 +2797,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 = block.shapeKind + ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) + : block.customGeometry + ? this.createCustomGeometrySvg(block, innerWidth, innerHeight) + : null; if (svgMarkup) { const svgElement = this.parseSafeSvg(svgMarkup); if (svgElement) { @@ -3086,6 +3091,48 @@ export class DomPainter { } } + /** + * Generates SVG markup from custom geometry path data (a:custGeom). + * Converts stored OOXML path commands (already converted to SVG d-strings) into a full SVG element. + */ + private createCustomGeometrySvg( + block: VectorShapeDrawingWithEffects, + widthOverride?: number, + heightOverride?: number, + ): string | null { + const geom = block.customGeometry; + if (!geom || !geom.paths.length) return null; + + const width = widthOverride ?? block.geometry.width; + const height = heightOverride ?? block.geometry.height; + + // Resolve fill color — null means "no fill" (a:noFill), use 'none' + let fillColor: string; + if (block.fillColor === null) { + fillColor = 'none'; + } else if (typeof block.fillColor === 'string') { + fillColor = block.fillColor; + } else { + fillColor = 'none'; + } + + const strokeColor = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : 'none'; + const strokeWidth = block.strokeWidth ?? 0; + + // Build SVG paths — scale the path coordinate space to the actual display dimensions via viewBox + const pathElements = geom.paths + .map((p) => { + const pathFill = p.fill === 'none' ? 'none' : fillColor; + // Sanitize d attribute — only allow SVG path commands and numbers + const safeD = p.d.replace(/[^MmLlHhVvCcSsQqTtAaZz0-9.,\s\-+eE]/g, ''); + return ``; + }) + .join(''); + + return `${pathElements}`; + } + private parseSafeSvg(markup: string): SVGElement | null { const DOMParserCtor = this.doc?.defaultView?.DOMParser ?? (typeof DOMParser !== 'undefined' ? DOMParser : null); if (!DOMParserCtor) { @@ -3375,6 +3422,7 @@ export class DomPainter { const attrs = child.attrs as PositionedDrawingGeometry & VectorShapeStyle & { kind?: string; + customGeometry?: CustomGeometry; shapeId?: string; shapeName?: string; textContent?: ShapeTextContent; @@ -3401,6 +3449,7 @@ export class DomPainter { drawingContentId: undefined, drawingContent: undefined, shapeKind: attrs.kind, + customGeometry: attrs.customGeometry, fillColor: attrs.fillColor, strokeColor: attrs.strokeColor, strokeWidth: attrs.strokeWidth, @@ -5892,6 +5941,7 @@ const deriveBlockVersion = (block: FlowBlock): string => { return [ 'drawing:vector', vector.shapeKind ?? '', + vector.customGeometry ? JSON.stringify(vector.customGeometry) : '', vector.fillColor ?? '', vector.strokeColor ?? '', vector.strokeWidth ?? '', diff --git a/packages/layout-engine/pm-adapter/src/converters/shapes.ts b/packages/layout-engine/pm-adapter/src/converters/shapes.ts index ff30f11e5..179f0de43 100644 --- a/packages/layout-engine/pm-adapter/src/converters/shapes.ts +++ b/packages/layout-engine/pm-adapter/src/converters/shapes.ts @@ -20,6 +20,7 @@ import { isShapeGroupTransform, normalizeShapeSize, normalizeShapeGroupChildren, + normalizeCustomGeometry, normalizeFillColor, normalizeStrokeColor, normalizeLineEnds, @@ -359,6 +360,7 @@ export const buildDrawingBlock = ( attrs: attrsWithPm, geometry, shapeKind: typeof rawAttrs.kind === 'string' ? rawAttrs.kind : undefined, + customGeometry: normalizeCustomGeometry(rawAttrs.customGeometry), fillColor: normalizeFillColor(rawAttrs.fillColor), strokeColor: normalizeStrokeColor(rawAttrs.strokeColor), strokeWidth: coerceNumber(rawAttrs.strokeWidth), diff --git a/packages/layout-engine/pm-adapter/src/utilities.ts b/packages/layout-engine/pm-adapter/src/utilities.ts index 73ea7b3c6..97d8d90af 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.ts @@ -7,6 +7,7 @@ import type { BoxSpacing, + CustomGeometry, DrawingBlock, DrawingContentSnapshot, ImageBlock, @@ -791,6 +792,29 @@ export function normalizeShapeGroupChildren(value: unknown): ShapeGroupChild[] { }); } +/** + * Normalizes a custom geometry value, validating its structure. + * Returns undefined if the value is not a valid CustomGeometry object. + */ +export function normalizeCustomGeometry(value: unknown): CustomGeometry | undefined { + if (!value || typeof value !== 'object') return undefined; + const obj = value as Record; + if (typeof obj.width !== 'number' || typeof obj.height !== 'number') return undefined; + if (!Array.isArray(obj.paths) || obj.paths.length === 0) return undefined; + const validPaths = obj.paths.filter( + (p: unknown) => p && typeof p === 'object' && typeof (p as Record).d === 'string', + ); + if (validPaths.length === 0) return undefined; + return { + paths: validPaths.map((p: Record) => ({ + d: p.d as string, + fill: typeof p.fill === 'string' ? p.fill : 'norm', + })), + width: obj.width, + height: obj.height, + }; +} + // ============================================================================ // Media/Image Utilities // ============================================================================ 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 3756175fc..21f6cdc0e 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 @@ -1,6 +1,12 @@ import { emuToPixels, rotToDegrees, polygonToObj } from '@converter/helpers.js'; import { carbonCopy } from '@core/utilities/carbonCopy.js'; -import { extractStrokeWidth, extractStrokeColor, extractFillColor, extractLineEnds } from './vector-shape-helpers'; +import { + extractStrokeWidth, + extractStrokeColor, + extractFillColor, + extractLineEnds, + extractCustomGeometry, +} from './vector-shape-helpers'; import { convertMetafileToSvg, isMetafileExtension, setMetafileDomEnvironment } from './metafile-converter.js'; import { collectTextBoxParagraphs, @@ -488,9 +494,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 hasCustGeom = !!spPr?.elements?.find((el) => el.name === 'a:custGeom'); - // For all other shapes (with or without text), or shapes with gradients, use the vector shape handler - if (shapeType) { + // For shapes with preset or custom geometry, use the vector shape handler + if (shapeType || hasCustGeom) { const result = getVectorShape({ params, node, graphicData, size, marginOffset, anchorData, wrap, isAnchor }); if (result?.attrs && isHidden) { result.attrs.hidden = true; @@ -592,9 +599,10 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset const spPr = wsp.elements?.find((el) => el.name === 'wps:spPr'); if (!spPr) return null; - // Extract shape kind + // Extract shape kind from preset geometry, or parse custom geometry paths const prstGeom = spPr.elements?.find((el) => el.name === 'a:prstGeom'); const shapeKind = prstGeom?.attributes?.['prst']; + const customGeometry = !shapeKind ? extractCustomGeometry(spPr) : null; // Extract size and transformations const shapeXfrm = spPr.elements?.find((el) => el.name === 'a:xfrm'); @@ -664,6 +672,7 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset shapeType: 'vectorShape', attrs: { kind: shapeKind, + customGeometry: customGeometry || undefined, x, y, width, @@ -1099,13 +1108,17 @@ export function getVectorShape({ params, node, graphicData, size, marginOffset, return null; } - // Extract shape kind + // Extract shape kind from preset geometry, or parse custom geometry paths const prstGeom = spPr.elements?.find((el) => el.name === 'a:prstGeom'); const shapeKind = prstGeom?.attributes?.['prst']; + schemaAttrs.kind = shapeKind; + if (!shapeKind) { - console.warn('Shape kind not found'); + const customGeometry = extractCustomGeometry(spPr); + if (customGeometry) { + schemaAttrs.customGeometry = customGeometry; + } } - schemaAttrs.kind = shapeKind; // Use wp:extent for dimensions (final displayed size from anchor) // This is the correct size that Word displays the shape at 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 fcaabaf00..cfa863bb3 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 @@ -18,6 +18,7 @@ vi.mock('./vector-shape-helpers.js', () => ({ extractStrokeColor: vi.fn(), extractStrokeWidth: vi.fn(), extractLineEnds: vi.fn(), + extractCustomGeometry: vi.fn(() => null), })); describe('handleImageNode', () => { @@ -1195,17 +1196,13 @@ describe('getVectorShape', () => { expect(result.attrs.drawingContent).toBe(drawingNode); }); - it('handles missing shape kind with warning', () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('handles missing shape kind gracefully', () => { const graphicData = makeGraphicData(); graphicData.elements[0].elements[0].elements[0].attributes = {}; // No prst const result = getVectorShape({ params: makeParams(), node: {}, graphicData, size: { width: 72, height: 72 } }); - expect(consoleWarnSpy).toHaveBeenCalledWith('Shape kind not found'); expect(result.attrs.kind).toBeUndefined(); - - consoleWarnSpy.mockRestore(); }); it('correctly prioritizes wp:extent over a:xfrm/a:ext for dimensions', () => { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js index 013340c93..91dfb6d7e 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js @@ -449,6 +449,87 @@ export function extractFillColor(spPr, style) { return null; } +/** + * Extracts custom geometry path data from a shape's properties (spPr). + * Parses OOXML a:custGeom/a:pathLst into SVG-compatible path data. + * + * OOXML path commands map to SVG: + * a:moveTo/a:pt → M x y + * a:lnTo/a:pt → L x y + * a:cubicBezTo (3 a:pt) → C x1 y1 x2 y2 x y + * a:close → Z + * + * @param {Object} spPr - The shape properties element (a:spPr or wps:spPr) + * @returns {{ paths: Array<{ d: string, fill: string }>, width: number, height: number }|null} + */ +export function extractCustomGeometry(spPr) { + const custGeom = spPr?.elements?.find((el) => el.name === 'a:custGeom'); + if (!custGeom) return null; + + const pathLst = custGeom.elements?.find((el) => el.name === 'a:pathLst'); + if (!pathLst?.elements?.length) return null; + + const paths = []; + let maxWidth = 0; + let maxHeight = 0; + + for (const pathEl of pathLst.elements) { + if (pathEl.name !== 'a:path') continue; + + const w = parseInt(pathEl.attributes?.['w'] || '0', 10); + const h = parseInt(pathEl.attributes?.['h'] || '0', 10); + const fill = pathEl.attributes?.['fill'] || 'norm'; + + if (w > maxWidth) maxWidth = w; + if (h > maxHeight) maxHeight = h; + + const segments = []; + if (pathEl.elements) { + for (const cmd of pathEl.elements) { + switch (cmd.name) { + case 'a:moveTo': { + const pt = cmd.elements?.find((el) => el.name === 'a:pt'); + if (pt) { + segments.push(`M ${pt.attributes?.['x'] || 0} ${pt.attributes?.['y'] || 0}`); + } + break; + } + case 'a:lnTo': { + const pt = cmd.elements?.find((el) => el.name === 'a:pt'); + if (pt) { + segments.push(`L ${pt.attributes?.['x'] || 0} ${pt.attributes?.['y'] || 0}`); + } + break; + } + case 'a:cubicBezTo': { + const pts = cmd.elements?.filter((el) => el.name === 'a:pt') || []; + if (pts.length === 3) { + segments.push( + `C ${pts[0].attributes?.['x'] || 0} ${pts[0].attributes?.['y'] || 0} ` + + `${pts[1].attributes?.['x'] || 0} ${pts[1].attributes?.['y'] || 0} ` + + `${pts[2].attributes?.['x'] || 0} ${pts[2].attributes?.['y'] || 0}`, + ); + } + break; + } + case 'a:close': { + segments.push('Z'); + break; + } + } + } + } + + if (segments.length > 0) { + paths.push({ d: segments.join(' '), fill }); + } + } + + if (paths.length === 0 || (maxWidth === 0 && maxHeight === 0)) return null; + + return { paths, width: maxWidth, height: maxHeight }; +} + /** * Extracts gradient fill information from a:gradFill element * @param {Object} gradFill - The a:gradFill element diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js index 6d82abef7..673505ae5 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js @@ -6,6 +6,7 @@ import { extractStrokeWidth, extractStrokeColor, extractFillColor, + extractCustomGeometry, } from './vector-shape-helpers.js'; import { emuToPixels } from '@converter/helpers.js'; @@ -513,3 +514,181 @@ describe('extractFillColor', () => { expect(extractFillColor(spPr, style)).toBe('#808080'); }); }); + +describe('extractCustomGeometry', () => { + it('returns null when spPr has no a:custGeom', () => { + const spPr = { + elements: [{ name: 'a:prstGeom', attributes: { prst: 'rect' } }], + }; + expect(extractCustomGeometry(spPr)).toBeNull(); + }); + + it('returns null when custGeom has no pathLst', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [{ name: 'a:avLst' }], + }, + ], + }; + expect(extractCustomGeometry(spPr)).toBeNull(); + }); + + it('returns null when pathLst is empty', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [{ name: 'a:pathLst', elements: [] }], + }, + ], + }; + expect(extractCustomGeometry(spPr)).toBeNull(); + }); + + it('parses a simple rectangle path (moveTo, lnTo, close)', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '100', h: '200' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '0' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: '100', y: '0' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: '100', y: '200' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '200' } }] }, + { name: 'a:close' }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = extractCustomGeometry(spPr); + expect(result).not.toBeNull(); + expect(result.width).toBe(100); + expect(result.height).toBe(200); + expect(result.paths).toHaveLength(1); + expect(result.paths[0].d).toBe('M 0 0 L 100 0 L 100 200 L 0 200 Z'); + expect(result.paths[0].fill).toBe('norm'); + }); + + it('parses a path with fill="none"', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '50', h: '50', fill: 'none' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '0' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: '50', y: '50' } }] }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = extractCustomGeometry(spPr); + expect(result).not.toBeNull(); + expect(result.paths[0].fill).toBe('none'); + }); + + it('parses cubicBezTo commands', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '300', h: '300' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '0' } }] }, + { + name: 'a:cubicBezTo', + elements: [ + { name: 'a:pt', attributes: { x: '50', y: '100' } }, + { name: 'a:pt', attributes: { x: '150', y: '200' } }, + { name: 'a:pt', attributes: { x: '300', y: '300' } }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = extractCustomGeometry(spPr); + expect(result).not.toBeNull(); + expect(result.paths[0].d).toBe('M 0 0 C 50 100 150 200 300 300'); + }); + + it('returns null when spPr is null/undefined', () => { + expect(extractCustomGeometry(null)).toBeNull(); + expect(extractCustomGeometry(undefined)).toBeNull(); + }); + + it('uses max width/height from multiple paths', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '100', h: '200' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '0' } }] }, + { name: 'a:close' }, + ], + }, + { + name: 'a:path', + attributes: { w: '300', h: '150' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '0' } }] }, + { name: 'a:close' }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = extractCustomGeometry(spPr); + expect(result).not.toBeNull(); + expect(result.width).toBe(300); + expect(result.height).toBe(200); + expect(result.paths).toHaveLength(2); + }); +}); 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 ed7a38fea..aea16fa47 100644 --- a/packages/super-editor/src/extensions/vector-shape/vector-shape.js +++ b/packages/super-editor/src/extensions/vector-shape/vector-shape.js @@ -158,6 +158,11 @@ export const VectorShape = Node.create({ default: null, rendered: false, }, + + customGeometry: { + default: null, + rendered: false, + }, }; }, From b90473a1ed511987f0fe4789e1c2b6a16bbfe102 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 20 Feb 2026 10:07:45 -0300 Subject: [PATCH 2/2] feat(shapes): add full OOXML custGeom path command support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends custom geometry parsing with the remaining OOXML path commands and coordinate resolution system, completing spec compliance: - a:arcTo → SVG A: computes ellipse center from current pen position and stAng, derives end point, converts 60000ths-of-degree angles - a:quadBezTo → SVG Q: two control/end points - Built-in guide constants: w, h, wd2, hd2, r, b, l, t, hc, vc, ss, ls, ssd2–ssd32, cd2/cd4/cd8/3cd4 (angle constants) - Guide formula evaluation (a:gdLst): all 17 ECMA-376 operators (*/, +-, +/, ?:, abs, val, cos, sin, tan, sqrt, max, min, pin, mod, at2, cat2, sat2) - Per-path stroke attribute: a:path stroke="0" suppresses outline - Priority fix: customGeometry takes precedence over preset shapeKind default — prevents kind='rect' fallback from overriding custGeom paths --- packages/layout-engine/contracts/src/index.ts | 2 +- .../painters/dom/src/renderer.ts | 15 +- .../layout-engine/pm-adapter/src/utilities.ts | 1 + .../wp/helpers/vector-shape-helpers.js | 252 +++++++++++++++++- .../wp/helpers/vector-shape-helpers.test.js | 172 ++++++++++++ 5 files changed, 424 insertions(+), 18 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 829c631cc..3e9a66465 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -666,7 +666,7 @@ export type EffectExtent = { bottom: number; }; -export type CustomGeometryPath = { d: string; fill: string }; +export type CustomGeometryPath = { d: string; fill: string; stroke: boolean }; export type CustomGeometry = { paths: CustomGeometryPath[]; width: number; height: number }; export type VectorShapeStyle = { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ef02b75c7..9dcfd9dab 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2797,10 +2797,12 @@ export class DomPainter { contentContainer.style.width = `${innerWidth}px`; contentContainer.style.height = `${innerHeight}px`; - const svgMarkup = block.shapeKind - ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) - : block.customGeometry - ? this.createCustomGeometrySvg(block, innerWidth, innerHeight) + // customGeometry takes precedence: a:custGeom shapes have kind='rect' as their PM default, + // but the actual shape is defined by the custom path data, not the preset. + const svgMarkup = block.customGeometry + ? this.createCustomGeometrySvg(block, innerWidth, innerHeight) + : block.shapeKind + ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) : null; if (svgMarkup) { const svgElement = this.parseSafeSvg(svgMarkup); @@ -3124,9 +3126,12 @@ export class DomPainter { const pathElements = geom.paths .map((p) => { const pathFill = p.fill === 'none' ? 'none' : fillColor; + // Per-path stroke: a:path stroke="0" suppresses the outline for that path + const pathStroke = p.stroke === false ? 'none' : strokeColor; + const pathStrokeWidth = p.stroke === false ? 0 : strokeWidth; // Sanitize d attribute — only allow SVG path commands and numbers const safeD = p.d.replace(/[^MmLlHhVvCcSsQqTtAaZz0-9.,\s\-+eE]/g, ''); - return ``; + return ``; }) .join(''); diff --git a/packages/layout-engine/pm-adapter/src/utilities.ts b/packages/layout-engine/pm-adapter/src/utilities.ts index 97d8d90af..fa8ba2f18 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.ts @@ -809,6 +809,7 @@ export function normalizeCustomGeometry(value: unknown): CustomGeometry | undefi paths: validPaths.map((p: Record) => ({ d: p.d as string, fill: typeof p.fill === 'string' ? p.fill : 'norm', + stroke: p.stroke !== false, })), width: obj.width, height: obj.height, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js index 91dfb6d7e..c10dcd989 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js @@ -449,18 +449,174 @@ export function extractFillColor(spPr, style) { return null; } +/** + * Returns the built-in OOXML guide constants for a given path coordinate space. + * These are pre-defined names that can appear as coordinate or angle values in custGeom. + * + * Coordinates are in the path's own coordinate space (path.w × path.h). + * Angles are in 60,000ths of a degree. + * + * @param {number} pathW - Path coordinate space width (a:path @w) + * @param {number} pathH - Path coordinate space height (a:path @h) + * @returns {Record} + */ +function buildBuiltinGuides(pathW, pathH) { + const ss = Math.min(pathW, pathH); + const ls = Math.max(pathW, pathH); + return { + l: 0, + t: 0, + r: pathW, + b: pathH, + w: pathW, + h: pathH, + hc: Math.round(pathW / 2), + vc: Math.round(pathH / 2), + wd2: Math.round(pathW / 2), + hd2: Math.round(pathH / 2), + wd3: Math.round(pathW / 3), + hd3: Math.round(pathH / 3), + wd4: Math.round(pathW / 4), + hd4: Math.round(pathH / 4), + wd5: Math.round(pathW / 5), + hd5: Math.round(pathH / 5), + wd6: Math.round(pathW / 6), + hd6: Math.round(pathH / 6), + wd8: Math.round(pathW / 8), + hd8: Math.round(pathH / 8), + wd10: Math.round(pathW / 10), + wd32: Math.round(pathW / 32), + ss, + ls, + ssd2: Math.round(ss / 2), + ssd4: Math.round(ss / 4), + ssd6: Math.round(ss / 6), + ssd8: Math.round(ss / 8), + ssd16: Math.round(ss / 16), + ssd32: Math.round(ss / 32), + // Angle constants (in 60,000ths of a degree) + cd2: 10800000, + cd4: 5400000, + cd8: 2700000, + '3cd4': 16200000, + '3cd8': 8100000, + '5cd8': 13500000, + '7cd8': 18900000, + }; +} + +/** + * Evaluates a single OOXML guide formula against a resolved guide map. + * Supports all 17 formula operators from the ECMA-376 spec. + * + * @param {string} fmla - Formula string, e.g. "*\/ w 1 2" + * @param {Record} guides - Already-resolved guide values + * @returns {number} + */ +function evalGuideFormula(fmla, guides) { + const parts = fmla.trim().split(/\s+/); + const op = parts[0]; + const resolve = (v) => { + const n = Number(v); + if (!isNaN(n)) return n; + return guides[v] ?? 0; + }; + const a = () => resolve(parts[1]); + const b = () => resolve(parts[2]); + const c = () => resolve(parts[3]); + switch (op) { + case '*/': + return Math.round((a() * b()) / c()); + case '+-': + return a() + b() - c(); + case '+/': + return Math.round((a() + b()) / c()); + case '?:': + return a() > 0 ? b() : c(); + case 'abs': + return Math.abs(a()); + case 'val': + return a(); + case 'cos': + return Math.round(a() * Math.cos((b() / 60000) * (Math.PI / 180))); + case 'sin': + return Math.round(a() * Math.sin((b() / 60000) * (Math.PI / 180))); + case 'tan': + return Math.round(a() * Math.tan((b() / 60000) * (Math.PI / 180))); + case 'sqrt': + return Math.round(Math.sqrt(a())); + case 'max': + return Math.max(a(), b()); + case 'min': + return Math.min(a(), b()); + case 'pin': + return Math.max(a(), Math.min(c(), b())); + case 'mod': + return Math.round(Math.sqrt(a() ** 2 + b() ** 2 + c() ** 2)); + case 'at2': + return Math.round((Math.atan2(b(), a()) * 180 * 60000) / Math.PI); + case 'cat2': + return Math.round(a() * Math.cos(Math.atan2(c(), b()))); + case 'sat2': + return Math.round(a() * Math.sin(Math.atan2(c(), b()))); + default: + return 0; + } +} + +/** + * Parses the a:gdLst (guide list) element and returns a map of resolved guide names to values. + * Guides are processed in declaration order — guides can reference earlier guides. + * + * @param {Object|undefined} gdLst - The a:gdLst element + * @param {Record} baseGuides - Built-in constants to seed the context + * @returns {Record} + */ +function parseGuideList(gdLst, baseGuides) { + const guides = { ...baseGuides }; + if (!gdLst?.elements) return guides; + for (const gd of gdLst.elements) { + if (gd.name !== 'a:gd') continue; + const name = gd.attributes?.name; + const fmla = gd.attributes?.fmla; + if (name && fmla) { + guides[name] = evalGuideFormula(fmla, guides); + } + } + return guides; +} + +/** + * Resolves a coordinate or angle value that may be a literal number or a guide name. + * + * @param {string|number|undefined} value + * @param {Record} guides + * @returns {number} + */ +function resolveValue(value, guides) { + if (value === undefined || value === null) return 0; + const n = Number(value); + if (!isNaN(n)) return n; + return guides[String(value)] ?? 0; +} + /** * Extracts custom geometry path data from a shape's properties (spPr). * Parses OOXML a:custGeom/a:pathLst into SVG-compatible path data. * - * OOXML path commands map to SVG: - * a:moveTo/a:pt → M x y - * a:lnTo/a:pt → L x y - * a:cubicBezTo (3 a:pt) → C x1 y1 x2 y2 x y - * a:close → Z + * Supports all OOXML path commands: + * a:moveTo/a:pt → M x y + * a:lnTo/a:pt → L x y + * a:cubicBezTo/3×a:pt → C x1 y1 x2 y2 x y + * a:quadBezTo/2×a:pt → Q cx cy x y + * a:arcTo → A wR hR 0 largeArc sweep ex ey + * a:close → Z + * + * Also resolves OOXML built-in guide constants (w, h, wd2, hd2, r, b, cd4, etc.) + * and user-defined guide formulas from a:gdLst. * * @param {Object} spPr - The shape properties element (a:spPr or wps:spPr) - * @returns {{ paths: Array<{ d: string, fill: string }>, width: number, height: number }|null} + * @returns {{ paths: Array<{ d: string, fill: string, stroke: boolean }>, width: number, height: number }|null} */ export function extractCustomGeometry(spPr) { const custGeom = spPr?.elements?.find((el) => el.name === 'a:custGeom'); @@ -479,39 +635,111 @@ export function extractCustomGeometry(spPr) { const w = parseInt(pathEl.attributes?.['w'] || '0', 10); const h = parseInt(pathEl.attributes?.['h'] || '0', 10); const fill = pathEl.attributes?.['fill'] || 'norm'; + // stroke attribute: "0" or "false" means no stroke; default is true + const strokeAttr = pathEl.attributes?.['stroke']; + const stroke = strokeAttr !== '0' && strokeAttr !== 'false'; if (w > maxWidth) maxWidth = w; if (h > maxHeight) maxHeight = h; + // Build guide context: built-in constants for this path's coordinate space, + // then any user-defined guides from a:gdLst (processed in declaration order) + const builtins = buildBuiltinGuides(w, h); + const gdLst = custGeom.elements?.find((el) => el.name === 'a:gdLst'); + const guides = parseGuideList(gdLst, builtins); + const segments = []; + // Track current pen position — needed for a:arcTo center computation + let penX = 0; + let penY = 0; + if (pathEl.elements) { for (const cmd of pathEl.elements) { switch (cmd.name) { case 'a:moveTo': { const pt = cmd.elements?.find((el) => el.name === 'a:pt'); if (pt) { - segments.push(`M ${pt.attributes?.['x'] || 0} ${pt.attributes?.['y'] || 0}`); + const x = resolveValue(pt.attributes?.['x'], guides); + const y = resolveValue(pt.attributes?.['y'], guides); + penX = x; + penY = y; + segments.push(`M ${x} ${y}`); } break; } case 'a:lnTo': { const pt = cmd.elements?.find((el) => el.name === 'a:pt'); if (pt) { - segments.push(`L ${pt.attributes?.['x'] || 0} ${pt.attributes?.['y'] || 0}`); + const x = resolveValue(pt.attributes?.['x'], guides); + const y = resolveValue(pt.attributes?.['y'], guides); + penX = x; + penY = y; + segments.push(`L ${x} ${y}`); } break; } case 'a:cubicBezTo': { const pts = cmd.elements?.filter((el) => el.name === 'a:pt') || []; if (pts.length === 3) { + const coords = pts.map((p) => [ + resolveValue(p.attributes?.['x'], guides), + resolveValue(p.attributes?.['y'], guides), + ]); + penX = coords[2][0]; + penY = coords[2][1]; segments.push( - `C ${pts[0].attributes?.['x'] || 0} ${pts[0].attributes?.['y'] || 0} ` + - `${pts[1].attributes?.['x'] || 0} ${pts[1].attributes?.['y'] || 0} ` + - `${pts[2].attributes?.['x'] || 0} ${pts[2].attributes?.['y'] || 0}`, + `C ${coords[0][0]} ${coords[0][1]} ${coords[1][0]} ${coords[1][1]} ${coords[2][0]} ${coords[2][1]}`, ); } break; } + case 'a:quadBezTo': { + // Two a:pt children: control point + end point → SVG Q command + const pts = cmd.elements?.filter((el) => el.name === 'a:pt') || []; + if (pts.length === 2) { + const cx = resolveValue(pts[0].attributes?.['x'], guides); + const cy = resolveValue(pts[0].attributes?.['y'], guides); + const ex = resolveValue(pts[1].attributes?.['x'], guides); + const ey = resolveValue(pts[1].attributes?.['y'], guides); + penX = ex; + penY = ey; + segments.push(`Q ${cx} ${cy} ${ex} ${ey}`); + } + break; + } + case 'a:arcTo': { + // OOXML arcTo: the current pen position lies on the ellipse at stAng. + // The ellipse center is derived from the pen position and stAng. + // Angles are in 60,000ths of a degree. + const wR = resolveValue(cmd.attributes?.['wR'], guides); + const hR = resolveValue(cmd.attributes?.['hR'], guides); + const stAngRaw = resolveValue(cmd.attributes?.['stAng'], guides); + const swAngRaw = resolveValue(cmd.attributes?.['swAng'], guides); + + const stAngDeg = stAngRaw / 60000; + const swAngDeg = swAngRaw / 60000; + const stAngRad = (stAngDeg * Math.PI) / 180; + const swAngRad = (swAngDeg * Math.PI) / 180; + + // Compute ellipse center: pen = center + (wR*cos(stAng), hR*sin(stAng)) + const cx = penX - wR * Math.cos(stAngRad); + const cy = penY - hR * Math.sin(stAngRad); + + // Compute arc end point + const endAngRad = stAngRad + swAngRad; + const ex = cx + wR * Math.cos(endAngRad); + const ey = cy + hR * Math.sin(endAngRad); + + // SVG large-arc-flag: 1 if |sweep| > 180° + const largeArcFlag = Math.abs(swAngDeg) > 180 ? 1 : 0; + // SVG sweep-flag: 1 = clockwise (positive swAng) + const sweepFlag = swAngDeg > 0 ? 1 : 0; + + penX = Math.round(ex); + penY = Math.round(ey); + segments.push(`A ${wR} ${hR} 0 ${largeArcFlag} ${sweepFlag} ${penX} ${penY}`); + break; + } case 'a:close': { segments.push('Z'); break; @@ -521,7 +749,7 @@ export function extractCustomGeometry(spPr) { } if (segments.length > 0) { - paths.push({ d: segments.join(' '), fill }); + paths.push({ d: segments.join(' '), fill, stroke }); } } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js index 673505ae5..4c06aee1f 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js @@ -691,4 +691,176 @@ describe('extractCustomGeometry', () => { expect(result.height).toBe(200); expect(result.paths).toHaveLength(2); }); + + it('parses quadBezTo commands', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '200', h: '200' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '100' } }] }, + { + name: 'a:quadBezTo', + elements: [ + { name: 'a:pt', attributes: { x: '100', y: '0' } }, + { name: 'a:pt', attributes: { x: '200', y: '100' } }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = extractCustomGeometry(spPr); + expect(result).not.toBeNull(); + expect(result.paths[0].d).toBe('M 0 100 Q 100 0 200 100'); + }); + + it('parses arcTo commands and computes end point from pen position', () => { + // Right-angle arc: start at (200, 100), stAng=0° (east), swAng=90° clockwise → end at (100, 200) + // Ellipse center: (200 - 100*cos(0), 100 - 100*sin(0)) = (100, 100) + // End point: (100 + 100*cos(90°), 100 + 100*sin(90°)) = (100, 200) + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '200', h: '200' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: '200', y: '100' } }] }, + { name: 'a:arcTo', attributes: { wR: '100', hR: '100', stAng: '0', swAng: '5400000' } }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = extractCustomGeometry(spPr); + expect(result).not.toBeNull(); + expect(result.paths[0].d).toMatch(/^M 200 100 A 100 100 0 0 1/); + // End point should be near (100, 200) + const match = result.paths[0].d.match(/A \d+ \d+ \d+ \d+ \d+ (\d+) (\d+)/); + expect(Number(match[1])).toBeCloseTo(100, 0); + expect(Number(match[2])).toBeCloseTo(200, 0); + }); + + it('resolves built-in guide constants (wd2, hd2, r, b)', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '200', h: '100' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: 'wd2', y: '0' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: 'r', y: 'hd2' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: 'wd2', y: 'b' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: 'l', y: 'hd2' } }] }, + { name: 'a:close' }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = extractCustomGeometry(spPr); + expect(result).not.toBeNull(); + // wd2=100, r=200, hd2=50, b=100, l=0 + expect(result.paths[0].d).toBe('M 100 0 L 200 50 L 100 100 L 0 50 Z'); + }); + + it('evaluates user-defined guide formulas from a:gdLst', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:gdLst', + elements: [ + // margin = w / 10 = 200 / 10 = 20 + { name: 'a:gd', attributes: { name: 'margin', fmla: '*/ w 1 10' } }, + // inner = w - margin = 200 - 20 = 180 + { name: 'a:gd', attributes: { name: 'inner', fmla: '+- w 0 margin' } }, + ], + }, + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '200', h: '200' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: 'margin', y: 'margin' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: 'inner', y: 'margin' } }] }, + { name: 'a:close' }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = extractCustomGeometry(spPr); + expect(result).not.toBeNull(); + expect(result.paths[0].d).toBe('M 20 20 L 180 20 Z'); + }); + + it('respects path stroke="0" attribute', () => { + const spPr = { + elements: [ + { + name: 'a:custGeom', + elements: [ + { + name: 'a:pathLst', + elements: [ + { + name: 'a:path', + attributes: { w: '100', h: '100', stroke: '0' }, + elements: [ + { name: 'a:moveTo', elements: [{ name: 'a:pt', attributes: { x: '0', y: '0' } }] }, + { name: 'a:lnTo', elements: [{ name: 'a:pt', attributes: { x: '100', y: '100' } }] }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = extractCustomGeometry(spPr); + expect(result).not.toBeNull(); + expect(result.paths[0].stroke).toBe(false); + }); });