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
13 changes: 12 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 @@ -77,6 +78,7 @@ import {
} from './utils/sdt-helpers.js';
import { SdtGroupedHover } from './utils/sdt-hover.js';
import { computeTabWidth } from './utils/marker-helpers.js';
import { createCustomGeometrySvg } from './utils/custom-geometry-svg.js';
import { generateRulerDefinitionFromPx, createRulerElement, ensureRulerStyles } from './ruler/index.js';
import { toCssFontFamily } from '@superdoc/font-utils';
import {
Expand Down Expand Up @@ -3186,7 +3188,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
? 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 @@ -3768,6 +3776,7 @@ export class DomPainter {
const attrs = child.attrs as PositionedDrawingGeometry &
VectorShapeStyle & {
kind?: string;
customGeometry?: CustomGeometry;
shapeId?: string;
shapeName?: string;
textContent?: ShapeTextContent;
Expand All @@ -3794,6 +3803,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 @@ -6295,6 +6305,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import { createCustomGeometrySvg, type CustomGeometrySvgInput } from './custom-geometry-svg.js';

/** Helper to build a minimal block input. */
function makeBlock(overrides: Partial<CustomGeometrySvgInput> = {}): CustomGeometrySvgInput {
return {
geometry: { width: 100, height: 50 },
fillColor: '#ff0000',
strokeColor: '#000000',
strokeWidth: 2,
customGeometry: {
paths: [{ d: 'M 0 0 L 100 50', fill: 'solid', stroke: true }],
width: 200,
height: 100,
},
...overrides,
};
}

describe('createCustomGeometrySvg', () => {
it('returns null when no customGeometry', () => {
expect(createCustomGeometrySvg(makeBlock({ customGeometry: undefined }))).toBeNull();
});

it('returns null when paths array is empty', () => {
expect(
createCustomGeometrySvg(makeBlock({ customGeometry: { paths: [], width: 200, height: 100 } })),
).toBeNull();
});

it('generates SVG with correct dimensions and viewBox', () => {
const svg = createCustomGeometrySvg(makeBlock())!;
expect(svg).toContain('width="100"');
expect(svg).toContain('height="50"');
expect(svg).toContain('viewBox="0 0 200 100"');
expect(svg).toContain('preserveAspectRatio="none"');
});

it('uses width/height overrides when provided', () => {
const svg = createCustomGeometrySvg(makeBlock(), 300, 150)!;
expect(svg).toContain('width="300"');
expect(svg).toContain('height="150"');
// viewBox should still use geometry coordinate space
expect(svg).toContain('viewBox="0 0 200 100"');
});

it('resolves fillColor: null → none', () => {
const svg = createCustomGeometrySvg(makeBlock({ fillColor: null }))!;
expect(svg).toContain('fill="none"');
});

it('resolves fillColor: string → used directly', () => {
const svg = createCustomGeometrySvg(makeBlock({ fillColor: '#00ff00' }))!;
expect(svg).toContain('fill="#00ff00"');
});

it('resolves fillColor: non-string/non-null (gradient object) → none', () => {
const svg = createCustomGeometrySvg(makeBlock({ fillColor: { type: 'gradient' } as unknown as string }))!;
expect(svg).toContain('fill="none"');
});

it('resolves strokeColor: null → none', () => {
const svg = createCustomGeometrySvg(makeBlock({ strokeColor: null }))!;
expect(svg).toContain('stroke="none"');
});

it('resolves strokeColor: string → used directly', () => {
const svg = createCustomGeometrySvg(makeBlock({ strokeColor: '#0000ff' }))!;
expect(svg).toContain('stroke="#0000ff"');
});

it('defaults strokeWidth to 0 when undefined', () => {
const svg = createCustomGeometrySvg(makeBlock({ strokeWidth: undefined }))!;
expect(svg).toContain('stroke-width="0"');
});

it('applies per-path fill override: fill=none suppresses block fill', () => {
const block = makeBlock({
customGeometry: {
paths: [{ d: 'M 0 0 L 10 10', fill: 'none', stroke: true }],
width: 100,
height: 100,
},
});
const svg = createCustomGeometrySvg(block)!;
expect(svg).toContain('fill="none"');
});

it('applies per-path stroke suppression: stroke=false → stroke none, width 0', () => {
const block = makeBlock({
customGeometry: {
paths: [{ d: 'M 0 0 L 10 10', fill: 'solid', stroke: false }],
width: 100,
height: 100,
},
});
const svg = createCustomGeometrySvg(block)!;
expect(svg).toContain('stroke="none"');
expect(svg).toContain('stroke-width="0"');
});

it('sanitizes SVG d attribute — strips unsafe characters', () => {
const block = makeBlock({
customGeometry: {
paths: [{ d: 'M 0 0 L 10 10 <script>alert(1)</script>', fill: 'solid', stroke: true }],
width: 100,
height: 100,
},
});
const svg = createCustomGeometrySvg(block)!;
expect(svg).not.toContain('<script>');
expect(svg).not.toContain('alert');
// Valid path commands should survive
expect(svg).toContain('M 0 0 L 10 10');
});

it('generates multiple path elements for multiple paths', () => {
const block = makeBlock({
customGeometry: {
paths: [
{ d: 'M 0 0 L 50 50', fill: 'solid', stroke: true },
{ d: 'M 50 50 L 100 100', fill: 'none', stroke: false },
],
width: 100,
height: 100,
},
});
const svg = createCustomGeometrySvg(block)!;
const pathCount = (svg.match(/<path /g) || []).length;
expect(pathCount).toBe(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { CustomGeometry } from '@superdoc/contracts';

/**
* Minimal interface for the block properties needed by createCustomGeometrySvg.
* Avoids coupling to renderer-internal types (VectorShapeDrawingWithEffects).
*/
export interface CustomGeometrySvgInput {
customGeometry?: CustomGeometry;
geometry: { width: number; height: number };
fillColor?: string | null | unknown;
strokeColor?: string | null | unknown;
strokeWidth?: number;
}

/**
* 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.
*
* @param block - Block with custom geometry data and shape styling
* @param widthOverride - Override display width (pixels)
* @param heightOverride - Override display height (pixels)
* @returns SVG markup string, or null if no custom geometry
*/
export const createCustomGeometrySvg = (
block: CustomGeometrySvgInput,
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>`;
};
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
Loading
Loading