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
82 changes: 82 additions & 0 deletions packages/layout-engine/painters/dom/src/custom-geometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { createDomPainter } from './index.js';
import type { DrawingBlock, DrawingMeasure, Layout } from '@superdoc/contracts';

describe('DomPainter custom geometry shapes', () => {
let mount: HTMLElement;

beforeEach(() => {
mount = document.createElement('div');
document.body.appendChild(mount);
});

afterEach(() => {
mount.remove();
});

it('renders a:custGeom paths when kind is "custom" (no preset warning)', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

const drawingBlock: DrawingBlock = {
kind: 'drawing',
id: 'shape-custom-1',
drawingKind: 'vectorShape',
geometry: { width: 100, height: 80, rotation: 0, flipH: false, flipV: false },
shapeKind: 'custom',
fillColor: '#ff0000',
strokeColor: '#0000ff',
strokeWidth: 2,
attrs: {
customGeometry: {
paths: [{ d: 'M 0 0 L 100 0 L 100 80 L 0 80 Z' }],
},
},
};

const drawingMeasure: DrawingMeasure = {
kind: 'drawing',
drawingKind: 'vectorShape',
width: 100,
height: 80,
scale: 1,
naturalWidth: 100,
naturalHeight: 80,
geometry: { width: 100, height: 80, rotation: 0, flipH: false, flipV: false },
};

const drawingLayout: Layout = {
pageSize: { w: 400, h: 500 },
pages: [
{
number: 1,
fragments: [
{
kind: 'drawing',
blockId: 'shape-custom-1',
drawingKind: 'vectorShape',
x: 20,
y: 30,
width: 100,
height: 80,
geometry: { width: 100, height: 80, rotation: 0, flipH: false, flipV: false },
scale: 1,
},
],
},
],
};

const painter = createDomPainter({ blocks: [drawingBlock], measures: [drawingMeasure] });
painter.paint(drawingLayout, mount);

const path = mount.querySelector('svg path');
expect(path).toBeTruthy();
expect(path?.getAttribute('d')).toBe('M 0 0 L 100 0 L 100 80 L 0 80 Z');
expect(path?.getAttribute('fill')).toBe('#ff0000');
expect(path?.getAttribute('stroke')).toBe('#0000ff');
expect(path?.getAttribute('stroke-width')).toBe('2');

expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
89 changes: 88 additions & 1 deletion packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,49 @@ type EffectExtent = {
bottom: number;
};

type CustomGeometryPath = {
d: string;
fillRule?: string;
clipRule?: string;
};

type CustomGeometry = {
paths: CustomGeometryPath[];
};

function isCustomGeometry(value: unknown): value is CustomGeometry {
if (!value || typeof value !== 'object') return false;
const v = value as { paths?: unknown };
if (!Array.isArray(v.paths) || v.paths.length === 0) return false;
return v.paths.every((p) => {
if (!p || typeof p !== 'object') return false;
const path = p as { d?: unknown; fillRule?: unknown; clipRule?: unknown };
if (typeof path.d !== 'string' || path.d.length === 0) return false;
if (path.fillRule != null && typeof path.fillRule !== 'string') return false;
if (path.clipRule != null && typeof path.clipRule !== 'string') return false;
return true;
});
}

function escapeXmlAttribute(value: string): string {
return value.replace(/[&"'<>]/g, (ch) => {
switch (ch) {
case '&':
return '&amp;';
case '"':
return '&quot;';
case "'":
return '&apos;';
case '<':
return '&lt;';
case '>':
return '&gt;';
default:
return ch;
}
});
}

type VectorShapeDrawingWithEffects = VectorShapeDrawing & {
lineEnds?: LineEnds;
effectExtent?: EffectExtent;
Expand Down Expand Up @@ -2891,7 +2934,11 @@ export class DomPainter {
contentContainer.style.width = `${innerWidth}px`;
contentContainer.style.height = `${innerHeight}px`;

const svgMarkup = block.shapeKind ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) : null;
const svgMarkup =
this.tryCreateCustomGeometrySvg(block, innerWidth, innerHeight) ??
(block.shapeKind && block.shapeKind !== 'custom'
? this.tryCreatePresetSvg(block, innerWidth, innerHeight)
: null);
if (svgMarkup) {
const svgElement = this.parseSafeSvg(svgMarkup);
if (svgElement) {
Expand Down Expand Up @@ -3181,6 +3228,38 @@ export class DomPainter {
}
}

private tryCreateCustomGeometrySvg(
block: VectorShapeDrawing,
widthOverride?: number,
heightOverride?: number,
): string | null {
const attrs = block.attrs;
if (!attrs || typeof attrs !== 'object') return null;

const customGeometryRaw = (attrs as Record<string, unknown>).customGeometry;
if (!isCustomGeometry(customGeometryRaw)) return null;

const width = widthOverride ?? block.geometry.width;
const height = heightOverride ?? block.geometry.height;

const fill = block.fillColor === null ? 'none' : typeof block.fillColor === 'string' ? block.fillColor : '#000000';
const stroke =
block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : 'none';
const strokeWidth =
typeof block.strokeWidth === 'number' && Number.isFinite(block.strokeWidth) ? block.strokeWidth : 1;

const pathMarkup = customGeometryRaw.paths
.map((p) => {
const d = escapeXmlAttribute(p.d);
const fillRule = p.fillRule ? ` fill-rule="${escapeXmlAttribute(p.fillRule)}"` : '';
const clipRule = p.clipRule ? ` clip-rule="${escapeXmlAttribute(p.clipRule)}"` : '';
return `<path d="${d}" fill="${escapeXmlAttribute(fill)}" stroke="${escapeXmlAttribute(stroke)}" stroke-width="${strokeWidth}"${fillRule}${clipRule} />`;
})
.join('');

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

private parseSafeSvg(markup: string): SVGElement | null {
const DOMParserCtor = this.doc?.defaultView?.DOMParser ?? (typeof DOMParser !== 'undefined' ? DOMParser : null);
if (!DOMParserCtor) {
Expand Down Expand Up @@ -5687,9 +5766,17 @@ const deriveBlockVersion = (block: FlowBlock): string => {
}
if (block.drawingKind === 'vectorShape') {
const vector = block as VectorShapeDrawing;
const customGeometryVersion = (() => {
const attrs = vector.attrs;
if (!attrs || typeof attrs !== 'object') return '';
const customGeometryRaw = (attrs as Record<string, unknown>).customGeometry;
if (!isCustomGeometry(customGeometryRaw)) return '';
return customGeometryRaw.paths.map((p) => `${p.d}:${p.fillRule ?? ''}:${p.clipRule ?? ''}`).join('|');
})();
return [
'drawing:vector',
vector.shapeKind ?? '',
customGeometryVersion,
vector.fillColor ?? '',
vector.strokeColor ?? '',
vector.strokeWidth ?? '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -449,9 +449,10 @@ const handleShapeDrawing = (
const spPr = wsp.elements.find((el) => el.name === 'wps:spPr');
const prstGeom = spPr?.elements.find((el) => el.name === 'a:prstGeom');
const shapeType = prstGeom?.attributes['prst'];
const hasCustomGeometry = Boolean(spPr?.elements?.some((el) => el.name === 'a:custGeom'));

// For all other shapes (with or without text), or shapes with gradients, use the vector shape handler
if (shapeType) {
if (shapeType || hasCustomGeometry) {
const result = getVectorShape({ params, node, graphicData, size, marginOffset, anchorData, wrap, isAnchor });
if (result?.attrs && isHidden) {
result.attrs.hidden = true;
Expand Down Expand Up @@ -1060,18 +1061,24 @@ export function getVectorShape({ params, node, graphicData, size, marginOffset,
return null;
}

// Extract shape kind
// Use wp:extent for dimensions (final displayed size from anchor)
// This is the correct size that Word displays the shape at
const width = size?.width ?? DEFAULT_SHAPE_WIDTH;
const height = size?.height ?? DEFAULT_SHAPE_HEIGHT;

// Extract shape kind (preset geometry) and/or custom geometry (a:custGeom)
const prstGeom = spPr.elements?.find((el) => el.name === 'a:prstGeom');
const shapeKind = prstGeom?.attributes?.['prst'];
if (!shapeKind) {
// Extract custom geometry (a:custGeom) when no preset is provided
const customGeometry = extractCustomGeometry(spPr, width, height);
if (!shapeKind && !customGeometry) {
console.warn('Shape kind not found');
}
schemaAttrs.kind = shapeKind;

// Use wp:extent for dimensions (final displayed size from anchor)
// This is the correct size that Word displays the shape at
const width = size?.width ?? DEFAULT_SHAPE_WIDTH;
const height = size?.height ?? DEFAULT_SHAPE_HEIGHT;
schemaAttrs.kind = shapeKind || (customGeometry ? 'custom' : undefined);
if (customGeometry) {
schemaAttrs.customGeometry = customGeometry;
}

// Extract transformations from a:xfrm (rotation and flips are still valid)
const xfrm = spPr.elements?.find((el) => el.name === 'a:xfrm');
Expand Down Expand Up @@ -1125,3 +1132,95 @@ export function getVectorShape({ params, node, graphicData, size, marginOffset,
},
};
}

function extractCustomGeometry(spPr, targetWidthPx, targetHeightPx) {
const custGeom = spPr?.elements?.find((el) => el.name === 'a:custGeom');
if (!custGeom?.elements) return null;

const pathLst = custGeom.elements.find((el) => el.name === 'a:pathLst');
const paths = pathLst?.elements?.filter((el) => el.name === 'a:path') || [];
if (!paths.length) return null;

const toFiniteNumber = (value) => {
const numeric = typeof value === 'string' ? Number(value) : value;
return Number.isFinite(numeric) ? numeric : null;
};

const formatNumber = (value) => {
if (!Number.isFinite(value)) return '0';
const rounded = Math.round(value * 1000) / 1000;
return String(rounded);
};

const buildPathD = (pathEl, scaleX, scaleY) => {
const commands = [];
const pathChildren = pathEl?.elements || [];

const transformPoint = (ptEl) => {
const xEmu = toFiniteNumber(ptEl?.attributes?.x) ?? 0;
const yEmu = toFiniteNumber(ptEl?.attributes?.y) ?? 0;
const xPx = emuToPixels(xEmu) * scaleX;
const yPx = emuToPixels(yEmu) * scaleY;
return { x: xPx, y: yPx };
};

for (const child of pathChildren) {
if (!child) continue;
if (child.name === 'a:moveTo' || child.name === 'a:lnTo') {
const pt = child.elements?.find((el) => el.name === 'a:pt');
if (!pt) continue;
const { x, y } = transformPoint(pt);
commands.push(`${child.name === 'a:moveTo' ? 'M' : 'L'} ${formatNumber(x)} ${formatNumber(y)}`);
continue;
}

if (child.name === 'a:quadBezTo') {
const pts = (child.elements || []).filter((el) => el.name === 'a:pt');
if (pts.length < 2) continue;
const c = transformPoint(pts[0]);
const end = transformPoint(pts[1]);
commands.push(`Q ${formatNumber(c.x)} ${formatNumber(c.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`);
continue;
}

if (child.name === 'a:cubicBezTo') {
const pts = (child.elements || []).filter((el) => el.name === 'a:pt');
if (pts.length < 3) continue;
const c1 = transformPoint(pts[0]);
const c2 = transformPoint(pts[1]);
const end = transformPoint(pts[2]);
commands.push(
`C ${formatNumber(c1.x)} ${formatNumber(c1.y)} ${formatNumber(c2.x)} ${formatNumber(c2.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`,
);
continue;
}

if (child.name === 'a:close') {
commands.push('Z');
continue;
}
}

const d = commands.join(' ');
return d ? { d } : null;
};

const builtPaths = [];
for (const pathEl of paths) {
const intrinsicWEmu = toFiniteNumber(pathEl.attributes?.w);
const intrinsicHEmu = toFiniteNumber(pathEl.attributes?.h);
const intrinsicWPx = intrinsicWEmu ? emuToPixels(intrinsicWEmu) : null;
const intrinsicHPx = intrinsicHEmu ? emuToPixels(intrinsicHEmu) : null;

const scaleX =
intrinsicWPx && intrinsicWPx > 0 && Number.isFinite(targetWidthPx) ? targetWidthPx / intrinsicWPx : 1;
const scaleY =
intrinsicHPx && intrinsicHPx > 0 && Number.isFinite(targetHeightPx) ? targetHeightPx / intrinsicHPx : 1;

const built = buildPathD(pathEl, scaleX, scaleY);
if (built?.d) builtPaths.push(built);
}

if (!builtPaths.length) return null;
return { paths: builtPaths };
}
Loading