Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -701,6 +704,7 @@ export type ShapeGroupVectorChild = {
attrs: PositionedDrawingGeometry &
VectorShapeStyle & {
kind?: string;
customGeometry?: CustomGeometry;
shapeId?: string;
shapeName?: string;
};
Expand Down Expand Up @@ -742,6 +746,7 @@ export type VectorShapeDrawing = DrawingBlockBase & {
drawingKind: 'vectorShape';
geometry: DrawingGeometry;
shapeKind?: string;
customGeometry?: CustomGeometry;
fillColor?: FillColor;
strokeColor?: StrokeColor;
strokeWidth?: number;
Expand Down
57 changes: 56 additions & 1 deletion packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 `<path d="${safeD}" fill="${pathFill}" stroke="${pathStroke}" stroke-width="${pathStrokeWidth}" />`;
})
.join('');

return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${geom.width} ${geom.height}" preserveAspectRatio="none">${pathElements}</svg>`;
}

private parseSafeSvg(markup: string): SVGElement | null {
const DOMParserCtor = this.doc?.defaultView?.DOMParser ?? (typeof DOMParser !== 'undefined' ? DOMParser : null);
if (!DOMParserCtor) {
Expand Down Expand Up @@ -3772,6 +3824,7 @@ export class DomPainter {
const attrs = child.attrs as PositionedDrawingGeometry &
VectorShapeStyle & {
kind?: string;
customGeometry?: CustomGeometry;
shapeId?: string;
shapeName?: string;
textContent?: ShapeTextContent;
Expand All @@ -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,
Expand Down Expand Up @@ -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 ?? '',
Expand Down
2 changes: 2 additions & 0 deletions packages/layout-engine/pm-adapter/src/converters/shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
isShapeGroupTransform,
normalizeShapeSize,
normalizeShapeGroupChildren,
normalizeCustomGeometry,
normalizeFillColor,
normalizeStrokeColor,
normalizeLineEnds,
Expand Down Expand Up @@ -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),
Expand Down
25 changes: 25 additions & 0 deletions packages/layout-engine/pm-adapter/src/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import type {
BoxSpacing,
CustomGeometry,
DrawingBlock,
DrawingContentSnapshot,
ImageBlock,
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>).d === 'string',
);
if (validPaths.length === 0) return undefined;
return {
paths: validPaths.map((p: Record<string, unknown>) => ({
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
// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -667,6 +675,7 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset
shapeType: 'vectorShape',
attrs: {
kind: shapeKind,
customGeometry: customGeometry || undefined,
x,
y,
width,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading