feat(shapes): add custom geometry (a:custGeom) SVG rendering#2125
feat(shapes): add custom geometry (a:custGeom) SVG rendering#2125
Conversation
Parse OOXML custom geometry paths (a:custGeom/a:pathLst) and render them as SVG instead of falling back to solid CSS background fills. This fixes shapes that use custom path commands (moveTo, lineTo, cubicBezTo, close) rendering as solid black rectangles covering their entire bounding box. End-to-end pipeline: DOCX import parses a:custGeom path commands into SVG d-strings, stores them as customGeometry attribute through PM schema and layout contracts, and DomPainter generates proper SVG elements with viewBox scaling. Applies to both standalone vectorShape nodes and shapeGroup children.
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive support for OOXML custom geometry (a:custGeom) shapes by parsing path commands into SVG and rendering them correctly. Previously, shapes using custom path commands rendered as massive solid-color rectangles covering their entire bounding box due to missing support in the import pipeline. The PR implements end-to-end support across 6 packages (converter → PM schema → contracts → PM adapter → renderer) to handle custom geometry alongside the existing preset geometry support.
Changes:
- Adds
extractCustomGeometry()function to parse OOXML custom path commands (moveTo, lineTo, cubicBezTo, close) into SVG-compatible path data - Extends vector shape schema, contracts, and PM adapter to carry custom geometry through the layout pipeline
- Implements
createCustomGeometrySvg()renderer method to generate SVG from custom paths with viewBox scaling
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
vector-shape-helpers.js |
Adds extractCustomGeometry() to parse a:custGeom/a:pathLst into SVG d-strings with path command mapping |
vector-shape-helpers.test.js |
Comprehensive tests for custom geometry extraction covering null inputs, rectangle, fill=none, cubicBezTo, and multiple paths |
encode-image-node-helpers.js |
Routes a:custGeom shapes through vectorShape path; extracts custom geometry in both standalone shapes and shape groups |
encode-image-node-helpers.test.js |
Updates test to remove obsolete console.warn expectation and adds mock for extractCustomGeometry |
vector-shape.js |
Adds customGeometry attribute to PM schema with rendered: false flag |
contracts/index.ts |
Defines CustomGeometry and CustomGeometryPath types for type safety across packages |
pm-adapter/utilities.ts |
Adds normalizeCustomGeometry() validator to ensure valid structure before passing to renderer |
pm-adapter/shapes.ts |
Passes normalized customGeometry through to layout blocks |
renderer.ts |
Implements createCustomGeometrySvg() to generate SVG with viewBox scaling, sanitizes path data, includes customGeometry in block version signature |
Comments suppressed due to low confidence (4)
packages/layout-engine/painters/dom/src/renderer.ts:3129
- Color values (fillColor and strokeColor) are interpolated directly into SVG markup without validation. While the SVG is later passed through parseSafeSvg which sanitizes it, it would be more secure to validate color values upfront using the existing validateHexColor utility function to prevent potential injection attacks. This is especially important since these values can come from OOXML attributes which, while typically trusted, could potentially contain malicious content in a crafted document.
Consider validating fill and stroke colors before interpolating them into the SVG string, similar to how text formatting colors are validated at line 3028. For example, validate fillColor and strokeColor using validateHexColor and fall back to 'none' if validation fails.
// 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;
// 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="${strokeColor}" stroke-width="${strokeWidth}" />`;
packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js:480
- The parseInt function at line 479-480 can return NaN if the attributes contain invalid values. While the code checks maxWidth === 0 && maxHeight === 0 at line 528, it doesn't account for NaN values which would fail the comparison. If both w and h are NaN, the function would return { paths, width: NaN, height: NaN } which could cause rendering issues downstream.
Consider adding validation to handle NaN values, for example: const w = parseInt(pathEl.attributes?.['w'] || '0', 10) || 0; to ensure NaN is converted to 0.
const w = parseInt(pathEl.attributes?.['w'] || '0', 10);
const h = parseInt(pathEl.attributes?.['h'] || '0', 10);
packages/layout-engine/pm-adapter/src/utilities.ts:815
- The normalizeCustomGeometry function doesn't sanitize the 'd' attribute path strings. While line 3128 in renderer.ts does sanitize the d attribute before rendering, it would be more robust to validate path data during normalization to catch issues earlier in the pipeline. Additionally, the normalizer doesn't validate that width and height are positive finite numbers - they could be negative, zero, Infinity, or very large values that could cause rendering issues.
Consider adding validation: 1) Check that width and height are positive finite numbers using isFiniteNumber and > 0 checks. 2) Optionally sanitize the 'd' string during normalization to reject obviously invalid path data early.
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',
})),
width: obj.width,
height: obj.height,
};
packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js:519
- The extractCustomGeometry function only handles four path command types (moveTo, lnTo, cubicBezTo, close). However, OOXML custom geometry can contain other path commands like a:quadBezTo (quadratic Bezier curve) and a:arcTo (arc curve). When these unhandled commands are encountered, they are silently ignored in the switch statement, which could result in incomplete or incorrect shape rendering.
Consider: 1) Add handling for a:quadBezTo (maps to SVG 'Q' command) and a:arcTo if these commands are used in practice, or 2) Add a default case in the switch statement to log a warning when unknown path commands are encountered, helping with debugging and future feature detection.
for (const cmd of pathEl.elements) {
switch (cmd.name) {
case 'a:moveTo': {
const pt = cmd.elements?.find((el) => el.name === 'a:pt');
if (pt) {
segments.push(`M ${pt.attributes?.['x'] || 0} ${pt.attributes?.['y'] || 0}`);
}
break;
}
case 'a:lnTo': {
const pt = cmd.elements?.find((el) => el.name === 'a:pt');
if (pt) {
segments.push(`L ${pt.attributes?.['x'] || 0} ${pt.attributes?.['y'] || 0}`);
}
break;
}
case 'a:cubicBezTo': {
const pts = cmd.elements?.filter((el) => el.name === 'a:pt') || [];
if (pts.length === 3) {
segments.push(
`C ${pts[0].attributes?.['x'] || 0} ${pts[0].attributes?.['y'] || 0} ` +
`${pts[1].attributes?.['x'] || 0} ${pts[1].attributes?.['y'] || 0} ` +
`${pts[2].attributes?.['x'] || 0} ${pts[2].attributes?.['y'] || 0}`,
);
}
break;
}
case 'a:close': {
segments.push('Z');
break;
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Extends custom geometry parsing with the remaining OOXML path commands and coordinate resolution system, completing spec compliance: - a:arcTo → SVG A: computes ellipse center from current pen position and stAng, derives end point, converts 60000ths-of-degree angles - a:quadBezTo → SVG Q: two control/end points - Built-in guide constants: w, h, wd2, hd2, r, b, l, t, hc, vc, ss, ls, ssd2–ssd32, cd2/cd4/cd8/3cd4 (angle constants) - Guide formula evaluation (a:gdLst): all 17 ECMA-376 operators (*/, +-, +/, ?:, abs, val, cos, sin, tan, sqrt, max, min, pin, mod, at2, cat2, sat2) - Per-path stroke attribute: a:path stroke="0" suppresses outline - Priority fix: customGeometry takes precedence over preset shapeKind default — prevents kind='rect' fallback from overriding custGeom paths
Demo
Summary
a:custGeom) shapes — parses all OOXML path commands into SVG and renders them correctlya:custGeompath commands and coordinate systemvectorShapenodes andshapeGroupchildrenWhy a separate PR from #2034?
PR #2034 fixed preset geometry rendering —
a:prstClrcolors, hairline strokes, and anchor positioning for shapes defined viaa:prstGeom(named shapes likerect,ellipse, etc.).This PR addresses a fundamentally different problem: shapes using custom geometry (
a:custGeom), which defines shapes via raw path commands (moveTo, lineTo, arc, bezier, close) — essentially raw drawing instructions equivalent to SVG path data. The import pipeline had zero support for parsing these, so all custom shapes fell back to a solid CSS fill on the full bounding box.Changes
vector-shape-helpers.jsextractCustomGeometry()— parses all OOXML path commands into SVG d-strings, evaluates guide constants and formulasencode-image-node-helpers.jsa:custGeomshapes through vectorShape path instead of contentBlock placeholdervector-shape.jscustomGeometryattribute to PM schemacontracts/index.tsCustomGeometry/CustomGeometryPathtypespm-adapter/utilities.tsnormalizeCustomGeometry()validatorpm-adapter/shapes.tscustomGeometrythrough to layout blocksrenderer.tscreateCustomGeometrySvg()— SVG from custom paths; priority fix so custGeom takes precedence over preset kind defaultFull OOXML → SVG path command support
a:moveTo/a:ptM x ya:lnTo/a:ptL x ya:cubicBezTo(3×a:pt)C x1 y1 x2 y2 x ya:quadBezTo(2×a:pt)Q cx cy x ya:arcToA wR hR 0 largeArc sweep ex eya:closeZa:path/@w,@hviewBoxa:path/@fill=nonefill="none"a:path/@stroke=0stroke="none"Guide coordinate resolution
OOXML path coordinates can reference named constants and formulas instead of literal numbers. Full support for:
w,h,wd2,hd2,r,b,l,t,hc,vc,ss,ls,ssd2–ssd32,cd2,cd4,cd8,3cd4,7cd8, etc.*/,+-,+/,?:,abs,val,cos,sin,tan,sqrt,max,min,pin,mod,at2,cat2,sat2)a:gdLstprocessed in declaration order (guides can reference earlier guides)Visual verification
Test DOCX with 7 custGeom shapes, all rendering correctly:
moveTo+lnTo+close, literal coordswd2,hd2,r,b,l,t)a:arcTo(4 corner arcs)a:quadBezToa:cubicBezTo,fill=none, stroke-onlya:arcToarcsa:gdLstguide formulas (*/,+-)Test plan
extractCustomGeometry: 14 cases covering all path commands, guide constants, guide formulas, arcTo math, stroke attribute