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
17 changes: 17 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,7 @@ export type ShapeGroupVectorChild = {
attrs: PositionedDrawingGeometry &
VectorShapeStyle & {
kind?: string;
customGeometry?: CustomGeometryData;
shapeId?: string;
shapeName?: string;
};
Expand Down Expand Up @@ -737,10 +738,26 @@ export type DrawingBlockBase = {
attrs?: Record<string, unknown>;
};

/**
* Custom geometry path data extracted from a:custGeom/a:pathLst.
* Each path has an SVG `d` attribute and its own coordinate space (w × h).
*/
export type CustomGeometryData = {
paths: Array<{
/** SVG path d attribute (M, L, C, Q, Z commands) */
d: string;
/** Coordinate space width for this path */
w: number;
/** Coordinate space height for this path */
h: number;
}>;
};

export type VectorShapeDrawing = DrawingBlockBase & {
drawingKind: 'vectorShape';
geometry: DrawingGeometry;
shapeKind?: string;
customGeometry?: CustomGeometryData;
fillColor?: FillColor;
strokeColor?: StrokeColor;
strokeWidth?: number;
Expand Down
5 changes: 4 additions & 1 deletion packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2916,7 +2916,10 @@ async function measureDrawingBlock(block: DrawingBlock, constraints: MeasureCons
const naturalWidth = Math.max(1, rotatedBounds.width);
const naturalHeight = Math.max(1, rotatedBounds.height);

const maxWidth = fullWidthMax ?? (constraints.maxWidth > 0 ? constraints.maxWidth : naturalWidth);
// For floating drawings (wrapNone), don't constrain to the content area width.
// These drawings are positioned independently and can extend to page edges.
const isFloating = block.wrap?.type === 'None';
const maxWidth = fullWidthMax ?? (constraints.maxWidth > 0 && !isFloating ? constraints.maxWidth : naturalWidth);

// For anchored drawings with negative vertical positioning (designed to overflow their container),
// bypass the height constraint. This is common for footer/header graphics that extend beyond
Expand Down
111 changes: 86 additions & 25 deletions 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,
CustomGeometryData,
} from '@superdoc/contracts';
import { calculateJustifySpacing, computeLinePmRange, shouldApplyJustify, SPACE_CHARS } from '@superdoc/contracts';
import { getPresetShapeSvg } from '@superdoc/preset-geometry';
Expand Down Expand Up @@ -2797,8 +2798,13 @@ export class DomPainter {
contentContainer.style.height = `${innerHeight}px`;

const svgMarkup = block.shapeKind ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) : null;
if (svgMarkup) {
const svgElement = this.parseSafeSvg(svgMarkup);
// Try custom geometry when no preset shape is available
const customGeomSvg =
!svgMarkup && block.customGeometry ? this.tryCreateCustomGeometrySvg(block, innerWidth, innerHeight) : null;
const resolvedSvgMarkup = svgMarkup || customGeomSvg;

if (resolvedSvgMarkup) {
const svgElement = this.parseSafeSvg(resolvedSvgMarkup);
if (svgElement) {
svgElement.setAttribute('width', '100%');
svgElement.setAttribute('height', '100%');
Expand Down Expand Up @@ -3086,6 +3092,61 @@ export class DomPainter {
}
}

/**
* Creates an SVG string from custom geometry path data (a:custGeom).
* Each path in the custom geometry has its own coordinate space (w × h) which is
* mapped to the shape's actual dimensions via the SVG viewBox.
*/
private tryCreateCustomGeometrySvg(block: VectorShapeDrawing, width: number, height: number): string | null {
const custGeom = block.customGeometry;
if (!custGeom?.paths?.length) return null;

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.strokeColor === null ? 0 : (block.strokeWidth ?? 0);

// Build SVG paths. Each path has its own coordinate space (w × h).
// Use the first path's coordinate space for the viewBox, and scale subsequent paths if needed.
const firstPath = custGeom.paths[0];
const viewW = firstPath.w || width;
const viewH = firstPath.h || height;
Comment on lines +3118 to +3120
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If firstPath.w is 0 (from the parser's default), viewW will be 0, which will cause the SVG viewBox="0 0 0 ..." to be invalid. This creates an invalid SVG that may not render. Consider using the shape's actual width parameter as a minimum fallback, or validating that viewW and viewH are positive before using them.

Copilot uses AI. Check for mistakes.

// When the SVG viewBox maps to a non-uniform aspect ratio (common with group transforms),
// thin fill borders can become sub-pixel on one axis. Add a hairline stroke matching the
// fill color with vector-effect="non-scaling-stroke" so edges remain at least 0.5px visible.
const needsEdgeStroke = fillColor !== 'none' && strokeColor === 'none';
const edgeStroke = needsEdgeStroke
? ` stroke="${fillColor}" stroke-width="0.5" vector-effect="non-scaling-stroke"`
: '';

const pathElements = custGeom.paths
.map((p) => {
// If this path has a different coordinate space, apply a transform to map it
const pathW = p.w || viewW;
const pathH = p.h || viewH;
const needsTransform = pathW !== viewW || pathH !== viewH;
const scaleX = viewW / pathW;
const scaleY = viewH / pathH;
const transform = needsTransform ? ` transform="scale(${scaleX}, ${scaleY})"` : '';
Comment on lines +3136 to +3138
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If pathW or pathH is zero (from parsing w="0" or h="0" attributes), the division viewW / pathW or viewH / pathH will produce Infinity. This will create an invalid transform="scale(Infinity, ...)" attribute in the SVG, potentially causing rendering issues. Consider adding a guard to skip the transform when either dimension is zero, or fallback to a default non-zero value.

Suggested change
const scaleX = viewW / pathW;
const scaleY = viewH / pathH;
const transform = needsTransform ? ` transform="scale(${scaleX}, ${scaleY})"` : '';
let transform = '';
if (needsTransform && pathW !== 0 && pathH !== 0) {
const scaleX = viewW / pathW;
const scaleY = viewH / pathH;
if (Number.isFinite(scaleX) && Number.isFinite(scaleY)) {
transform = ` transform="scale(${scaleX}, ${scaleY})"`;
}
}

Copilot uses AI. Check for mistakes.
const strokeAttr =
strokeColor !== 'none' ? ` stroke="${strokeColor}" stroke-width="${strokeWidth}"` : edgeStroke;
return `<path d="${p.d}" fill="${fillColor}" fill-rule="evenodd"${strokeAttr}${transform} />`;
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path data p.d is directly interpolated into HTML without escaping. While the coordinates come from XML attributes that are typically numeric, if the XML parser allows special characters or if there's any way to inject SVG commands through malformed coordinates, this could create an XSS vector. The parseSafeSvg method strips unsafe content but only after the string is constructed. Consider HTML-escaping the path data before interpolation as a defense-in-depth measure, even though the current implementation should be safe for well-formed DOCX files.

Copilot uses AI. Check for mistakes.
})
.join('\n ');

return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${viewW} ${viewH}" 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 @@ -3300,29 +3361,23 @@ export class DomPainter {
const groupTransform = block.groupTransform;
let contentContainer: HTMLElement = groupEl;

// Calculate scale factors for counter-scaling text
const groupScaleX = 1;
const groupScaleY = 1;
// Compute the group's non-uniform scale factors for text counter-scaling.
// The import pre-scales child positions/sizes from child coordinate space to visible space.
const childWidth = groupTransform?.childWidth ?? groupTransform?.width ?? block.geometry.width ?? 0;
const childHeight = groupTransform?.childHeight ?? groupTransform?.height ?? block.geometry.height ?? 0;
const visibleWidth = groupTransform?.width ?? block.geometry.width ?? 0;
const visibleHeight = groupTransform?.height ?? block.geometry.height ?? 0;
const groupScaleX = childWidth > 0 && visibleWidth > 0 ? visibleWidth / childWidth : 1;
const groupScaleY = childHeight > 0 && visibleHeight > 0 ? visibleHeight / childHeight : 1;

if (groupTransform) {
const inner = this.doc!.createElement('div');
inner.style.position = 'absolute';
inner.style.left = '0';
inner.style.top = '0';
const childWidth = groupTransform.childWidth ?? groupTransform.width ?? block.geometry.width ?? 0;
const childHeight = groupTransform.childHeight ?? groupTransform.height ?? block.geometry.height ?? 0;
inner.style.width = `${Math.max(1, childWidth)}px`;
inner.style.height = `${Math.max(1, childHeight)}px`;
const transforms: string[] = [];
const offsetX = groupTransform.childX ?? 0;
const offsetY = groupTransform.childY ?? 0;
if (offsetX || offsetY) {
transforms.push(`translate(${-offsetX}px, ${-offsetY}px)`);
}
if (transforms.length > 0) {
inner.style.transformOrigin = 'top left';
inner.style.transform = transforms.join(' ');
}
// Container at visible dimensions. Children use pre-scaled positions/sizes.
inner.style.width = `${Math.max(1, visibleWidth)}px`;
inner.style.height = `${Math.max(1, visibleHeight)}px`;
groupEl.appendChild(inner);
contentContainer = inner;
}
Expand All @@ -3334,12 +3389,16 @@ export class DomPainter {
const wrapper = this.doc!.createElement('div');
wrapper.classList.add('superdoc-shape-group__child');
wrapper.style.position = 'absolute';
wrapper.style.left = `${attrs.x ?? 0}px`;
wrapper.style.top = `${attrs.y ?? 0}px`;
const childWidthValue = typeof attrs.width === 'number' ? attrs.width : block.geometry.width;
const childHeightValue = typeof attrs.height === 'number' ? attrs.height : block.geometry.height;
wrapper.style.width = `${Math.max(1, childWidthValue)}px`;
wrapper.style.height = `${Math.max(1, childHeightValue)}px`;

// Children use pre-scaled (visual-space) positions/sizes from import.
wrapper.style.left = `${Number(attrs.x ?? 0)}px`;
wrapper.style.top = `${Number(attrs.y ?? 0)}px`;

const childW = typeof attrs.width === 'number' ? attrs.width : block.geometry.width;
const childH = typeof attrs.height === 'number' ? attrs.height : block.geometry.height;
wrapper.style.width = `${Math.max(1, childW)}px`;
wrapper.style.height = `${Math.max(1, childH)}px`;

wrapper.style.transformOrigin = 'center';
const transforms: string[] = [];
if (attrs.rotation) {
Expand Down Expand Up @@ -3375,6 +3434,7 @@ export class DomPainter {
const attrs = child.attrs as PositionedDrawingGeometry &
VectorShapeStyle & {
kind?: string;
customGeometry?: CustomGeometryData;
shapeId?: string;
shapeName?: string;
textContent?: ShapeTextContent;
Expand All @@ -3401,6 +3461,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
10 changes: 9 additions & 1 deletion packages/layout-engine/pm-adapter/src/converters/shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
* to DrawingBlocks
*/

import type { DrawingBlock, ImageBlock, VectorShapeDrawing, ShapeGroupDrawing, ImageAnchor } from '@superdoc/contracts';
import type {
DrawingBlock,
ImageBlock,
VectorShapeDrawing,
ShapeGroupDrawing,
ImageAnchor,
CustomGeometryData,
} from '@superdoc/contracts';
import type { PMNode, NodeHandlerContext, BlockIdGenerator, PositionMap } from '../types.js';
import type { EffectExtent, LineEnds } from '../utilities.js';
import {
Expand Down Expand Up @@ -359,6 +366,7 @@ export const buildDrawingBlock = (
attrs: attrsWithPm,
geometry,
shapeKind: typeof rawAttrs.kind === 'string' ? rawAttrs.kind : undefined,
customGeometry: rawAttrs.customGeometry != null ? (rawAttrs.customGeometry as CustomGeometryData) : undefined,
fillColor: normalizeFillColor(rawAttrs.fillColor),
strokeColor: normalizeStrokeColor(rawAttrs.strokeColor),
strokeWidth: coerceNumber(rawAttrs.strokeWidth),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ vi.mock('./vector-shape-helpers.js', () => ({
extractFillColor: vi.fn(),
extractStrokeColor: vi.fn(),
extractStrokeWidth: vi.fn(),
extractCustomGeometry: vi.fn(),
}));

/**
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 @@ -489,9 +495,22 @@ const handleShapeDrawing = (
const prstGeom = spPr?.elements.find((el) => el.name === 'a:prstGeom');
const shapeType = prstGeom?.attributes['prst'];

// For all other shapes (with or without text), or shapes with gradients, use the vector shape handler
if (shapeType) {
const result = getVectorShape({ params, node, graphicData, size, marginOffset, anchorData, wrap, isAnchor });
// Check for custom geometry when no preset geometry is found
const custGeom = !shapeType ? extractCustomGeometry(spPr) : null;

// For shapes with preset geometry or custom geometry, use the vector shape handler
if (shapeType || custGeom) {
const result = getVectorShape({
params,
node,
graphicData,
size,
marginOffset,
anchorData,
wrap,
isAnchor,
customGeometry: custGeom,
});
if (result?.attrs && isHidden) {
result.attrs.hidden = true;
}
Expand Down Expand Up @@ -592,9 +611,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 (preset geometry) or custom geometry
const prstGeom = spPr.elements?.find((el) => el.name === 'a:prstGeom');
const shapeKind = prstGeom?.attributes?.['prst'];
const customGeom = !shapeKind ? extractCustomGeometry(spPr) : null;

// Extract size and transformations
const shapeXfrm = spPr.elements?.find((el) => el.name === 'a:xfrm');
Expand Down Expand Up @@ -664,6 +684,7 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset
shapeType: 'vectorShape',
attrs: {
kind: shapeKind,
customGeometry: customGeom || undefined,
x,
y,
width,
Expand Down Expand Up @@ -1081,7 +1102,17 @@ const buildShapePlaceholder = (node, size, padding, marginOffset, shapeType) =>
* // }
* // }
*/
export function getVectorShape({ params, node, graphicData, size, marginOffset, anchorData, wrap, isAnchor }) {
export function getVectorShape({
params,
node,
graphicData,
size,
marginOffset,
anchorData,
wrap,
isAnchor,
customGeometry,
}) {
const schemaAttrs = {};

const drawingNode = params.nodes?.[0];
Expand All @@ -1099,14 +1130,21 @@ export function getVectorShape({ params, node, graphicData, size, marginOffset,
return null;
}

// Extract shape kind
// Extract shape kind (preset geometry) or custom geometry
const prstGeom = spPr.elements?.find((el) => el.name === 'a:prstGeom');
const shapeKind = prstGeom?.attributes?.['prst'];
if (!shapeKind) {
console.warn('Shape kind not found');
}
schemaAttrs.kind = shapeKind;

// Store custom geometry if provided (from a:custGeom) or extract it here
if (customGeometry) {
schemaAttrs.customGeometry = customGeometry;
} else if (!shapeKind) {
const extracted = extractCustomGeometry(spPr);
if (extracted) {
schemaAttrs.customGeometry = extracted;
}
}

// 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;
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(),
}));

describe('handleImageNode', () => {
Expand Down Expand Up @@ -1195,17 +1196,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 by trying custom geometry extraction', () => {
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ vi.mock('./vector-shape-helpers.js', () => ({
}),
extractStrokeWidth: vi.fn(() => 1),
extractLineEnds: vi.fn(() => null),
extractCustomGeometry: vi.fn(() => null),
}));

vi.mock('@core/utilities/carbonCopy.js', () => ({
Expand Down
Loading
Loading