diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index f344c8c285..cf2017bca7 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -700,6 +700,7 @@ export type ShapeGroupVectorChild = { attrs: PositionedDrawingGeometry & VectorShapeStyle & { kind?: string; + customGeometry?: CustomGeometryData; shapeId?: string; shapeName?: string; }; @@ -737,10 +738,26 @@ export type DrawingBlockBase = { attrs?: Record; }; +/** + * 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; diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 5858b11bf5..9bc2c0a9b9 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -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 diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index cb6b4ef03c..be35ce642a 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -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'; @@ -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%'); @@ -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; + + // 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})"` : ''; + const strokeAttr = + strokeColor !== 'none' ? ` stroke="${strokeColor}" stroke-width="${strokeWidth}"` : edgeStroke; + return ``; + }) + .join('\n '); + + return ` + ${pathElements} +`; + } + private parseSafeSvg(markup: string): SVGElement | null { const DOMParserCtor = this.doc?.defaultView?.DOMParser ?? (typeof DOMParser !== 'undefined' ? DOMParser : null); if (!DOMParserCtor) { @@ -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; } @@ -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) { @@ -3375,6 +3434,7 @@ export class DomPainter { const attrs = child.attrs as PositionedDrawingGeometry & VectorShapeStyle & { kind?: string; + customGeometry?: CustomGeometryData; shapeId?: string; shapeName?: string; textContent?: ShapeTextContent; @@ -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, diff --git a/packages/layout-engine/pm-adapter/src/converters/shapes.ts b/packages/layout-engine/pm-adapter/src/converters/shapes.ts index ff30f11e51..7bec2b776a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/shapes.ts +++ b/packages/layout-engine/pm-adapter/src/converters/shapes.ts @@ -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 { @@ -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), diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers-header.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers-header.test.js index 6416c600cb..697e828c2e 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers-header.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers-header.test.js @@ -16,6 +16,7 @@ vi.mock('./vector-shape-helpers.js', () => ({ extractFillColor: vi.fn(), extractStrokeColor: vi.fn(), extractStrokeWidth: vi.fn(), + extractCustomGeometry: vi.fn(), })); /** diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 3756175fc8..6a9f8ca4fe 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -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, @@ -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; } @@ -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'); @@ -664,6 +684,7 @@ const handleShapeGroup = (params, node, graphicData, size, padding, marginOffset shapeType: 'vectorShape', attrs: { kind: shapeKind, + customGeometry: customGeom || undefined, x, y, width, @@ -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]; @@ -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; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index fcaabaf00a..14c1ac861a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -18,6 +18,7 @@ vi.mock('./vector-shape-helpers.js', () => ({ extractStrokeColor: vi.fn(), extractStrokeWidth: vi.fn(), extractLineEnds: vi.fn(), + extractCustomGeometry: vi.fn(), })); describe('handleImageNode', () => { @@ -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', () => { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-shape-group-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-shape-group-helpers.test.js index 193dd63808..a152e4e974 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-shape-group-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-shape-group-helpers.test.js @@ -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', () => ({ diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js index 013340c935..e9b688d059 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js @@ -449,6 +449,89 @@ export function extractFillColor(spPr, style) { return null; } +/** + * Extracts custom geometry path data from a:custGeom element and converts it to SVG paths. + * Per ECMA-376, a:custGeom contains a:pathLst with path commands (moveTo, lnTo, cubicBezTo, + * quadBezTo, arcTo, close) in a coordinate space defined by the path's w/h attributes. + * @param {Object} spPr - The shape properties element (a:spPr or wps:spPr) + * @returns {{ paths: Array<{ d: string, w: number, h: number }> } | null} + */ +export function extractCustomGeometry(spPr) { + const custGeom = spPr?.elements?.find((el) => el.name === 'a:custGeom'); + if (!custGeom) return null; + + const pathLst = custGeom.elements?.find((el) => el.name === 'a:pathLst'); + if (!pathLst?.elements) return null; + + const paths = pathLst.elements + .filter((el) => el.name === 'a:path') + .map((pathEl) => { + const w = parseInt(pathEl.attributes?.['w'] || '0', 10); + const h = parseInt(pathEl.attributes?.['h'] || '0', 10); + const d = convertDrawingMLPathToSvg(pathEl); + return { d, w, h }; + }) + .filter((p) => p.d); + + if (paths.length === 0) return null; + return { paths }; +} + +/** + * Converts a DrawingML a:path element's child commands to an SVG path d attribute. + * Supports: moveTo→M, lnTo→L, cubicBezTo→C, quadBezTo→Q, close→Z + * @param {Object} pathEl - The a:path element + * @returns {string} SVG path d attribute + */ +function convertDrawingMLPathToSvg(pathEl) { + if (!pathEl?.elements) return ''; + + const parts = []; + for (const cmd of pathEl.elements) { + switch (cmd.name) { + case 'a:moveTo': { + const pt = cmd.elements?.find((el) => el.name === 'a:pt'); + if (pt) { + parts.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) { + parts.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) { + parts.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:quadBezTo': { + const pts = cmd.elements?.filter((el) => el.name === 'a:pt') || []; + if (pts.length === 2) { + parts.push( + `Q ${pts[0].attributes?.['x'] || 0} ${pts[0].attributes?.['y'] || 0} ` + + `${pts[1].attributes?.['x'] || 0} ${pts[1].attributes?.['y'] || 0}`, + ); + } + break; + } + case 'a:close': + parts.push('Z'); + break; + } + } + return parts.join(' '); +} + /** * Extracts gradient fill information from a:gradFill element * @param {Object} gradFill - The a:gradFill element diff --git a/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js index eb7fc9402e..1e7019c05f 100644 --- a/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js @@ -238,7 +238,8 @@ export class ShapeGroupView { } // Generate the shape based on its kind - const shapeKind = attrs.kind || 'rect'; + const shapeKind = attrs.kind; + const customGeometry = attrs.customGeometry; // Preserve null (from ), but provide default for undefined const fillColor = attrs.fillColor === null ? null : (attrs.fillColor ?? '#5b9bd5'); // Use null-coalescing to preserve null (from ), but provide default for undefined @@ -295,9 +296,66 @@ export class ShapeGroupView { return g; } + // Handle custom geometry paths (a:custGeom) — render SVG paths directly + if (customGeometry?.paths?.length) { + const fillStr = fillValue === null ? 'none' : typeof fillValue === 'string' ? fillValue : 'none'; + const strokeStr = strokeColor === null ? 'none' : strokeColor; + const strokeW = strokeColor === null ? 0 : strokeWidth; + + const firstPath = customGeometry.paths[0]; + const viewW = firstPath.w || width; + const viewH = firstPath.h || height; + + // Create a nested SVG with viewBox for proper coordinate mapping + const innerSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + innerSvg.setAttribute('x', '0'); + innerSvg.setAttribute('y', '0'); + innerSvg.setAttribute('width', width.toString()); + innerSvg.setAttribute('height', height.toString()); + innerSvg.setAttribute('viewBox', `0 0 ${viewW} ${viewH}`); + innerSvg.setAttribute('preserveAspectRatio', 'none'); + + for (const pathData of customGeometry.paths) { + const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + pathEl.setAttribute('d', pathData.d); + pathEl.setAttribute('fill', fillStr); + pathEl.setAttribute('fill-rule', 'evenodd'); + pathEl.setAttribute('stroke', strokeStr); + pathEl.setAttribute('stroke-width', strokeW.toString()); + + // Scale if this path has a different coordinate space + const pathW = pathData.w || viewW; + const pathH = pathData.h || viewH; + if (pathW !== viewW || pathH !== viewH) { + const scaleX = viewW / pathW; + const scaleY = viewH / pathH; + pathEl.setAttribute('transform', `scale(${scaleX}, ${scaleY})`); + } + innerSvg.appendChild(pathEl); + } + g.appendChild(innerSvg); + + // Add text content if present + if (attrs.textContent && attrs.textContent.parts) { + const pageNumber = this.editor?.options?.currentPageNumber; + const totalPages = this.editor?.options?.totalPageCount; + const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { + textVerticalAlign: attrs.textVerticalAlign, + textInsets: attrs.textInsets, + pageNumber, + totalPages, + }); + if (textGroup) { + g.appendChild(textGroup); + } + } + return g; + } + + // Fall through to preset shape rendering (default to 'rect' if no kind) try { const svgContent = getPresetShapeSvg({ - preset: shapeKind, + preset: shapeKind || 'rect', styleOverrides: { fill: fillValue || 'none', stroke: strokeColor === null ? 'none' : strokeColor, diff --git a/packages/super-editor/src/extensions/vector-shape/vector-shape.js b/packages/super-editor/src/extensions/vector-shape/vector-shape.js index ed7a38feab..6890c88fd7 100644 --- a/packages/super-editor/src/extensions/vector-shape/vector-shape.js +++ b/packages/super-editor/src/extensions/vector-shape/vector-shape.js @@ -67,6 +67,11 @@ export const VectorShape = Node.create({ }, }, + customGeometry: { + default: null, + rendered: false, + }, + lineEnds: { default: null, rendered: false,