Skip to content

Comments

feat(shapes): add custom geometry (a:custGeom) SVG rendering#2125

Open
tupizz wants to merge 4 commits intomainfrom
tadeu/fix-custgeom-rendering
Open

feat(shapes): add custom geometry (a:custGeom) SVG rendering#2125
tupizz wants to merge 4 commits intomainfrom
tadeu/fix-custgeom-rendering

Conversation

@tupizz
Copy link
Contributor

@tupizz tupizz commented Feb 20, 2026

Demo

CleanShot 2026-02-20 at 10 15 05 CleanShot 2026-02-20 at 10 15 42

Summary

  • Adds end-to-end support for OOXML custom geometry (a:custGeom) shapes — parses all OOXML path commands into SVG and renders them correctly
  • Fixes shapes that were rendering as massive solid-color rectangles covering their entire bounding box
  • Implements full ECMA-376 spec compliance for a:custGeom path commands and coordinate system
  • Applies to both standalone vectorShape nodes and shapeGroup children

Why a separate PR from #2034?

PR #2034 fixed preset geometry rendering — a:prstClr colors, hairline strokes, and anchor positioning for shapes defined via a:prstGeom (named shapes like rect, 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

File Change
vector-shape-helpers.js extractCustomGeometry() — parses all OOXML path commands into SVG d-strings, evaluates guide constants and formulas
encode-image-node-helpers.js Routes a:custGeom shapes through vectorShape path instead of contentBlock placeholder
vector-shape.js Adds customGeometry attribute to PM schema
contracts/index.ts CustomGeometry / CustomGeometryPath types
pm-adapter/utilities.ts normalizeCustomGeometry() validator
pm-adapter/shapes.ts Passes customGeometry through to layout blocks
renderer.ts createCustomGeometrySvg() — SVG from custom paths; priority fix so custGeom takes precedence over preset kind default

Full OOXML → SVG path command support

OOXML SVG Notes
a:moveTo/a:pt M x y
a:lnTo/a:pt L x y
a:cubicBezTo (3×a:pt) C x1 y1 x2 y2 x y
a:quadBezTo (2×a:pt) Q cx cy x y
a:arcTo A wR hR 0 largeArc sweep ex ey Center derived from current pen + stAng
a:close Z
a:path/@w,@h SVG viewBox
a:path/@fill=none fill="none" Path-level fill override
a:path/@stroke=0 stroke="none" Per-path stroke suppression

Guide coordinate resolution

OOXML path coordinates can reference named constants and formulas instead of literal numbers. Full support for:

  • Built-in constants: w, h, wd2, hd2, r, b, l, t, hc, vc, ss, ls, ssd2–ssd32, cd2, cd4, cd8, 3cd4, 7cd8, etc.
  • All 17 guide formula operators (*/, +-, +/, ?:, abs, val, cos, sin, tan, sqrt, max, min, pin, mod, at2, cat2, sat2)
  • User-defined guides from a:gdLst processed in declaration order (guides can reference earlier guides)

Visual verification

Test DOCX with 7 custGeom shapes, all rendering correctly:

Shape Feature tested Result
Green rectangle moveTo + lnTo + close, literal coords ✅ Correct shape
Blue diamond Built-in guide constants (wd2, hd2, r, b, l, t) ✅ Diamond shape
Orange rounded rect a:arcTo (4 corner arcs) ✅ Rounded corners
Purple leaf a:quadBezTo ✅ Leaf shape
Teal wave a:cubicBezTo, fill=none, stroke-only ✅ Wave curve
Pink circle Two 180° a:arcTo arcs ✅ Perfect circle
Red star a:gdLst guide formulas (*/, +-) ✅ Star shape

Test plan

  • Unit tests for extractCustomGeometry: 14 cases covering all path commands, guide constants, guide formulas, arcTo math, stroke attribute
  • All 6185 tests pass across 663 test files
  • TypeScript type-check passes
  • Visual verification in dev browser: all 7 custGeom shapes render correctly
  • Load "SAMPLE DOC.docx" from Linear ticket SD-1331 → black rectangle replaced by proper SVG shapes

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.
@chatgpt-codex-connector
Copy link

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

Copilot AI review requested due to automatic review settings February 20, 2026 12:18
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

@tupizz tupizz self-assigned this Feb 20, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant