-
Notifications
You must be signed in to change notification settings - Fork 73
fix(shapes): render grouped DrawingML shapes with custom geometry (SD-1877) #2105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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})"` : ''; | ||||||||||||||||||||||||
|
Comment on lines
+3136
to
+3138
|
||||||||||||||||||||||||
| const scaleX = viewW / pathW; | |
| const scaleY = viewH / pathH; | |
| const transform = needsTransform ? ` transform="scale(${scaleX}, ${scaleY})"` : ''; | |
| let transform = ''; | |
| if (needsTransform && pathW !== 0 && pathH !== 0) { | |
| const scaleX = viewW / pathW; | |
| const scaleY = viewH / pathH; | |
| if (Number.isFinite(scaleX) && Number.isFinite(scaleY)) { | |
| transform = ` transform="scale(${scaleX}, ${scaleY})"`; | |
| } | |
| } |
Copilot
AI
Feb 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The path data p.d is directly interpolated into HTML without escaping. While the coordinates come from XML attributes that are typically numeric, if the XML parser allows special characters or if there's any way to inject SVG commands through malformed coordinates, this could create an XSS vector. The parseSafeSvg method strips unsafe content but only after the string is constructed. Consider HTML-escaping the path data before interpolation as a defense-in-depth measure, even though the current implementation should be safe for well-formed DOCX files.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
firstPath.wis0(from the parser's default),viewWwill be0, which will cause the SVGviewBox="0 0 0 ..."to be invalid. This creates an invalid SVG that may not render. Consider using the shape's actualwidthparameter as a minimum fallback, or validating thatviewWandviewHare positive before using them.