diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 168b53a74..690f7a813 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -667,6 +667,9 @@ export type EffectExtent = { bottom: number; }; +export type CustomGeometryPath = { d: string; fill: string; stroke: boolean }; +export type CustomGeometry = { paths: CustomGeometryPath[]; width: number; height: number }; + export type VectorShapeStyle = { fillColor?: FillColor; strokeColor?: StrokeColor; @@ -701,6 +704,7 @@ export type ShapeGroupVectorChild = { attrs: PositionedDrawingGeometry & VectorShapeStyle & { kind?: string; + customGeometry?: CustomGeometry; shapeId?: string; shapeName?: string; }; @@ -742,6 +746,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 191c35b3d..d0105af21 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'; @@ -3190,7 +3191,13 @@ export class DomPainter { contentContainer.style.width = `${innerWidth}px`; contentContainer.style.height = `${innerHeight}px`; - const svgMarkup = block.shapeKind ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) : null; + // 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); if (svgElement) { @@ -3483,6 +3490,51 @@ 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; + // 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 ``; + }) + .join(''); + + return `${pathElements}`; + } + private parseSafeSvg(markup: string): SVGElement | null { const DOMParserCtor = this.doc?.defaultView?.DOMParser ?? (typeof DOMParser !== 'undefined' ? DOMParser : null); if (!DOMParserCtor) { @@ -3772,6 +3824,7 @@ export class DomPainter { const attrs = child.attrs as PositionedDrawingGeometry & VectorShapeStyle & { kind?: string; + customGeometry?: CustomGeometry; shapeId?: string; shapeName?: string; textContent?: ShapeTextContent; @@ -3798,6 +3851,7 @@ export class DomPainter { drawingContentId: undefined, drawingContent: undefined, shapeKind: attrs.kind, + customGeometry: attrs.customGeometry, fillColor: attrs.fillColor, strokeColor: attrs.strokeColor, strokeWidth: attrs.strokeWidth, @@ -6296,6 +6350,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..fa8ba2f18 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,30 @@ 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', + stroke: p.stroke !== false, + })), + 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 f41e8c6cc..d19895146 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, @@ -491,9 +497,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; @@ -595,9 +602,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'); @@ -667,6 +675,7 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset shapeType: 'vectorShape', attrs: { kind: shapeKind, + customGeometry: customGeometry || undefined, x, y, width, @@ -1102,13 +1111,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 48cbe2032..5903d9d9c 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', () => { @@ -1198,17 +1199,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..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,6 +449,315 @@ 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. + * + * 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, stroke: boolean }>, 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'; + // 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) { + 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) { + 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 ${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; + } + } + } + } + + if (segments.length > 0) { + paths.push({ d: segments.join(' '), fill, stroke }); + } + } + + 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..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 @@ -6,6 +6,7 @@ import { extractStrokeWidth, extractStrokeColor, extractFillColor, + extractCustomGeometry, } from './vector-shape-helpers.js'; import { emuToPixels } from '@converter/helpers.js'; @@ -513,3 +514,353 @@ 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); + }); + + 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); + }); +}); 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, + }, }; },