From f533b9408cc26511ca09f9cffda0ebf303737a67 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 18 Feb 2026 22:03:47 -0300 Subject: [PATCH 1/3] fix(shapes): render grouped DrawingML shapes with custom geometry (SD-1877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouped DrawingML shapes with custom geometry (a:custGeom) were rendering as solid black rectangles because the importer and renderers only supported preset geometry (a:prstGeom). This adds full custom geometry support and fixes several related sizing issues. - Add extractCustomGeometry() parser that converts DrawingML path commands (moveTo, lnTo, cubicBezTo, quadBezTo, close) to SVG path data - Wire custom geometry through the full pipeline: import → PM node attr → pm-adapter → contracts → DomPainter + ShapeGroupView - Use fill-rule="evenodd" for hollow frame paths (winding creates cutout) - Add vector-effect="non-scaling-stroke" hairline stroke so thin borders remain visible after non-uniform group coordinate scaling - Fix floating drawing width: wrapNone drawings no longer constrained to content area width, matching Word's behavior - Rewrite group coordinate rendering to use visual-space positioning (pre-scaled by importer) instead of CSS scale transforms --- packages/layout-engine/contracts/src/index.ts | 17 +++ .../layout-engine/measuring/dom/src/index.ts | 5 +- .../painters/dom/src/renderer.ts | 111 ++++++++++++++---- .../pm-adapter/src/converters/shapes.ts | 10 +- .../encode-image-node-helpers-header.test.js | 1 + .../wp/helpers/encode-image-node-helpers.js | 58 +++++++-- .../helpers/encode-image-node-helpers.test.js | 7 +- .../encode-shape-group-helpers.test.js | 1 + .../wp/helpers/vector-shape-helpers.js | 83 +++++++++++++ .../extensions/shape-group/ShapeGroupView.js | 62 +++++++++- .../extensions/vector-shape/vector-shape.js | 5 + 11 files changed, 316 insertions(+), 44 deletions(-) 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, From 5c90e4bc98cf9cac5bf3bb68632fda5e23eebcab Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 25 Feb 2026 16:17:15 -0300 Subject: [PATCH 2/3] fix(shapes): address custom geometry rendering correctness issues (SD-1877) - Add zero-dimension viewBox guard in DomPainter (return null when viewW/viewH is 0) - Add the same guard in ShapeGroupView so it falls through to fallback rendering - Add hairline edge stroke workaround to ShapeGroupView to match DomPainter behavior (filled shapes without a stroke can lose sub-pixel borders under non-uniform group transforms) - Add explicit default: break in the DrawingML path command switch to document that unknown commands (e.g. arcTo) are intentionally skipped - Fix JSDoc: remove arcTo from the supported commands list since it has no handler --- .../painters/dom/src/renderer.ts | 3 + .../wp/helpers/vector-shape-helpers.js | 7 +- .../extensions/shape-group/ShapeGroupView.js | 70 ++++++++++++------- 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index be35ce642a..cb19b12081 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3119,6 +3119,9 @@ export class DomPainter { const viewW = firstPath.w || width; const viewH = firstPath.h || height; + // Degenerate: zero-dimension viewBox is invalid SVG — skip rendering. + if (viewW === 0 || viewH === 0) return null; + // 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. 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 e9b688d059..3dfaedf9e3 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 @@ -452,7 +452,8 @@ export function extractFillColor(spPr, style) { /** * 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. + * quadBezTo, close) in a coordinate space defined by the path's w/h attributes. + * Note: arcTo is not currently translated (no SVG arc equivalent is emitted; it is skipped). * @param {Object} spPr - The shape properties element (a:spPr or wps:spPr) * @returns {{ paths: Array<{ d: string, w: number, h: number }> } | null} */ @@ -480,6 +481,7 @@ export function extractCustomGeometry(spPr) { /** * 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 + * Unsupported commands (e.g. arcTo) are intentionally skipped — they produce no output. * @param {Object} pathEl - The a:path element * @returns {string} SVG path d attribute */ @@ -527,6 +529,9 @@ function convertDrawingMLPathToSvg(pathEl) { case 'a:close': parts.push('Z'); break; + default: + // Unknown DrawingML path commands (e.g. arcTo) are skipped — no SVG equivalent is emitted. + break; } } return parts.join(' '); diff --git a/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js index 1e7019c05f..8a774f4155 100644 --- a/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js @@ -306,34 +306,52 @@ export class ShapeGroupView { 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})`); + // Degenerate: zero-dimension viewBox is invalid SVG — skip custom geometry rendering. + if (viewW > 0 && viewH > 0) { + // 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 = fillStr !== 'none' && strokeStr === 'none'; + + // 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'); + + if (strokeStr !== 'none') { + pathEl.setAttribute('stroke', strokeStr); + pathEl.setAttribute('stroke-width', strokeW.toString()); + } else if (needsEdgeStroke) { + pathEl.setAttribute('stroke', fillStr); + pathEl.setAttribute('stroke-width', '0.5'); + pathEl.setAttribute('vector-effect', 'non-scaling-stroke'); + } else { + pathEl.setAttribute('stroke', 'none'); + pathEl.setAttribute('stroke-width', '0'); + } + + // 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); } - innerSvg.appendChild(pathEl); + g.appendChild(innerSvg); } - g.appendChild(innerSvg); // Add text content if present if (attrs.textContent && attrs.textContent.parts) { From 4774750b6bf43a4260913052e2e0d3d5cd28f524 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 26 Feb 2026 15:56:20 -0800 Subject: [PATCH 3/3] fix(painter-dom): fix custom geometry rendering priority, fills, and group text scaling --- devtools/visual-testing/pnpm-lock.yaml | 23 +-- .../src/renderer-shape-regressions.test.ts | 160 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 35 ++-- 3 files changed, 183 insertions(+), 35 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts diff --git a/devtools/visual-testing/pnpm-lock.yaml b/devtools/visual-testing/pnpm-lock.yaml index 9b5af7fa88..08a513a39b 100644 --- a/devtools/visual-testing/pnpm-lock.yaml +++ b/devtools/visual-testing/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@aws-sdk/client-s3': specifier: ^3.500.0 version: 3.983.0 - '@superdoc-testing/helpers': - specifier: workspace:* - version: link:packages/test-helpers dotenv: specifier: ^17.2.4 version: 17.2.4 @@ -2072,8 +2069,8 @@ packages: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} superdoc@file:../../packages/superdoc/superdoc.tgz: - resolution: {integrity: sha512-C+FnGjvFUyXpEASvhLJCuFuD9gtD9Smfa+zwi++GU4qzEFvWxaxiEmG7oI+AB+N1fpuBqzK93u1TdYNJCSaRWQ==, tarball: file:../../packages/superdoc/superdoc.tgz} - version: 1.14.1 + resolution: {integrity: sha512-vasgfL+tW8P8X8kQEa5rOtsv83xKc6BHfo/I2V1S0BKbaGb3T86oHTi4biYLGnpFOdOIKHg3Gw/NfPCPMU7jvQ==, tarball: file:../../packages/superdoc/superdoc.tgz} + version: 1.16.0 peerDependencies: '@hocuspocus/provider': ^2.13.6 pdfjs-dist: ^5.4.296 @@ -3844,7 +3841,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color optional: true @@ -3987,6 +3984,10 @@ snapshots: date-fns@4.1.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 @@ -4303,14 +4304,14 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color optional: true @@ -4318,7 +4319,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -4897,7 +4898,7 @@ snapshots: vite-node@3.2.4(@types/node@22.19.9)(tsx@4.21.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 6.4.1(@types/node@22.19.9)(tsx@4.21.0) @@ -4939,7 +4940,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 diff --git a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts new file mode 100644 index 0000000000..bc29f2995f --- /dev/null +++ b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createDomPainter } from './index.js'; +import type { DrawingGeometry, FlowBlock, Layout, Measure, SolidFillWithAlpha } from '@superdoc/contracts'; + +type DrawingFlowBlock = Extract; + +function createDrawingFixtures(block: DrawingFlowBlock): { blocks: FlowBlock[]; measures: Measure[]; layout: Layout } { + const geometry = block.geometry; + const measure: Measure = { + kind: 'drawing', + drawingKind: block.drawingKind, + width: geometry.width, + height: geometry.height, + scale: 1, + naturalWidth: geometry.width, + naturalHeight: geometry.height, + geometry, + groupTransform: block.drawingKind === 'shapeGroup' ? block.groupTransform : undefined, + }; + + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'drawing', + blockId: block.id, + drawingKind: block.drawingKind, + x: 20, + y: 20, + width: geometry.width, + height: geometry.height, + geometry, + scale: 1, + isAnchored: false, + }, + ], + }, + ], + }; + + return { + blocks: [block], + measures: [measure], + layout, + }; +} + +describe('DomPainter shape regressions', () => { + let mount: HTMLElement; + + beforeEach(() => { + mount = document.createElement('div'); + document.body.appendChild(mount); + }); + + afterEach(() => { + mount.remove(); + }); + + it('prefers custom geometry paths over preset lookups when both are present', () => { + const geometry: DrawingGeometry = { width: 120, height: 120, rotation: 0, flipH: false, flipV: false }; + const customPath = 'M 0 100 L 50 0 L 100 100 Z'; + + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'custom-over-preset', + drawingKind: 'vectorShape', + geometry, + shapeKind: 'rect', + customGeometry: { + paths: [{ d: customPath, w: 100, h: 100 }], + }, + fillColor: '#0EA5E9', + strokeColor: '#0F172A', + strokeWidth: 1, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const renderedPath = mount.querySelector(`.superdoc-vector-shape svg path[d="${customPath}"]`); + expect(renderedPath).toBeTruthy(); + }); + + it('keeps custom-geometry object fills paintable for solidWithAlpha fills', () => { + const geometry: DrawingGeometry = { width: 120, height: 120, rotation: 0, flipH: false, flipV: false }; + const alphaFill: SolidFillWithAlpha = { type: 'solidWithAlpha', color: '#22C55E', alpha: 0.4 }; + + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'custom-geometry-solid-alpha', + drawingKind: 'vectorShape', + geometry, + customGeometry: { + paths: [{ d: 'M 0 0 L 100 0 L 100 100 L 0 100 Z', w: 100, h: 100 }], + }, + fillColor: alphaFill, + strokeColor: null, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const path = mount.querySelector('.superdoc-vector-shape svg path') as SVGPathElement | null; + expect(path).toBeTruthy(); + expect(path?.getAttribute('fill')).toBe(alphaFill.color); + expect(path?.getAttribute('fill-opacity')).toBe(String(alphaFill.alpha)); + }); + + it('does not inverse-scale shape-group text when child geometry is already pre-scaled', () => { + const geometry: DrawingGeometry = { width: 200, height: 100, rotation: 0, flipH: false, flipV: false }; + + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'shape-group-text-no-inverse-scale', + drawingKind: 'shapeGroup', + geometry, + groupTransform: { + width: 200, + height: 100, + childWidth: 100, + childHeight: 50, + }, + shapes: [ + { + shapeType: 'vectorShape', + attrs: { + x: 0, + y: 0, + width: 200, + height: 100, + kind: 'rect', + fillColor: '#E2E8F0', + textAlign: 'left', + textContent: { + parts: [{ text: 'Grouped text' }], + }, + }, + }, + ], + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const textOverlay = mount.querySelector( + '.superdoc-shape-group .superdoc-vector-shape div[style*="display: flex"]', + ) as HTMLElement | null; + expect(textOverlay).toBeTruthy(); + expect(textOverlay?.style.transform).toBe(''); + expect(textOverlay?.style.width).toBe('100%'); + expect(textOverlay?.style.height).toBe('100%'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index faef99ea03..bf86df99da 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3188,11 +3188,12 @@ export class DomPainter { contentContainer.style.width = `${innerWidth}px`; contentContainer.style.height = `${innerHeight}px`; - const svgMarkup = block.shapeKind ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) : null; - // Try custom geometry when no preset shape is available - const customGeomSvg = - !svgMarkup && block.customGeometry ? this.tryCreateCustomGeometrySvg(block, innerWidth, innerHeight) : null; - const resolvedSvgMarkup = svgMarkup || customGeomSvg; + // Custom geometry takes priority — shapeKind may carry a schema default ('rect') + // even when the source shape only had a:custGeom and no a:prstGeom. + const customGeomSvg = block.customGeometry ? this.tryCreateCustomGeometrySvg(block, innerWidth, innerHeight) : null; + const svgMarkup = + !customGeomSvg && block.shapeKind ? this.tryCreatePresetSvg(block, innerWidth, innerHeight) : null; + const resolvedSvgMarkup = customGeomSvg || svgMarkup; if (resolvedSvgMarkup) { const svgElement = this.parseSafeSvg(resolvedSvgMarkup); @@ -3350,17 +3351,6 @@ export class DomPainter { textDiv.style.fontSize = '12px'; textDiv.style.lineHeight = '1.2'; - // Apply counter-scaling to prevent text from being stretched by parent group transform - if (groupScaleX !== 1 || groupScaleY !== 1) { - const counterScaleX = 1 / groupScaleX; - const counterScaleY = 1 / groupScaleY; - textDiv.style.transform = `scale(${counterScaleX}, ${counterScaleY})`; - textDiv.style.transformOrigin = 'top left'; - // Adjust dimensions to compensate for counter-scaling - textDiv.style.width = `${100 * groupScaleX}%`; - textDiv.style.height = `${100 * groupScaleY}%`; - } - // Horizontal text alignment uses CSS text-align property // Note: justifyContent is already set above for vertical alignment if (textAlign === 'center') { @@ -3501,7 +3491,10 @@ export class DomPainter { } else if (typeof block.fillColor === 'string') { fillColor = block.fillColor; } else { - fillColor = 'none'; + // Gradient / solidWithAlpha: use a placeholder fill so that downstream + // applyGradientToSVG / applyAlphaToSVG (which skip fill="none") can + // target these elements and replace the fill. + fillColor = '#000000'; } const strokeColor = block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : 'none'; @@ -3758,14 +3751,8 @@ export class DomPainter { const groupTransform = block.groupTransform; let contentContainer: HTMLElement = groupEl; - // 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'); @@ -3780,7 +3767,7 @@ export class DomPainter { } block.shapes.forEach((child) => { - const childContent = this.createGroupChildContent(child, groupScaleX, groupScaleY, context); + const childContent = this.createGroupChildContent(child, 1, 1, context); if (!childContent) return; const attrs = (child as ShapeGroupChild).attrs ?? {}; const wrapper = this.doc!.createElement('div');