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 ``;
+ }
+
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,
+ },
};
},