From da7cbfaaa29129aec66f3d361d60bf3400afedad Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:43:50 -0300 Subject: [PATCH 1/7] refactor(painters/dom): unify drawing block rendering across renderer and table cells Move renderDrawingContent (vectorShape, chart, shapeGroup, shapeText paths) out of renderer.ts into drawings/renderDrawingContent.ts so the main renderer and table cells share a single rendering implementation. Extract the table-cell drawing wrapper into drawings/tableDrawingFrame.ts to deduplicate the in-flow and anchored drawing scaffolding, and thread the renderDrawingContent callback through renderTableCell so non-image drawings render via the shared path. Add unit coverage for the new renderDrawingContent module and the table cell frame builder. --- .../src/drawings/renderDrawingContent.test.ts | 115 ++ .../dom/src/drawings/renderDrawingContent.ts | 907 ++++++++++++++++ .../dom/src/drawings/tableDrawingFrame.ts | 68 ++ .../painters/dom/src/images/drawing-image.ts | 2 + .../painters/dom/src/renderer.ts | 982 +----------------- .../dom/src/table/renderTableCell.test.ts | 101 ++ .../painters/dom/src/table/renderTableCell.ts | 130 +-- 7 files changed, 1236 insertions(+), 1069 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts create mode 100644 packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts create mode 100644 packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts new file mode 100644 index 0000000000..ee55cf1bbe --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { DrawingBlock } from '@superdoc/contracts'; +import { buildImageHyperlinkAnchor } from '../images/hyperlink.js'; +import { renderDrawingContent } from './renderDrawingContent.js'; + +describe('renderDrawingContent', () => { + const createDoc = (): Document => document.implementation.createHTMLDocument('drawing-content'); + + it('renders vector shapes through the shared drawing content path', () => { + const doc = createDoc(); + const block: DrawingBlock = { + kind: 'drawing', + id: 'shape-1', + drawingKind: 'vectorShape', + geometry: { width: 100, height: 50 }, + shapeKind: 'rect', + fillColor: '#ff0000', + strokeColor: '#000000', + }; + + const el = renderDrawingContent({ + doc, + block, + geometry: block.geometry, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + + expect(el.classList.contains('superdoc-vector-shape')).toBe(true); + expect(el.querySelector('svg')).toBeTruthy(); + }); + + it('renders shape groups and charts through the shared drawing content path', () => { + const doc = createDoc(); + const shapeGroup: DrawingBlock = { + kind: 'drawing', + id: 'group-1', + drawingKind: 'shapeGroup', + geometry: { width: 100, height: 100 }, + shapes: [{ shapeType: 'image', attrs: { x: 0, y: 0, width: 40, height: 30, src: 'data:image/png;base64,AAA' } }], + }; + const chart: DrawingBlock = { + kind: 'drawing', + id: 'chart-1', + drawingKind: 'chart', + geometry: { width: 120, height: 80 }, + chartData: undefined, + }; + + const groupEl = renderDrawingContent({ + doc, + block: shapeGroup, + geometry: shapeGroup.geometry, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + const chartEl = renderDrawingContent({ + doc, + block: chart, + geometry: chart.geometry, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + + expect(groupEl.classList.contains('superdoc-shape-group')).toBe(true); + expect(groupEl.querySelector('img')).toBeTruthy(); + expect(chartEl.classList.contains('superdoc-chart')).toBe(true); + expect(chartEl.textContent).toContain('No chart data'); + }); + + it('renders fallback placeholders through the shared drawing content path', () => { + const doc = createDoc(); + const block = { + kind: 'drawing', + id: 'unknown-1', + drawingKind: 'unsupported', + } as unknown as DrawingBlock; + + const el = renderDrawingContent({ + doc, + block, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + + expect(el.classList.contains('superdoc-drawing-placeholder')).toBe(true); + expect(el.style.border).toContain('dashed'); + }); + + it('uses shared image behavior for filters, hyperlinks, and clip containers', () => { + const doc = createDoc(); + const clipContainer = doc.createElement('div'); + const block: DrawingBlock = { + kind: 'drawing', + id: 'image-1', + drawingKind: 'image', + src: 'data:image/png;base64,AAA', + clipPath: 'inset(10% 20% 30% 40%)', + grayscale: true, + hyperlink: { url: 'https://example.com/image', tooltip: 'Open image' }, + }; + + const el = renderDrawingContent({ + doc, + block, + clipContainer, + buildImageHyperlinkAnchor: (imageEl, hyperlink, display) => + buildImageHyperlinkAnchor(doc, imageEl, hyperlink, display), + }); + + const anchor = el as HTMLAnchorElement; + const img = anchor.querySelector('img.superdoc-drawing-image') as HTMLImageElement | null; + expect(anchor.tagName).toBe('A'); + expect(anchor.href).toBe('https://example.com/image'); + expect(img?.style.filter).toContain('grayscale(100%)'); + expect(img?.style.clipPath).toBe('inset(10% 20% 30% 40%)'); + expect(clipContainer.style.overflow).toBe('hidden'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts new file mode 100644 index 0000000000..09ec524ddf --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts @@ -0,0 +1,907 @@ +import type { + ChartDrawing, + CustomGeometryData, + DrawingBlock, + DrawingGeometry, + GradientFill, + PositionedDrawingGeometry, + ShapeGroupChild, + ShapeGroupDrawing, + ShapeTextContent, + SolidFillWithAlpha, + VectorShapeDrawing, + VectorShapeStyle, +} from '@superdoc/contracts'; +import { getPresetShapeSvg } from '@superdoc/preset-geometry'; +import { createChartElement as renderChartToElement } from '../chart-renderer.js'; +import { + createDrawingImageElement, + createShapeGroupImageElement, + createShapeTextImageElement, +} from '../images/drawing-image.js'; +import type { BuildImageHyperlinkAnchor } from '../images/types.js'; +import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from '../svg-utils.js'; +import type { FragmentRenderContext } from '../renderer.js'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const WORDART_LINE_FILL_RATIO = 0.9; + +type LineEnd = { + type?: string; + width?: string; + length?: string; +}; + +type LineEnds = { + head?: LineEnd; + tail?: LineEnd; +}; + +type EffectExtent = { + left: number; + top: number; + right: number; + bottom: number; +}; + +type VectorShapeDrawingWithEffects = VectorShapeDrawing & { + lineEnds?: LineEnds; + effectExtent?: EffectExtent; +}; + +export type RenderDrawingContentParams = { + doc: Document; + block: DrawingBlock; + geometry?: DrawingGeometry; + context?: FragmentRenderContext; + clipContainer?: HTMLElement; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; +}; + +export const createDrawingPlaceholder = (doc: Document): HTMLElement => { + const placeholder = doc.createElement('div'); + placeholder.classList.add('superdoc-drawing-placeholder'); + placeholder.style.width = '100%'; + placeholder.style.height = '100%'; + const stripePattern = + 'repeating-linear-gradient(45deg, rgba(15,23,42,0.1), rgba(15,23,42,0.1) 6px, rgba(15,23,42,0.2) 6px, rgba(15,23,42,0.2) 12px)'; + placeholder.style.background = stripePattern; + placeholder.style.backgroundImage = stripePattern; + placeholder.style.border = '1px dashed rgba(15, 23, 42, 0.3)'; + return placeholder; +}; + +export const renderDrawingContent = ({ + doc, + block, + geometry, + context, + clipContainer, + buildImageHyperlinkAnchor, +}: RenderDrawingContentParams): HTMLElement => { + const renderer = new DrawingContentRenderer(doc, buildImageHyperlinkAnchor); + return renderer.render(block, geometry, context, clipContainer); +}; + +class DrawingContentRenderer { + constructor( + private readonly doc: Document, + private readonly buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor, + ) {} + + render( + block: DrawingBlock, + geometry?: DrawingGeometry, + context?: FragmentRenderContext, + clipContainer?: HTMLElement, + ): HTMLElement { + if (block.drawingKind === 'image') { + return createDrawingImageElement(this.doc, block, this.buildImageHyperlinkAnchor, clipContainer); + } + if (block.drawingKind === 'vectorShape') { + return this.createVectorShapeElement(block, geometry ?? block.geometry, false, 1, 1, context); + } + if (block.drawingKind === 'shapeGroup') { + return this.createShapeGroupElement(block, context); + } + if (block.drawingKind === 'chart') { + return this.createChartElement(block); + } + return createDrawingPlaceholder(this.doc); + } + + private createVectorShapeElement( + block: VectorShapeDrawingWithEffects, + geometry?: DrawingGeometry, + applyTransforms = false, + groupScaleX = 1, + groupScaleY = 1, + context?: FragmentRenderContext, + ): HTMLElement { + const container = this.doc.createElement('div'); + container.classList.add('superdoc-vector-shape'); + container.style.width = '100%'; + container.style.height = '100%'; + container.style.position = 'relative'; + container.style.overflow = 'hidden'; + + const { offsetX, offsetY, innerWidth, innerHeight } = this.getEffectExtentMetrics(block, geometry); + const contentContainer = this.doc.createElement('div'); + contentContainer.style.position = 'absolute'; + contentContainer.style.left = `${offsetX}px`; + contentContainer.style.top = `${offsetY}px`; + contentContainer.style.width = `${innerWidth}px`; + contentContainer.style.height = `${innerHeight}px`; + if (applyTransforms && geometry) { + this.applyVectorShapeTransforms(contentContainer, geometry); + } + + 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); + if (svgElement) { + svgElement.setAttribute('width', '100%'); + svgElement.setAttribute('height', '100%'); + svgElement.style.display = 'block'; + + if (block.fillColor && typeof block.fillColor === 'object') { + if ('type' in block.fillColor && block.fillColor.type === 'gradient') { + applyGradientToSVG(svgElement, block.fillColor as GradientFill); + } else if ('type' in block.fillColor && block.fillColor.type === 'solidWithAlpha') { + applyAlphaToSVG(svgElement, block.fillColor as SolidFillWithAlpha); + } + } + + this.applyLineEnds(svgElement, block); + contentContainer.appendChild(svgElement); + + if (this.hasShapeTextContent(block.textContent)) { + const textElement = this.createShapeTextElement( + block, + innerWidth, + innerHeight, + groupScaleX, + groupScaleY, + context, + ); + contentContainer.appendChild(textElement); + } + + container.appendChild(contentContainer); + return container; + } + } + + this.applyFallbackShapeStyle(contentContainer, block); + + if (this.hasShapeTextContent(block.textContent)) { + const textElement = this.createShapeTextElement( + block, + innerWidth, + innerHeight, + groupScaleX, + groupScaleY, + context, + ); + contentContainer.appendChild(textElement); + } + + container.appendChild(contentContainer); + return container; + } + + private applyFallbackShapeStyle(container: HTMLElement, block: VectorShapeDrawing): void { + if (block.fillColor === null) { + container.style.background = 'none'; + } else if (typeof block.fillColor === 'string') { + container.style.background = block.fillColor; + } else if (typeof block.fillColor === 'object' && 'type' in block.fillColor) { + if (block.fillColor.type === 'solidWithAlpha') { + const alpha = (block.fillColor as SolidFillWithAlpha).alpha; + const color = (block.fillColor as SolidFillWithAlpha).color; + container.style.background = color; + container.style.opacity = alpha.toString(); + } else if (block.fillColor.type === 'gradient') { + container.style.background = 'rgba(15, 23, 42, 0.1)'; + } + } else { + container.style.background = 'rgba(15, 23, 42, 0.1)'; + } + + if (block.strokeColor === null) { + container.style.border = 'none'; + } else if (typeof block.strokeColor === 'string') { + const strokeWidth = block.strokeWidth ?? 1; + container.style.border = `${strokeWidth}px solid ${block.strokeColor}`; + } else { + container.style.border = '1px solid rgba(15, 23, 42, 0.3)'; + } + } + + private hasShapeTextContent(textContent?: ShapeTextContent): textContent is ShapeTextContent { + return Array.isArray(textContent?.parts) && textContent.parts.length > 0; + } + + private createShapeTextElement( + block: VectorShapeDrawing, + width: number, + height: number, + groupScaleX = 1, + groupScaleY = 1, + context?: FragmentRenderContext, + ): Element { + const textContent = block.textContent; + if (!this.hasShapeTextContent(textContent)) { + return this.doc.createElement('div'); + } + + if (this.shouldUseWordArtTextRenderer(block)) { + return this.createWordArtTextElement( + textContent, + block.textAlign ?? 'center', + block.textInsets, + width, + height, + context, + ); + } + + return this.createFallbackTextElement( + textContent, + block.textAlign ?? 'center', + block.textVerticalAlign, + block.textInsets, + groupScaleX, + groupScaleY, + context, + ); + } + + private shouldUseWordArtTextRenderer(block: VectorShapeDrawing): boolean { + return block.attrs?.isWordArt === true && this.hasShapeTextContent(block.textContent); + } + + private createWordArtTextElement( + textContent: ShapeTextContent, + textAlign: string, + textInsets: { top: number; right: number; bottom: number; left: number } | undefined, + width: number, + height: number, + context?: FragmentRenderContext, + ): SVGSVGElement { + const svg = this.doc.createElementNS(SVG_NS, 'svg'); + svg.classList.add('superdoc-wordart-text'); + svg.setAttribute('xmlns', SVG_NS); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.style.position = 'absolute'; + svg.style.left = '0'; + svg.style.top = '0'; + svg.style.width = '100%'; + svg.style.height = '100%'; + svg.style.overflow = 'visible'; + svg.style.pointerEvents = 'none'; + + const insets = textInsets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + const availableWidth = Math.max(1, width - insets.left - insets.right); + const availableHeight = Math.max(1, height - insets.top - insets.bottom); + const lines = this.buildWordArtLines(textContent, context); + const lineCount = Math.max(lines.length, 1); + const lineHeight = availableHeight / lineCount; + const fontSize = Math.max(1, lineHeight * WORDART_LINE_FILL_RATIO); + const textAnchor = this.getWordArtTextAnchor(textAlign); + const textX = this.getWordArtTextX(textAlign, insets.left, availableWidth); + + lines.forEach((parts, lineIndex) => { + if (parts.length === 0) { + return; + } + + const textEl = this.doc.createElementNS(SVG_NS, 'text'); + textEl.setAttribute('xml:space', 'preserve'); + textEl.setAttribute('x', String(textX)); + textEl.setAttribute('y', String(insets.top + lineHeight * (lineIndex + 0.5))); + textEl.setAttribute('text-anchor', textAnchor); + textEl.setAttribute('dominant-baseline', 'middle'); + textEl.setAttribute('font-size', String(fontSize)); + textEl.setAttribute('textLength', String(availableWidth)); + textEl.setAttribute('lengthAdjust', 'spacingAndGlyphs'); + + parts.forEach((part) => { + const tspan = this.doc.createElementNS(SVG_NS, 'tspan'); + tspan.setAttribute('xml:space', 'preserve'); + tspan.textContent = part.text; + this.applyWordArtTextFormatting(tspan, part.formatting); + textEl.appendChild(tspan); + }); + + svg.appendChild(textEl); + }); + + return svg; + } + + private buildWordArtLines( + textContent: ShapeTextContent, + context?: FragmentRenderContext, + ): Array> { + const lines: Array> = [[]]; + + textContent.parts.forEach((part) => { + if (part.isLineBreak) { + lines.push([]); + return; + } + + const resolvedText = this.resolveShapeTextPartText(part, context); + if (!resolvedText) { + return; + } + + lines[lines.length - 1].push({ + text: resolvedText, + formatting: part.formatting, + }); + }); + + const nonEmptyLines = lines.filter((line) => line.length > 0); + return nonEmptyLines.length > 0 ? nonEmptyLines : [[]]; + } + + private resolveShapeTextPartText(part: ShapeTextContent['parts'][number], context?: FragmentRenderContext): string { + if (part.fieldType === 'PAGE') { + return context?.pageNumberText ?? String(context?.pageNumber ?? 1); + } + if (part.fieldType === 'NUMPAGES') { + return String(context?.totalPages ?? 1); + } + return part.text; + } + + private getWordArtTextAnchor(textAlign: string): 'start' | 'middle' | 'end' { + if (textAlign === 'right' || textAlign === 'r') { + return 'end'; + } + if (textAlign === 'center') { + return 'middle'; + } + return 'start'; + } + + private getWordArtTextX(textAlign: string, leftInset: number, availableWidth: number): number { + if (textAlign === 'right' || textAlign === 'r') { + return leftInset + availableWidth; + } + if (textAlign === 'center') { + return leftInset + availableWidth / 2; + } + return leftInset; + } + + private applyWordArtTextFormatting( + element: SVGTextElement | SVGTSpanElement, + formatting?: ShapeTextContent['parts'][number]['formatting'], + ): void { + if (!formatting) { + return; + } + if (formatting.bold) { + element.setAttribute('font-weight', 'bold'); + } + if (formatting.italic) { + element.setAttribute('font-style', 'italic'); + } + if (formatting.fontFamily) { + element.setAttribute('font-family', formatting.fontFamily); + } + if (formatting.color) { + const validatedColor = validateHexColor(formatting.color); + if (validatedColor) { + element.setAttribute('fill', validatedColor); + } + } + if (formatting.letterSpacing != null) { + element.setAttribute('letter-spacing', String(formatting.letterSpacing)); + } + } + + private createFallbackTextElement( + textContent: ShapeTextContent, + textAlign: string, + textVerticalAlign?: 'top' | 'center' | 'bottom', + textInsets?: { top: number; right: number; bottom: number; left: number }, + groupScaleX = 1, + groupScaleY = 1, + context?: FragmentRenderContext, + ): HTMLElement { + const textDiv = this.doc.createElement('div'); + textDiv.style.position = 'absolute'; + textDiv.style.top = '0'; + textDiv.style.left = '0'; + textDiv.style.width = '100%'; + textDiv.style.height = '100%'; + textDiv.style.display = 'flex'; + textDiv.style.flexDirection = 'column'; + + const verticalAlign = textVerticalAlign ?? 'top'; + if (verticalAlign === 'top') { + textDiv.style.justifyContent = 'flex-start'; + } else if (verticalAlign === 'bottom') { + textDiv.style.justifyContent = 'flex-end'; + } else { + textDiv.style.justifyContent = 'center'; + } + + if (textInsets) { + textDiv.style.padding = `${textInsets.top}px ${textInsets.right}px ${textInsets.bottom}px ${textInsets.left}px`; + } else { + textDiv.style.padding = '10px'; + } + + textDiv.style.boxSizing = 'border-box'; + textDiv.style.wordWrap = 'break-word'; + textDiv.style.overflowWrap = 'break-word'; + textDiv.style.overflow = 'hidden'; + textDiv.style.minWidth = '0'; + textDiv.style.fontSize = '12px'; + textDiv.style.lineHeight = '1.2'; + + if (textAlign === 'center') { + textDiv.style.textAlign = 'center'; + } else if (textAlign === 'right' || textAlign === 'r') { + textDiv.style.textAlign = 'right'; + } else { + textDiv.style.textAlign = 'left'; + } + + let currentParagraph = this.doc.createElement('div'); + currentParagraph.style.width = '100%'; + currentParagraph.style.minWidth = '0'; + currentParagraph.style.whiteSpace = 'normal'; + + textContent.parts.forEach((part) => { + if (part.isLineBreak) { + textDiv.appendChild(currentParagraph); + currentParagraph = this.doc.createElement('div'); + currentParagraph.style.width = '100%'; + currentParagraph.style.minWidth = '0'; + currentParagraph.style.whiteSpace = 'normal'; + if (part.isEmptyParagraph) { + currentParagraph.style.minHeight = '1em'; + } + } else if (part.kind === 'image' && part.src) { + currentParagraph.appendChild(createShapeTextImageElement(this.doc, part)); + } else { + const span = this.doc.createElement('span'); + span.textContent = this.resolveShapeTextPartText(part, context); + if (part.formatting) { + if (part.formatting.bold) { + span.style.fontWeight = 'bold'; + } + if (part.formatting.italic) { + span.style.fontStyle = 'italic'; + } + if (part.formatting.fontFamily) { + span.style.fontFamily = part.formatting.fontFamily; + } + if (part.formatting.color) { + const validatedColor = validateHexColor(part.formatting.color); + if (validatedColor) { + span.style.color = validatedColor; + } + } + if (part.formatting.fontSize) { + span.style.fontSize = `${part.formatting.fontSize}px`; + } + if (part.formatting.letterSpacing != null) { + span.style.letterSpacing = `${part.formatting.letterSpacing}px`; + } + } + currentParagraph.appendChild(span); + } + }); + + textDiv.appendChild(currentParagraph); + + return textDiv; + } + + private tryCreatePresetSvg( + block: VectorShapeDrawing, + widthOverride?: number, + heightOverride?: number, + ): string | null { + try { + let fillColor: string | undefined; + if (block.fillColor === null) { + fillColor = 'none'; + } else if (typeof block.fillColor === 'string') { + fillColor = block.fillColor; + } + const strokeColor = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : undefined; + + if (block.shapeKind === 'line' || block.shapeKind === 'straightConnector1') { + const width = widthOverride ?? block.geometry.width; + const height = heightOverride ?? block.geometry.height; + const stroke = strokeColor ?? '#000000'; + const strokeWidth = block.strokeWidth ?? 1; + + return ` + +`; + } + + return getPresetShapeSvg({ + preset: block.shapeKind ?? '', + styleOverrides: () => ({ + fill: fillColor, + stroke: strokeColor, + strokeWidth: block.strokeWidth ?? undefined, + }), + width: widthOverride ?? block.geometry.width, + height: heightOverride ?? block.geometry.height, + }); + } catch (error) { + console.warn(`[DomPainter] Unable to render preset shape "${block.shapeKind}":`, error); + return null; + } + } + + 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 = '#000000'; + } + const strokeColor = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : 'none'; + const strokeWidth = block.strokeColor === null ? 0 : (block.strokeWidth ?? 0); + + const firstPath = custGeom.paths[0]; + const viewW = firstPath.w || width; + const viewH = firstPath.h || height; + + if (viewW === 0 || viewH === 0) return null; + + 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) => { + 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) { + return null; + } + const parser = new DOMParserCtor(); + const parsed = parser.parseFromString(markup, 'image/svg+xml'); + if (!parsed || parsed.getElementsByTagName('parsererror').length > 0) { + return null; + } + const svgElement = parsed.documentElement as unknown as SVGElement | null; + if (!svgElement) return null; + this.stripUnsafeSvgContent(svgElement); + const imported = this.doc.importNode(svgElement, true); + return imported ? (imported as unknown as SVGElement) : null; + } + + private stripUnsafeSvgContent(element: Element): void { + element.querySelectorAll('script').forEach((script) => script.remove()); + const sanitize = (node: Element) => { + Array.from(node.attributes).forEach((attr) => { + if (attr.name.toLowerCase().startsWith('on')) { + node.removeAttribute(attr.name); + } + }); + Array.from(node.children).forEach((child) => { + sanitize(child as Element); + }); + }; + sanitize(element); + } + + private getEffectExtentMetrics( + block: VectorShapeDrawingWithEffects, + geometry?: DrawingGeometry, + ): { + offsetX: number; + offsetY: number; + innerWidth: number; + innerHeight: number; + } { + const left = block.effectExtent?.left ?? 0; + const top = block.effectExtent?.top ?? 0; + const right = block.effectExtent?.right ?? 0; + const bottom = block.effectExtent?.bottom ?? 0; + const sourceGeometry = geometry ?? block.geometry; + const width = sourceGeometry.width ?? 0; + const height = sourceGeometry.height ?? 0; + const innerWidth = Math.max(0, width - left - right); + const innerHeight = Math.max(0, height - top - bottom); + return { offsetX: left, offsetY: top, innerWidth, innerHeight }; + } + + private applyLineEnds(svgElement: SVGElement, block: VectorShapeDrawingWithEffects): void { + const lineEnds = block.lineEnds; + if (!lineEnds) return; + if (block.strokeColor === null) return; + const strokeColor = typeof block.strokeColor === 'string' ? block.strokeColor : '#000000'; + const strokeWidth = block.strokeWidth ?? 1; + if (strokeWidth <= 0) return; + + const target = this.findLineEndTarget(svgElement); + if (!target) return; + + const defs = this.ensureSvgDefs(svgElement); + const baseId = this.sanitizeSvgId(`sd-line-${block.id}`); + + if (lineEnds.tail) { + const id = `${baseId}-tail`; + this.appendLineEndMarker(defs, id, lineEnds.tail, strokeColor, true, block.effectExtent ?? undefined); + target.setAttribute('marker-start', `url(#${id})`); + } + + if (lineEnds.head) { + const id = `${baseId}-head`; + this.appendLineEndMarker(defs, id, lineEnds.head, strokeColor, false, block.effectExtent ?? undefined); + target.setAttribute('marker-end', `url(#${id})`); + } + } + + private findLineEndTarget(svgElement: SVGElement): SVGElement | null { + const line = svgElement.querySelector('line'); + if (line) return line as SVGElement; + const path = svgElement.querySelector('path'); + if (path) return path as SVGElement; + const polyline = svgElement.querySelector('polyline'); + return polyline as SVGElement | null; + } + + private ensureSvgDefs(svgElement: SVGElement): SVGDefsElement { + const existing = svgElement.querySelector('defs'); + if (existing) return existing as SVGDefsElement; + const defs = this.doc.createElementNS('http://www.w3.org/2000/svg', 'defs'); + svgElement.insertBefore(defs, svgElement.firstChild); + return defs; + } + + private appendLineEndMarker( + defs: SVGDefsElement, + id: string, + lineEnd: LineEnd, + strokeColor: string, + isStart: boolean, + effectExtent?: EffectExtent, + ): void { + if (defs.querySelector(`#${id}`)) return; + + const marker = this.doc.createElementNS('http://www.w3.org/2000/svg', 'marker'); + marker.setAttribute('id', id); + marker.setAttribute('viewBox', '0 0 10 10'); + marker.setAttribute('orient', 'auto'); + + const sizeScale = (value?: string): number => { + if (value === 'sm') return 0.75; + if (value === 'lg') return 1.25; + return 1; + }; + const effectMax = effectExtent + ? Math.max(effectExtent.left ?? 0, effectExtent.right ?? 0, effectExtent.top ?? 0, effectExtent.bottom ?? 0) + : 0; + const useEffectExtent = Number.isFinite(effectMax) && effectMax > 0; + const markerWidth = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.length); + const markerHeight = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.width); + marker.setAttribute('markerUnits', useEffectExtent ? 'userSpaceOnUse' : 'strokeWidth'); + marker.setAttribute('markerWidth', markerWidth.toString()); + marker.setAttribute('markerHeight', markerHeight.toString()); + marker.setAttribute('refX', isStart ? '0' : '10'); + marker.setAttribute('refY', '5'); + + const shape = this.createLineEndShape(lineEnd.type ?? 'triangle', strokeColor, isStart); + marker.appendChild(shape); + defs.appendChild(marker); + } + + private createLineEndShape(type: string, strokeColor: string, isStart: boolean): SVGElement { + const normalized = type.toLowerCase(); + if (normalized === 'diamond') { + const path = this.doc.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M 0 5 L 5 0 L 10 5 L 5 10 Z'); + path.setAttribute('fill', strokeColor); + path.setAttribute('stroke', 'none'); + return path; + } + if (normalized === 'oval') { + const circle = this.doc.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', '5'); + circle.setAttribute('cy', '5'); + circle.setAttribute('r', '5'); + circle.setAttribute('fill', strokeColor); + circle.setAttribute('stroke', 'none'); + return circle; + } + + const path = this.doc.createElementNS('http://www.w3.org/2000/svg', 'path'); + const d = isStart ? 'M 10 0 L 0 5 L 10 10 Z' : 'M 0 0 L 10 5 L 0 10 Z'; + path.setAttribute('d', d); + path.setAttribute('fill', strokeColor); + path.setAttribute('stroke', 'none'); + return path; + } + + private sanitizeSvgId(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, ''); + } + + private applyVectorShapeTransforms(target: HTMLElement | SVGElement, geometry: DrawingGeometry): void { + const transforms: string[] = []; + if (geometry.rotation) { + transforms.push(`rotate(${geometry.rotation}deg)`); + } + if (geometry.flipH) { + transforms.push('scaleX(-1)'); + } + if (geometry.flipV) { + transforms.push('scaleY(-1)'); + } + if (transforms.length > 0) { + target.style.transformOrigin = 'center'; + target.style.transform = transforms.join(' '); + } else { + target.style.removeProperty('transform'); + target.style.removeProperty('transform-origin'); + } + } + + private createShapeGroupElement(block: ShapeGroupDrawing, context?: FragmentRenderContext): HTMLElement { + const groupEl = this.doc.createElement('div'); + groupEl.classList.add('superdoc-shape-group'); + groupEl.style.position = 'relative'; + groupEl.style.width = '100%'; + groupEl.style.height = '100%'; + + const groupTransform = block.groupTransform; + let contentContainer: HTMLElement = groupEl; + + const visibleWidth = groupTransform?.width ?? block.geometry.width ?? 0; + const visibleHeight = groupTransform?.height ?? block.geometry.height ?? 0; + + if (groupTransform) { + const inner = this.doc.createElement('div'); + inner.style.position = 'absolute'; + inner.style.left = '0'; + inner.style.top = '0'; + inner.style.width = `${Math.max(1, visibleWidth)}px`; + inner.style.height = `${Math.max(1, visibleHeight)}px`; + groupEl.appendChild(inner); + contentContainer = inner; + } + + block.shapes.forEach((child) => { + const childContent = this.createGroupChildContent(child, 1, 1, context); + if (!childContent) return; + const attrs = (child as ShapeGroupChild).attrs ?? {}; + const wrapper = this.doc.createElement('div'); + wrapper.classList.add('superdoc-shape-group__child'); + wrapper.style.position = 'absolute'; + + 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) { + transforms.push(`rotate(${attrs.rotation}deg)`); + } + if (attrs.flipH) { + transforms.push('scaleX(-1)'); + } + if (attrs.flipV) { + transforms.push('scaleY(-1)'); + } + if (transforms.length > 0) { + wrapper.style.transform = transforms.join(' '); + } + childContent.style.width = '100%'; + childContent.style.height = '100%'; + wrapper.appendChild(childContent); + contentContainer.appendChild(wrapper); + }); + + return groupEl; + } + + private createGroupChildContent( + child: ShapeGroupChild, + groupScaleX: number = 1, + groupScaleY: number = 1, + context?: FragmentRenderContext, + ): HTMLElement | null { + if (child.shapeType === 'vectorShape' && 'fillColor' in child.attrs) { + const attrs = child.attrs as PositionedDrawingGeometry & + VectorShapeStyle & { + kind?: string; + customGeometry?: CustomGeometryData; + shapeId?: string; + shapeName?: string; + textContent?: ShapeTextContent; + textAlign?: string; + lineEnds?: LineEnds; + }; + const childGeometry = { + width: attrs.width ?? 0, + height: attrs.height ?? 0, + rotation: attrs.rotation ?? 0, + flipH: attrs.flipH ?? false, + flipV: attrs.flipV ?? false, + }; + const vectorChild: VectorShapeDrawingWithEffects = { + drawingKind: 'vectorShape', + kind: 'drawing', + id: `${attrs.shapeId ?? child.shapeType}`, + geometry: childGeometry, + padding: undefined, + margin: undefined, + anchor: undefined, + wrap: undefined, + attrs: child.attrs, + drawingContentId: undefined, + drawingContent: undefined, + shapeKind: attrs.kind, + customGeometry: attrs.customGeometry, + fillColor: attrs.fillColor, + strokeColor: attrs.strokeColor, + strokeWidth: attrs.strokeWidth, + lineEnds: attrs.lineEnds, + textContent: attrs.textContent, + textAlign: attrs.textAlign, + textVerticalAlign: attrs.textVerticalAlign, + textInsets: attrs.textInsets, + }; + return this.createVectorShapeElement(vectorChild, childGeometry, false, groupScaleX, groupScaleY, context); + } + if (child.shapeType === 'image' && 'src' in child.attrs) { + return createShapeGroupImageElement(this.doc, child); + } + return createDrawingPlaceholder(this.doc); + } + + private createChartElement(block: ChartDrawing): HTMLElement { + return renderChartToElement(this.doc, block.chartData, block.geometry); + } +} diff --git a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts new file mode 100644 index 0000000000..e641439c85 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts @@ -0,0 +1,68 @@ +import type { DrawingBlock, SdtMetadata } from '@superdoc/contracts'; +import { createDrawingPlaceholder } from './renderDrawingContent.js'; + +export type RenderTableDrawingFrameParams = { + doc: Document; + block: DrawingBlock; + width: number; + height: number; + position: 'relative' | 'absolute'; + left?: number; + top?: number; + zIndex?: number; + flexShrink?: string; + renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; + applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; +}; + +export const renderTableDrawingFrame = ({ + doc, + block, + width, + height, + position, + left, + top, + zIndex, + flexShrink, + renderDrawingContent, + applySdtDataset, +}: RenderTableDrawingFrameParams): HTMLElement => { + const drawingWrapper = doc.createElement('div'); + drawingWrapper.style.position = position; + if (left != null) { + drawingWrapper.style.left = `${left}px`; + } + if (top != null) { + drawingWrapper.style.top = `${top}px`; + } + drawingWrapper.style.width = `${width}px`; + drawingWrapper.style.height = `${height}px`; + if (flexShrink != null) { + drawingWrapper.style.flexShrink = flexShrink; + } + drawingWrapper.style.maxWidth = '100%'; + drawingWrapper.style.boxSizing = 'border-box'; + if (zIndex != null) { + drawingWrapper.style.zIndex = String(zIndex); + } + applySdtDataset(drawingWrapper, block.attrs?.sdt as SdtMetadata | undefined); + + const drawingInner = doc.createElement('div'); + drawingInner.classList.add('superdoc-table-drawing'); + drawingInner.style.width = '100%'; + drawingInner.style.height = '100%'; + drawingInner.style.display = 'flex'; + drawingInner.style.alignItems = 'center'; + drawingInner.style.justifyContent = 'center'; + drawingInner.style.overflow = 'hidden'; + + const drawingContent = + renderDrawingContent?.(block, { clipContainer: drawingInner }) ?? createDrawingPlaceholder(doc); + drawingContent.style.width = '100%'; + drawingContent.style.height = '100%'; + drawingInner.appendChild(drawingContent); + + drawingWrapper.appendChild(drawingInner); + return drawingWrapper; +}; diff --git a/packages/layout-engine/painters/dom/src/images/drawing-image.ts b/packages/layout-engine/painters/dom/src/images/drawing-image.ts index 70f72c7429..a05187662a 100644 --- a/packages/layout-engine/painters/dom/src/images/drawing-image.ts +++ b/packages/layout-engine/painters/dom/src/images/drawing-image.ts @@ -13,12 +13,14 @@ export const createDrawingImageElement = ( doc: Document, block: DrawingBlock, buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor, + clipContainer?: HTMLElement, ): HTMLElement => { const drawing = block as ImageDrawing; return createBlockImageContent({ doc, block: drawing, className: 'superdoc-drawing-image', + clipContainer, imageDisplay: 'block', buildImageHyperlinkAnchor, }); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index d0aeeba23f..5fbdd433b1 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1,13 +1,9 @@ import type { - ChartDrawing, ColumnLayout, - CustomGeometryData, DrawingBlock, DrawingFragment, - DrawingGeometry, FlowMode, Fragment, - GradientFill, ImageFragment, ImageHyperlink, Line, @@ -15,19 +11,12 @@ import type { PageMargins, ParaFragment, ParagraphBlock, - PositionedDrawingGeometry, Run, - ShapeGroupChild, - ShapeGroupDrawing, - ShapeTextContent, - SolidFillWithAlpha, SourceAnchor, TableBlock, TableFragment, TableMeasure, TextRun, - VectorShapeDrawing, - VectorShapeStyle, ResolvedLayout, ResolvedFragmentItem, ResolvedPage, @@ -37,9 +26,7 @@ import type { ResolvedDrawingItem, } from '@superdoc/contracts'; import { expandRunsForInlineNewlines, getCellSpacingPx, normalizeColumnLayout } from '@superdoc/contracts'; -import { getPresetShapeSvg } from '@superdoc/preset-geometry'; import { DOM_CLASS_NAMES } from './constants.js'; -import { createChartElement as renderChartToElement } from './chart-renderer.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; import { CLASS_NAMES, @@ -58,7 +45,6 @@ import { spreadStyles, type PageStyles, } from './styles.js'; -import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; import { computeSdtBoundaries } from './sdt/boundaries.js'; import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js'; @@ -79,44 +65,17 @@ import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; import { renderParagraphFragment as renderParagraphFragmentElement } from './paragraph/renderParagraphFragment.js'; import { renderLine as renderRunLine } from './runs/render-line.js'; import type { RunRenderContext } from './runs/types.js'; -import { - createDrawingImageElement, - createShapeGroupImageElement, - createShapeTextImageElement, -} from './images/drawing-image.js'; import { renderImageFragment as renderImageFragmentElement } from './images/image-fragment.js'; import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; import { applyStyles } from './utils/apply-styles.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; +import { renderDrawingContent as renderSharedDrawingContent } from './drawings/renderDrawingContent.js'; export type { PaintSnapshotStructuredContentBlockEntity, PaintSnapshotStructuredContentInlineEntity, } from './sdt/snapshot.js'; -type LineEnd = { - type?: string; - width?: string; - length?: string; -}; - -type LineEnds = { - head?: LineEnd; - tail?: LineEnd; -}; - -type EffectExtent = { - left: number; - top: number; - right: number; - bottom: number; -}; - -type VectorShapeDrawingWithEffects = VectorShapeDrawing & { - lineEnds?: LineEnds; - effectExtent?: EffectExtent; -}; - /** * Layout mode for document rendering. * @typedef {('vertical'|'horizontal'|'book')} LayoutMode @@ -663,8 +622,6 @@ function collectLineTabsForSnapshot(lineEl: HTMLElement): PaintSnapshotTabStyle[ const DEFAULT_PAGE_HEIGHT_PX = 1056; /** Default gap used when virtualization is enabled (kept in sync with PresentationEditor layout defaults). */ const DEFAULT_VIRTUALIZED_PAGE_GAP = 72; -const SVG_NS = 'http://www.w3.org/2000/svg'; -const WORDART_LINE_FILL_RATIO = 0.9; // Comment highlight color tokens moved to CommentHighlightDecorator (super-editor). /** @@ -2631,913 +2588,13 @@ export class DomPainter { if (!this.doc) { throw new Error('DomPainter: document is not available'); } - if (block.drawingKind === 'image') { - return createDrawingImageElement(this.doc, block, this.buildImageHyperlinkAnchor.bind(this)); - } - if (block.drawingKind === 'vectorShape') { - return this.createVectorShapeElement(block, fragment.geometry, false, 1, 1, context); - } - if (block.drawingKind === 'shapeGroup') { - return this.createShapeGroupElement(block, context); - } - if (block.drawingKind === 'chart') { - return this.createChartElement(block); - } - return this.createDrawingPlaceholder(); - } - - private createVectorShapeElement( - block: VectorShapeDrawingWithEffects, - geometry?: DrawingGeometry, - applyTransforms = false, - groupScaleX = 1, - groupScaleY = 1, - context?: FragmentRenderContext, - ): HTMLElement { - const container = this.doc!.createElement('div'); - container.classList.add('superdoc-vector-shape'); - container.style.width = '100%'; - container.style.height = '100%'; - container.style.position = 'relative'; - container.style.overflow = 'hidden'; - - const { offsetX, offsetY, innerWidth, innerHeight } = this.getEffectExtentMetrics(block, geometry); - const contentContainer = this.doc!.createElement('div'); - contentContainer.style.position = 'absolute'; - contentContainer.style.left = `${offsetX}px`; - contentContainer.style.top = `${offsetY}px`; - contentContainer.style.width = `${innerWidth}px`; - contentContainer.style.height = `${innerHeight}px`; - if (applyTransforms && geometry) { - this.applyVectorShapeTransforms(contentContainer, geometry); - } - - // 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); - if (svgElement) { - svgElement.setAttribute('width', '100%'); - svgElement.setAttribute('height', '100%'); - svgElement.style.display = 'block'; - - // Apply gradient fill if present - if (block.fillColor && typeof block.fillColor === 'object') { - if ('type' in block.fillColor && block.fillColor.type === 'gradient') { - applyGradientToSVG(svgElement, block.fillColor as GradientFill); - } else if ('type' in block.fillColor && block.fillColor.type === 'solidWithAlpha') { - applyAlphaToSVG(svgElement, block.fillColor as SolidFillWithAlpha); - } - } - - this.applyLineEnds(svgElement, block); - contentContainer.appendChild(svgElement); - - if (this.hasShapeTextContent(block.textContent)) { - const textElement = this.createShapeTextElement( - block, - innerWidth, - innerHeight, - groupScaleX, - groupScaleY, - context, - ); - contentContainer.appendChild(textElement); - } - - container.appendChild(contentContainer); - return container; - } - } - - // Fallback rendering when no preset shape SVG is available - this.applyFallbackShapeStyle(contentContainer, block); - - if (this.hasShapeTextContent(block.textContent)) { - const textElement = this.createShapeTextElement( - block, - innerWidth, - innerHeight, - groupScaleX, - groupScaleY, - context, - ); - contentContainer.appendChild(textElement); - } - - container.appendChild(contentContainer); - return container; - } - - /** - * Apply fill and stroke styles to a fallback shape container - */ - private applyFallbackShapeStyle(container: HTMLElement, block: VectorShapeDrawing): void { - // Handle fill color - if (block.fillColor === null) { - container.style.background = 'none'; - } else if (typeof block.fillColor === 'string') { - container.style.background = block.fillColor; - } else if (typeof block.fillColor === 'object' && 'type' in block.fillColor) { - if (block.fillColor.type === 'solidWithAlpha') { - const alpha = (block.fillColor as SolidFillWithAlpha).alpha; - const color = (block.fillColor as SolidFillWithAlpha).color; - container.style.background = color; - container.style.opacity = alpha.toString(); - } else if (block.fillColor.type === 'gradient') { - // For CSS gradients in fallback, we'd need to convert - // For now, use a placeholder color - container.style.background = 'rgba(15, 23, 42, 0.1)'; - } - } else { - container.style.background = 'rgba(15, 23, 42, 0.1)'; - } - - // Handle stroke color - if (block.strokeColor === null) { - container.style.border = 'none'; - } else if (typeof block.strokeColor === 'string') { - const strokeWidth = block.strokeWidth ?? 1; - container.style.border = `${strokeWidth}px solid ${block.strokeColor}`; - } else { - container.style.border = '1px solid rgba(15, 23, 42, 0.3)'; - } - } - - private hasShapeTextContent(textContent?: ShapeTextContent): textContent is ShapeTextContent { - return Array.isArray(textContent?.parts) && textContent.parts.length > 0; - } - - private createShapeTextElement( - block: VectorShapeDrawing, - width: number, - height: number, - groupScaleX = 1, - groupScaleY = 1, - context?: FragmentRenderContext, - ): Element { - const textContent = block.textContent; - if (!this.hasShapeTextContent(textContent)) { - return this.doc!.createElement('div'); - } - - if (this.shouldUseWordArtTextRenderer(block)) { - return this.createWordArtTextElement( - textContent, - block.textAlign ?? 'center', - block.textInsets, - width, - height, - context, - ); - } - - return this.createFallbackTextElement( - textContent, - block.textAlign ?? 'center', - block.textVerticalAlign, - block.textInsets, - groupScaleX, - groupScaleY, + return renderSharedDrawingContent({ + doc: this.doc, + block, + geometry: fragment.geometry, context, - ); - } - - private shouldUseWordArtTextRenderer(block: VectorShapeDrawing): boolean { - return block.attrs?.isWordArt === true && this.hasShapeTextContent(block.textContent); - } - - private createWordArtTextElement( - textContent: ShapeTextContent, - textAlign: string, - textInsets: { top: number; right: number; bottom: number; left: number } | undefined, - width: number, - height: number, - context?: FragmentRenderContext, - ): SVGSVGElement { - const svg = this.doc!.createElementNS(SVG_NS, 'svg'); - svg.classList.add('superdoc-wordart-text'); - svg.setAttribute('xmlns', SVG_NS); - svg.setAttribute('viewBox', `0 0 ${width} ${height}`); - svg.setAttribute('preserveAspectRatio', 'none'); - svg.style.position = 'absolute'; - svg.style.left = '0'; - svg.style.top = '0'; - svg.style.width = '100%'; - svg.style.height = '100%'; - svg.style.overflow = 'visible'; - svg.style.pointerEvents = 'none'; - - const insets = textInsets ?? { top: 0, right: 0, bottom: 0, left: 0 }; - const availableWidth = Math.max(1, width - insets.left - insets.right); - const availableHeight = Math.max(1, height - insets.top - insets.bottom); - const lines = this.buildWordArtLines(textContent, context); - const lineCount = Math.max(lines.length, 1); - const lineHeight = availableHeight / lineCount; - const fontSize = Math.max(1, lineHeight * WORDART_LINE_FILL_RATIO); - const textAnchor = this.getWordArtTextAnchor(textAlign); - const textX = this.getWordArtTextX(textAlign, insets.left, availableWidth); - - lines.forEach((parts, lineIndex) => { - if (parts.length === 0) { - return; - } - - const textEl = this.doc!.createElementNS(SVG_NS, 'text'); - textEl.setAttribute('xml:space', 'preserve'); - textEl.setAttribute('x', String(textX)); - textEl.setAttribute('y', String(insets.top + lineHeight * (lineIndex + 0.5))); - textEl.setAttribute('text-anchor', textAnchor); - textEl.setAttribute('dominant-baseline', 'middle'); - textEl.setAttribute('font-size', String(fontSize)); - textEl.setAttribute('textLength', String(availableWidth)); - textEl.setAttribute('lengthAdjust', 'spacingAndGlyphs'); - - parts.forEach((part) => { - const tspan = this.doc!.createElementNS(SVG_NS, 'tspan'); - tspan.setAttribute('xml:space', 'preserve'); - tspan.textContent = part.text; - this.applyWordArtTextFormatting(tspan, part.formatting); - textEl.appendChild(tspan); - }); - - svg.appendChild(textEl); - }); - - return svg; - } - - private buildWordArtLines( - textContent: ShapeTextContent, - context?: FragmentRenderContext, - ): Array> { - const lines: Array> = [[]]; - - textContent.parts.forEach((part) => { - if (part.isLineBreak) { - lines.push([]); - return; - } - - const resolvedText = this.resolveShapeTextPartText(part, context); - if (!resolvedText) { - return; - } - - lines[lines.length - 1].push({ - text: resolvedText, - formatting: part.formatting, - }); - }); - - const nonEmptyLines = lines.filter((line) => line.length > 0); - return nonEmptyLines.length > 0 ? nonEmptyLines : [[]]; - } - - private resolveShapeTextPartText(part: ShapeTextContent['parts'][number], context?: FragmentRenderContext): string { - if (part.fieldType === 'PAGE') { - return context?.pageNumberText ?? String(context?.pageNumber ?? 1); - } - if (part.fieldType === 'NUMPAGES') { - return String(context?.totalPages ?? 1); - } - return part.text; - } - - private getWordArtTextAnchor(textAlign: string): 'start' | 'middle' | 'end' { - if (textAlign === 'right' || textAlign === 'r') { - return 'end'; - } - if (textAlign === 'center') { - return 'middle'; - } - return 'start'; - } - - private getWordArtTextX(textAlign: string, leftInset: number, availableWidth: number): number { - if (textAlign === 'right' || textAlign === 'r') { - return leftInset + availableWidth; - } - if (textAlign === 'center') { - return leftInset + availableWidth / 2; - } - return leftInset; - } - - private applyWordArtTextFormatting( - element: SVGTextElement | SVGTSpanElement, - formatting?: ShapeTextContent['parts'][number]['formatting'], - ): void { - if (!formatting) { - return; - } - if (formatting.bold) { - element.setAttribute('font-weight', 'bold'); - } - if (formatting.italic) { - element.setAttribute('font-style', 'italic'); - } - if (formatting.fontFamily) { - element.setAttribute('font-family', formatting.fontFamily); - } - if (formatting.color) { - const validatedColor = validateHexColor(formatting.color); - if (validatedColor) { - element.setAttribute('fill', validatedColor); - } - } - if (formatting.letterSpacing != null) { - element.setAttribute('letter-spacing', String(formatting.letterSpacing)); - } - } - - /** - * Create a fallback text element for shapes without SVG - * @param textContent - Text content with formatting - * @param textAlign - Horizontal text alignment - * @param textVerticalAlign - Vertical text alignment (top, center, bottom) - * @param textInsets - Text insets in pixels (top, right, bottom, left) - * @param groupScaleX - Scale factor applied by parent group (for counter-scaling) - * @param groupScaleY - Scale factor applied by parent group (for counter-scaling) - */ - private createFallbackTextElement( - textContent: ShapeTextContent, - textAlign: string, - textVerticalAlign?: 'top' | 'center' | 'bottom', - textInsets?: { top: number; right: number; bottom: number; left: number }, - groupScaleX = 1, - groupScaleY = 1, - context?: FragmentRenderContext, - ): HTMLElement { - const textDiv = this.doc!.createElement('div'); - textDiv.style.position = 'absolute'; - textDiv.style.top = '0'; - textDiv.style.left = '0'; - textDiv.style.width = '100%'; - textDiv.style.height = '100%'; - textDiv.style.display = 'flex'; - textDiv.style.flexDirection = 'column'; - - // Use extracted vertical alignment or default to top per OOXML spec - // In flex-direction: column, justifyContent controls vertical (main axis) - const verticalAlign = textVerticalAlign ?? 'top'; - if (verticalAlign === 'top') { - textDiv.style.justifyContent = 'flex-start'; - } else if (verticalAlign === 'bottom') { - textDiv.style.justifyContent = 'flex-end'; - } else { - textDiv.style.justifyContent = 'center'; - } - - // Use extracted text insets or default to 10px all around - if (textInsets) { - textDiv.style.padding = `${textInsets.top}px ${textInsets.right}px ${textInsets.bottom}px ${textInsets.left}px`; - } else { - textDiv.style.padding = '10px'; - } - - textDiv.style.boxSizing = 'border-box'; - textDiv.style.wordWrap = 'break-word'; - textDiv.style.overflowWrap = 'break-word'; - textDiv.style.overflow = 'hidden'; - // min-width: 0 allows flex container to shrink below content size for text wrapping - textDiv.style.minWidth = '0'; - // Set explicit base font-size to prevent CSS inheritance issues - // Individual spans will override with their own sizes from textContent.parts - textDiv.style.fontSize = '12px'; - textDiv.style.lineHeight = '1.2'; - - // Horizontal text alignment uses CSS text-align property - // Note: justifyContent is already set above for vertical alignment - if (textAlign === 'center') { - textDiv.style.textAlign = 'center'; - } else if (textAlign === 'right' || textAlign === 'r') { - textDiv.style.textAlign = 'right'; - } else { - textDiv.style.textAlign = 'left'; - } - - // Create paragraphs by splitting on line breaks - let currentParagraph = this.doc!.createElement('div'); - // Set width to 100% to enable text wrapping within the shape bounds - currentParagraph.style.width = '100%'; - // min-width: 0 prevents flex item from overflowing (flexbox default is min-width: auto) - currentParagraph.style.minWidth = '0'; - // Override inherited white-space: pre from parent fragment to allow text wrapping - currentParagraph.style.whiteSpace = 'normal'; - - textContent.parts.forEach((part) => { - if (part.isLineBreak) { - // Finish current paragraph and start a new one - textDiv.appendChild(currentParagraph); - currentParagraph = this.doc!.createElement('div'); - currentParagraph.style.width = '100%'; - currentParagraph.style.minWidth = '0'; - currentParagraph.style.whiteSpace = 'normal'; - // Empty paragraphs create extra spacing (blank line) - if (part.isEmptyParagraph) { - currentParagraph.style.minHeight = '1em'; - } - } else if (part.kind === 'image' && part.src) { - currentParagraph.appendChild(createShapeTextImageElement(this.doc!, part)); - } else { - const span = this.doc!.createElement('span'); - span.textContent = this.resolveShapeTextPartText(part, context); - if (part.formatting) { - if (part.formatting.bold) { - span.style.fontWeight = 'bold'; - } - if (part.formatting.italic) { - span.style.fontStyle = 'italic'; - } - if (part.formatting.fontFamily) { - span.style.fontFamily = part.formatting.fontFamily; - } - if (part.formatting.color) { - // Validate and normalize color format (handles both with and without # prefix) - const validatedColor = validateHexColor(part.formatting.color); - if (validatedColor) { - span.style.color = validatedColor; - } - } - if (part.formatting.fontSize) { - span.style.fontSize = `${part.formatting.fontSize}px`; - } - if (part.formatting.letterSpacing != null) { - span.style.letterSpacing = `${part.formatting.letterSpacing}px`; - } - } - currentParagraph.appendChild(span); - } - }); - - // Add the final paragraph - textDiv.appendChild(currentParagraph); - - return textDiv; - } - - private tryCreatePresetSvg( - block: VectorShapeDrawing, - widthOverride?: number, - heightOverride?: number, - ): string | null { - try { - // For preset shapes, we need to pass string colors only - // Gradients and alpha will be applied after SVG is created - // null means explicitly "no fill" (from or fillRef idx="0"), so use 'none' - // undefined means no explicit fill, so we let the preset library use its default - let fillColor: string | undefined; - if (block.fillColor === null) { - fillColor = 'none'; - } else if (typeof block.fillColor === 'string') { - fillColor = block.fillColor; - } - const strokeColor = - block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : undefined; - - // Special case: handle line-like shapes directly since getPresetShapeSvg doesn't support them well - if (block.shapeKind === 'line' || block.shapeKind === 'straightConnector1') { - const width = widthOverride ?? block.geometry.width; - const height = heightOverride ?? block.geometry.height; - const stroke = strokeColor ?? '#000000'; - const strokeWidth = block.strokeWidth ?? 1; - - return ` - -`; - } - - return getPresetShapeSvg({ - preset: block.shapeKind ?? '', - styleOverrides: () => ({ - fill: fillColor, - stroke: strokeColor, - strokeWidth: block.strokeWidth ?? undefined, - }), - width: widthOverride ?? block.geometry.width, - height: heightOverride ?? block.geometry.height, - }); - } catch (error) { - console.warn(`[DomPainter] Unable to render preset shape "${block.shapeKind}":`, error); - return null; - } - } - - /** - * 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 { - // 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'; - 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; - - // 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. - 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) { - return null; - } - const parser = new DOMParserCtor(); - const parsed = parser.parseFromString(markup, 'image/svg+xml'); - if (!parsed || parsed.getElementsByTagName('parsererror').length > 0) { - return null; - } - // documentElement might be HTMLElement or Element, use type guard via unknown - const svgElement = parsed.documentElement as unknown as SVGElement | null; - if (!svgElement) return null; - this.stripUnsafeSvgContent(svgElement); - // Safe cast: importNode preserves the element type, and we've verified it's an SVGElement - const imported = this.doc?.importNode(svgElement, true); - return imported ? (imported as unknown as SVGElement) : null; - } - - private stripUnsafeSvgContent(element: Element): void { - element.querySelectorAll('script').forEach((script) => script.remove()); - const sanitize = (node: Element) => { - Array.from(node.attributes).forEach((attr) => { - if (attr.name.toLowerCase().startsWith('on')) { - node.removeAttribute(attr.name); - } - }); - Array.from(node.children).forEach((child) => { - sanitize(child as Element); - }); - }; - sanitize(element); - } - - private getEffectExtentMetrics( - block: VectorShapeDrawingWithEffects, - geometry?: DrawingGeometry, - ): { - offsetX: number; - offsetY: number; - innerWidth: number; - innerHeight: number; - } { - const left = block.effectExtent?.left ?? 0; - const top = block.effectExtent?.top ?? 0; - const right = block.effectExtent?.right ?? 0; - const bottom = block.effectExtent?.bottom ?? 0; - const sourceGeometry = geometry ?? block.geometry; - const width = sourceGeometry.width ?? 0; - const height = sourceGeometry.height ?? 0; - const innerWidth = Math.max(0, width - left - right); - const innerHeight = Math.max(0, height - top - bottom); - return { offsetX: left, offsetY: top, innerWidth, innerHeight }; - } - - private applyLineEnds(svgElement: SVGElement, block: VectorShapeDrawingWithEffects): void { - const lineEnds = block.lineEnds; - if (!lineEnds) return; - if (block.strokeColor === null) return; - const strokeColor = typeof block.strokeColor === 'string' ? block.strokeColor : '#000000'; - const strokeWidth = block.strokeWidth ?? 1; - if (strokeWidth <= 0) return; - - const target = this.findLineEndTarget(svgElement); - if (!target) return; - - const defs = this.ensureSvgDefs(svgElement); - const baseId = this.sanitizeSvgId(`sd-line-${block.id}`); - - if (lineEnds.tail) { - const id = `${baseId}-tail`; - this.appendLineEndMarker( - defs, - id, - lineEnds.tail, - strokeColor, - strokeWidth, - true, - block.effectExtent ?? undefined, - ); - target.setAttribute('marker-start', `url(#${id})`); - } - - if (lineEnds.head) { - const id = `${baseId}-head`; - this.appendLineEndMarker( - defs, - id, - lineEnds.head, - strokeColor, - strokeWidth, - false, - block.effectExtent ?? undefined, - ); - target.setAttribute('marker-end', `url(#${id})`); - } - } - - private findLineEndTarget(svgElement: SVGElement): SVGElement | null { - const line = svgElement.querySelector('line'); - if (line) return line as SVGElement; - const path = svgElement.querySelector('path'); - if (path) return path as SVGElement; - const polyline = svgElement.querySelector('polyline'); - return polyline as SVGElement | null; - } - - private ensureSvgDefs(svgElement: SVGElement): SVGDefsElement { - const existing = svgElement.querySelector('defs'); - if (existing) return existing as SVGDefsElement; - const defs = this.doc!.createElementNS('http://www.w3.org/2000/svg', 'defs'); - svgElement.insertBefore(defs, svgElement.firstChild); - return defs; - } - - private appendLineEndMarker( - defs: SVGDefsElement, - id: string, - lineEnd: LineEnd, - strokeColor: string, - _strokeWidth: number, - isStart: boolean, - effectExtent?: EffectExtent, - ): void { - if (defs.querySelector(`#${id}`)) return; - - const marker = this.doc!.createElementNS('http://www.w3.org/2000/svg', 'marker'); - marker.setAttribute('id', id); - marker.setAttribute('viewBox', '0 0 10 10'); - marker.setAttribute('orient', 'auto'); - - const sizeScale = (value?: string): number => { - if (value === 'sm') return 0.75; - if (value === 'lg') return 1.25; - return 1; - }; - const effectMax = effectExtent - ? Math.max(effectExtent.left ?? 0, effectExtent.right ?? 0, effectExtent.top ?? 0, effectExtent.bottom ?? 0) - : 0; - const useEffectExtent = Number.isFinite(effectMax) && effectMax > 0; - const markerWidth = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.length); - const markerHeight = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.width); - marker.setAttribute('markerUnits', useEffectExtent ? 'userSpaceOnUse' : 'strokeWidth'); - marker.setAttribute('markerWidth', markerWidth.toString()); - marker.setAttribute('markerHeight', markerHeight.toString()); - marker.setAttribute('refX', isStart ? '0' : '10'); - marker.setAttribute('refY', '5'); - - const shape = this.createLineEndShape(lineEnd.type ?? 'triangle', strokeColor, isStart); - marker.appendChild(shape); - defs.appendChild(marker); - } - - private createLineEndShape(type: string, strokeColor: string, isStart: boolean): SVGElement { - const normalized = type.toLowerCase(); - if (normalized === 'diamond') { - const path = this.doc!.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M 0 5 L 5 0 L 10 5 L 5 10 Z'); - path.setAttribute('fill', strokeColor); - path.setAttribute('stroke', 'none'); - return path; - } - if (normalized === 'oval') { - const circle = this.doc!.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx', '5'); - circle.setAttribute('cy', '5'); - circle.setAttribute('r', '5'); - circle.setAttribute('fill', strokeColor); - circle.setAttribute('stroke', 'none'); - return circle; - } - - const path = this.doc!.createElementNS('http://www.w3.org/2000/svg', 'path'); - const d = isStart ? 'M 10 0 L 0 5 L 10 10 Z' : 'M 0 0 L 10 5 L 0 10 Z'; - path.setAttribute('d', d); - path.setAttribute('fill', strokeColor); - path.setAttribute('stroke', 'none'); - return path; - } - - private sanitizeSvgId(value: string): string { - return value.replace(/[^a-zA-Z0-9_-]/g, ''); - } - - private applyVectorShapeTransforms(target: HTMLElement | SVGElement, geometry: DrawingGeometry): void { - const transforms: string[] = []; - if (geometry.rotation) { - transforms.push(`rotate(${geometry.rotation}deg)`); - } - if (geometry.flipH) { - transforms.push('scaleX(-1)'); - } - if (geometry.flipV) { - transforms.push('scaleY(-1)'); - } - if (transforms.length > 0) { - target.style.transformOrigin = 'center'; - target.style.transform = transforms.join(' '); - } else { - target.style.removeProperty('transform'); - target.style.removeProperty('transform-origin'); - } - } - - private createShapeGroupElement(block: ShapeGroupDrawing, context?: FragmentRenderContext): HTMLElement { - const groupEl = this.doc!.createElement('div'); - groupEl.classList.add('superdoc-shape-group'); - groupEl.style.position = 'relative'; - groupEl.style.width = '100%'; - groupEl.style.height = '100%'; - - const groupTransform = block.groupTransform; - let contentContainer: HTMLElement = groupEl; - - const visibleWidth = groupTransform?.width ?? block.geometry.width ?? 0; - const visibleHeight = groupTransform?.height ?? block.geometry.height ?? 0; - - if (groupTransform) { - const inner = this.doc!.createElement('div'); - inner.style.position = 'absolute'; - inner.style.left = '0'; - inner.style.top = '0'; - // 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; - } - - block.shapes.forEach((child) => { - const childContent = this.createGroupChildContent(child, 1, 1, context); - if (!childContent) return; - const attrs = (child as ShapeGroupChild).attrs ?? {}; - const wrapper = this.doc!.createElement('div'); - wrapper.classList.add('superdoc-shape-group__child'); - wrapper.style.position = 'absolute'; - - // 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) { - transforms.push(`rotate(${attrs.rotation}deg)`); - } - if (attrs.flipH) { - transforms.push('scaleX(-1)'); - } - if (attrs.flipV) { - transforms.push('scaleY(-1)'); - } - if (transforms.length > 0) { - wrapper.style.transform = transforms.join(' '); - } - childContent.style.width = '100%'; - childContent.style.height = '100%'; - wrapper.appendChild(childContent); - contentContainer.appendChild(wrapper); + buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), }); - - return groupEl; - } - - private createGroupChildContent( - child: ShapeGroupChild, - groupScaleX: number = 1, - groupScaleY: number = 1, - context?: FragmentRenderContext, - ): HTMLElement | null { - // Type narrowing with explicit checks to help TypeScript distinguish union members - if (child.shapeType === 'vectorShape' && 'fillColor' in child.attrs) { - // After this check, child should be ShapeGroupVectorChild - const attrs = child.attrs as PositionedDrawingGeometry & - VectorShapeStyle & { - kind?: string; - customGeometry?: CustomGeometryData; - shapeId?: string; - shapeName?: string; - textContent?: ShapeTextContent; - textAlign?: string; - lineEnds?: LineEnds; - }; - const childGeometry = { - width: attrs.width ?? 0, - height: attrs.height ?? 0, - rotation: attrs.rotation ?? 0, - flipH: attrs.flipH ?? false, - flipV: attrs.flipV ?? false, - }; - const vectorChild: VectorShapeDrawingWithEffects = { - drawingKind: 'vectorShape', - kind: 'drawing', - id: `${attrs.shapeId ?? child.shapeType}`, - geometry: childGeometry, - padding: undefined, - margin: undefined, - anchor: undefined, - wrap: undefined, - attrs: child.attrs, - drawingContentId: undefined, - drawingContent: undefined, - shapeKind: attrs.kind, - customGeometry: attrs.customGeometry, - fillColor: attrs.fillColor, - strokeColor: attrs.strokeColor, - strokeWidth: attrs.strokeWidth, - lineEnds: attrs.lineEnds, - textContent: attrs.textContent, - textAlign: attrs.textAlign, - textVerticalAlign: attrs.textVerticalAlign, - textInsets: attrs.textInsets, - }; - // Pass geometry and scale factors to ensure text overlay has correct dimensions - return this.createVectorShapeElement(vectorChild, childGeometry, false, groupScaleX, groupScaleY, context); - } - if (child.shapeType === 'image' && 'src' in child.attrs) { - return createShapeGroupImageElement(this.doc!, child); - } - return this.createDrawingPlaceholder(); - } - - private createDrawingPlaceholder(): HTMLElement { - const placeholder = this.doc!.createElement('div'); - placeholder.classList.add('superdoc-drawing-placeholder'); - placeholder.style.width = '100%'; - placeholder.style.height = '100%'; - placeholder.style.background = - 'repeating-linear-gradient(45deg, rgba(15,23,42,0.1), rgba(15,23,42,0.1) 6px, rgba(15,23,42,0.2) 6px, rgba(15,23,42,0.2) 12px)'; - placeholder.style.border = '1px dashed rgba(15, 23, 42, 0.3)'; - return placeholder; - } - - // ============================================================================ - // Chart Rendering - // ============================================================================ - - /** - * Create an SVG chart element from a ChartDrawing block. - * Delegates to the chart-renderer module for clean separation. - */ - private createChartElement(block: ChartDrawing): HTMLElement { - return renderChartToElement(this.doc!, block.chartData, block.geometry); } private resolveTableRenderData( @@ -3614,21 +2671,18 @@ export class DomPainter { * Renders drawing content that lives inside a table cell. * Table-cell vector shapes intentionally skip outer geometry transforms. */ - const renderDrawingContentForTableCell = (block: DrawingBlock): HTMLElement => { - if (block.drawingKind === 'image') { - return createDrawingImageElement(this.doc!, block, this.buildImageHyperlinkAnchor.bind(this)); - } - if (block.drawingKind === 'shapeGroup') { - return this.createShapeGroupElement(block, context); - } - if (block.drawingKind === 'vectorShape') { - return this.createVectorShapeElement(block, block.geometry, false, 1, 1, context); - } - if (block.drawingKind === 'chart') { - return this.createChartElement(block); - } - return this.createDrawingPlaceholder(); - }; + const renderDrawingContentForTableCell = ( + block: DrawingBlock, + options?: { clipContainer?: HTMLElement }, + ): HTMLElement => + renderSharedDrawingContent({ + doc: this.doc!, + block, + geometry: 'geometry' in block ? block.geometry : undefined, + context, + clipContainer: options?.clipContainer, + buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), + }); const tableRenderData = this.resolveTableRenderData(fragment, resolvedItem); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index b50b3f65c8..080816ef53 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -3689,6 +3689,107 @@ describe('renderTableCell', () => { expect(capturedBlock.shapes.length).toBe(1); }); + it('passes the table drawing inner wrapper as the clip container', () => { + const vectorShapeBlock = { + kind: 'drawing' as const, + id: 'drawing-clip-container', + drawingKind: 'vectorShape' as const, + geometry: { width: 100, height: 100, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect' as const, + }; + + const drawingMeasure = { + kind: 'drawing' as const, + width: 100, + height: 100, + }; + + let capturedClipContainer: HTMLElement | undefined; + const mockRenderDrawingContent = ( + _block: DrawingBlock, + options?: { clipContainer?: HTMLElement }, + ): HTMLElement => { + capturedClipContainer = options?.clipContainer; + const div = doc.createElement('div'); + div.classList.add('test-drawing-element'); + return div; + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [drawingMeasure], + width: 120, + height: 120, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-drawing-clip-container', + blocks: [vectorShapeBlock], + attrs: {}, + }, + renderDrawingContent: mockRenderDrawingContent, + }); + + const drawingInner = cellElement.querySelector('.superdoc-table-drawing') as HTMLElement | null; + expect(capturedClipContainer).toBe(drawingInner); + expect(drawingInner?.style.overflow).toBe('hidden'); + }); + + it('applies drawing SDT metadata from attrs.sdt only', () => { + const sdt: SdtMetadata = { + id: 'drawing-sdt', + tag: 'Drawing SDT', + alias: 'Drawing', + }; + const vectorShapeBlock = { + kind: 'drawing' as const, + id: 'drawing-sdt-block', + drawingKind: 'vectorShape' as const, + geometry: { width: 100, height: 100, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect' as const, + attrs: { + sdt, + unrelated: 'must-not-be-treated-as-sdt', + }, + }; + + const drawingMeasure = { + kind: 'drawing' as const, + width: 100, + height: 100, + }; + + const appliedMetadata: Array = []; + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [drawingMeasure], + width: 120, + height: 120, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-drawing-sdt', + blocks: [vectorShapeBlock], + attrs: {}, + }, + renderDrawingContent: () => doc.createElement('div'), + applySdtDataset: (_el, metadata) => { + appliedMetadata.push(metadata); + }, + }); + + const drawingWrapper = cellElement.querySelector('.superdoc-table-drawing')?.parentElement as HTMLElement | null; + expect(drawingWrapper).toBeTruthy(); + expect(appliedMetadata).toContain(sdt); + expect(appliedMetadata).not.toContain(vectorShapeBlock.attrs as unknown as SdtMetadata); + }); + it('should apply width and height styles to returned element', () => { const vectorShapeBlock = { kind: 'drawing' as const, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 4fc7a4485d..8d3bd7a0d4 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -1,7 +1,6 @@ import type { CellBorders, DrawingBlock, - ImageDrawing, DrawingMeasure, Fragment, ImageBlock, @@ -33,6 +32,7 @@ import { import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; +import { renderTableDrawingFrame } from '../drawings/tableDrawingFrame.js'; type TableRowMeasure = TableMeasure['rows'][number]; type TableCellMeasure = TableRowMeasure['cells'][number]; @@ -221,7 +221,7 @@ type EmbeddedTableRenderParams = { options?: { inTableParagraph?: boolean; wrapperEl?: HTMLElement }, ) => void; /** Optional callback to render drawing content (shapes, etc.) */ - renderDrawingContent?: (block: DrawingBlock) => HTMLElement; + renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; /** Starting row index for partial rendering (inclusive, default 0) */ @@ -570,7 +570,7 @@ type TableCellRenderDependencies = { * The returned element will have width: 100% and height: 100% styles applied automatically. * If undefined, a placeholder element with diagonal stripes pattern is rendered instead. */ - renderDrawingContent?: (block: DrawingBlock) => HTMLElement; + renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; /** Rendering context */ context: FragmentRenderContext; /** Function to apply SDT metadata as data attributes */ @@ -892,57 +892,16 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen continue; } - const drawingWrapper = doc.createElement('div'); - drawingWrapper.style.position = 'relative'; - drawingWrapper.style.width = `${blockMeasure.width}px`; - drawingWrapper.style.height = `${blockMeasure.height}px`; - drawingWrapper.style.flexShrink = '0'; - drawingWrapper.style.maxWidth = '100%'; - drawingWrapper.style.boxSizing = 'border-box'; - applySdtDataset(drawingWrapper, (block as DrawingBlock).attrs as SdtMetadata | undefined); - - const drawingInner = doc.createElement('div'); - drawingInner.classList.add('superdoc-table-drawing'); - drawingInner.style.width = '100%'; - drawingInner.style.height = '100%'; - drawingInner.style.display = 'flex'; - drawingInner.style.alignItems = 'center'; - drawingInner.style.justifyContent = 'center'; - drawingInner.style.overflow = 'hidden'; - - if (block.drawingKind === 'image' && 'src' in block && block.src) { - drawingInner.appendChild( - createBlockImageContent({ - doc, - block: block as ImageDrawing, - className: 'superdoc-drawing-image', - clipContainer: drawingInner, - imageDisplay: 'block', - buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, - }), - ); - } else if (renderDrawingContent) { - // Use the callback for other drawing types (vectorShape, shapeGroup, etc.) - const drawingContent = renderDrawingContent(block as DrawingBlock); - drawingContent.style.width = '100%'; - drawingContent.style.height = '100%'; - drawingInner.appendChild(drawingContent); - } else { - // Fallback placeholder when no rendering callback is provided - const placeholder = doc.createElement('div'); - placeholder.classList.add('superdoc-drawing-placeholder'); - placeholder.style.width = '100%'; - placeholder.style.height = '100%'; - const stripePattern = - 'repeating-linear-gradient(45deg, rgba(15,23,42,0.1), rgba(15,23,42,0.1) 6px, rgba(15,23,42,0.2) 6px, rgba(15,23,42,0.2) 12px)'; - // Set both shorthand and longhand to handle partial CSS property support in test DOMs. - placeholder.style.background = stripePattern; - placeholder.style.backgroundImage = stripePattern; - placeholder.style.border = '1px dashed rgba(15, 23, 42, 0.3)'; - drawingInner.appendChild(placeholder); - } - - drawingWrapper.appendChild(drawingInner); + const drawingWrapper = renderTableDrawingFrame({ + doc, + block, + width: blockMeasure.width, + height: blockMeasure.height, + position: 'relative', + flexShrink: '0', + renderDrawingContent, + applySdtDataset, + }); content.appendChild(drawingWrapper); flowCursorY += blockMeasure.height; continue; @@ -1104,57 +1063,18 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen ); content.appendChild(imageWrapper); } else { - const drawingWrapper = doc.createElement('div'); - drawingWrapper.style.position = 'absolute'; - drawingWrapper.style.left = `${left}px`; - drawingWrapper.style.top = `${top}px`; - drawingWrapper.style.width = `${objectWidth}px`; - drawingWrapper.style.height = `${objectHeight}px`; - drawingWrapper.style.maxWidth = '100%'; - drawingWrapper.style.boxSizing = 'border-box'; - drawingWrapper.style.zIndex = String(zIndex); - applySdtDataset(drawingWrapper, anchoredBlock.attrs as SdtMetadata | undefined); - - const drawingInner = doc.createElement('div'); - drawingInner.classList.add('superdoc-table-drawing'); - drawingInner.style.width = '100%'; - drawingInner.style.height = '100%'; - drawingInner.style.display = 'flex'; - drawingInner.style.alignItems = 'center'; - drawingInner.style.justifyContent = 'center'; - drawingInner.style.overflow = 'hidden'; - - if (anchoredBlock.drawingKind === 'image' && 'src' in anchoredBlock && anchoredBlock.src) { - drawingInner.appendChild( - createBlockImageContent({ - doc, - block: anchoredBlock as ImageDrawing, - className: 'superdoc-drawing-image', - clipContainer: drawingInner, - imageDisplay: 'block', - buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, - }), - ); - } else if (renderDrawingContent) { - const drawingContent = renderDrawingContent(anchoredBlock as DrawingBlock); - drawingContent.style.width = '100%'; - drawingContent.style.height = '100%'; - drawingInner.appendChild(drawingContent); - } else { - const placeholder = doc.createElement('div'); - placeholder.classList.add('superdoc-drawing-placeholder'); - placeholder.style.width = '100%'; - placeholder.style.height = '100%'; - const stripePattern = - 'repeating-linear-gradient(45deg, rgba(15,23,42,0.1), rgba(15,23,42,0.1) 6px, rgba(15,23,42,0.2) 6px, rgba(15,23,42,0.2) 12px)'; - // Set both shorthand and longhand to handle partial CSS property support in test DOMs. - placeholder.style.background = stripePattern; - placeholder.style.backgroundImage = stripePattern; - placeholder.style.border = '1px dashed rgba(15, 23, 42, 0.3)'; - drawingInner.appendChild(placeholder); - } - - drawingWrapper.appendChild(drawingInner); + const drawingWrapper = renderTableDrawingFrame({ + doc, + block: anchoredBlock, + width: objectWidth, + height: objectHeight, + position: 'absolute', + left, + top, + zIndex, + renderDrawingContent, + applySdtDataset, + }); content.appendChild(drawingWrapper); } } From 9ba9188c408004bdcb9b1927003a694c79ac13fb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:51:55 -0300 Subject: [PATCH 2/7] fix(painters/dom): render table drawing images without callback --- .../dom/src/drawings/tableDrawingFrame.ts | 9 ++- .../dom/src/table/renderTableCell.test.ts | 62 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 2 + 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts index e641439c85..0a21128ce6 100644 --- a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts @@ -1,4 +1,6 @@ import type { DrawingBlock, SdtMetadata } from '@superdoc/contracts'; +import { createDrawingImageElement } from '../images/drawing-image.js'; +import type { BuildImageHyperlinkAnchor } from '../images/types.js'; import { createDrawingPlaceholder } from './renderDrawingContent.js'; export type RenderTableDrawingFrameParams = { @@ -12,6 +14,7 @@ export type RenderTableDrawingFrameParams = { zIndex?: number; flexShrink?: string; renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; }; @@ -26,6 +29,7 @@ export const renderTableDrawingFrame = ({ zIndex, flexShrink, renderDrawingContent, + buildImageHyperlinkAnchor, applySdtDataset, }: RenderTableDrawingFrameParams): HTMLElement => { const drawingWrapper = doc.createElement('div'); @@ -58,7 +62,10 @@ export const renderTableDrawingFrame = ({ drawingInner.style.overflow = 'hidden'; const drawingContent = - renderDrawingContent?.(block, { clipContainer: drawingInner }) ?? createDrawingPlaceholder(doc); + renderDrawingContent?.(block, { clipContainer: drawingInner }) ?? + (block.drawingKind === 'image' + ? createDrawingImageElement(doc, block, buildImageHyperlinkAnchor, drawingInner) + : createDrawingPlaceholder(doc)); drawingContent.style.width = '100%'; drawingContent.style.height = '100%'; drawingInner.appendChild(drawingContent); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index 080816ef53..a8d0fccc61 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -682,6 +682,68 @@ describe('renderTableCell', () => { expect(drawingWrapper?.style.top).toBe('7px'); }); + it('renders image drawing blocks inside table cells without a drawing callback', () => { + const para: ParagraphBlock = { + kind: 'paragraph', + id: 'para-drawing-image-anchor', + runs: [{ text: 'Anchor', fontFamily: 'Arial', fontSize: 16 }], + }; + + const flowingDrawing: DrawingBlock = { + kind: 'drawing', + id: 'drawing-image-flow', + drawingKind: 'image', + src: 'data:image/png;base64,AAA', + } as DrawingBlock; + + const anchoredDrawing: DrawingBlock = { + kind: 'drawing', + id: 'drawing-image-anchor', + drawingKind: 'image', + src: 'data:image/png;base64,BBB', + anchor: { isAnchored: true, alignH: 'left', offsetH: 12, vRelativeFrom: 'paragraph', offsetV: 7 }, + wrap: { type: 'None' }, + attrs: { anchorParagraphId: 'para-drawing-image-anchor' }, + } as DrawingBlock; + + const drawingMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'image', + width: 30, + height: 15, + scale: 1, + naturalWidth: 30, + naturalHeight: 15, + }; + + const cellMeasure: TableCellMeasure = { + blocks: [paragraphMeasure, drawingMeasure, drawingMeasure], + width: 100, + height: 50, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }; + + const cell: TableCell = { + id: 'cell-with-drawing-images', + blocks: [para, flowingDrawing, anchoredDrawing], + attrs: {}, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell, + }); + + const drawingImages = cellElement.querySelectorAll('img.superdoc-drawing-image'); + expect(drawingImages).toHaveLength(2); + expect((drawingImages[0] as HTMLImageElement).src).toBe('data:image/png;base64,AAA'); + expect((drawingImages[1] as HTMLImageElement).src).toBe('data:image/png;base64,BBB'); + expect(drawingImages[1]?.parentElement?.parentElement?.style.position).toBe('absolute'); + }); + it('pushes text away from wrapSquare anchored images in table cells', () => { const para: ParagraphBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 8d3bd7a0d4..ba28460d8e 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -900,6 +900,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen position: 'relative', flexShrink: '0', renderDrawingContent, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, applySdtDataset, }); content.appendChild(drawingWrapper); @@ -1073,6 +1074,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen top, zIndex, renderDrawingContent, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, applySdtDataset, }); content.appendChild(drawingWrapper); From cafc71a616b94c4ab8dc2c695135f1c1d7289cf3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:55:01 -0300 Subject: [PATCH 3/7] fix(painters/dom): bypass drawing callback for table images --- .../painters/dom/src/drawings/tableDrawingFrame.ts | 5 ++--- .../painters/dom/src/table/renderTableCell.test.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts index 0a21128ce6..b00ccb4559 100644 --- a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts @@ -62,10 +62,9 @@ export const renderTableDrawingFrame = ({ drawingInner.style.overflow = 'hidden'; const drawingContent = - renderDrawingContent?.(block, { clipContainer: drawingInner }) ?? - (block.drawingKind === 'image' + block.drawingKind === 'image' ? createDrawingImageElement(doc, block, buildImageHyperlinkAnchor, drawingInner) - : createDrawingPlaceholder(doc)); + : (renderDrawingContent?.(block, { clipContainer: drawingInner }) ?? createDrawingPlaceholder(doc)); drawingContent.style.width = '100%'; drawingContent.style.height = '100%'; drawingInner.appendChild(drawingContent); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index a8d0fccc61..6976d7c915 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { renderTableCell, getCellSegmentCount } from './renderTableCell.js'; import { getCellLines } from '@superdoc/layout-engine'; import type { @@ -682,7 +682,7 @@ describe('renderTableCell', () => { expect(drawingWrapper?.style.top).toBe('7px'); }); - it('renders image drawing blocks inside table cells without a drawing callback', () => { + it('renders image drawing blocks inside table cells without using a drawing callback', () => { const para: ParagraphBlock = { kind: 'paragraph', id: 'para-drawing-image-anchor', @@ -730,11 +730,17 @@ describe('renderTableCell', () => { blocks: [para, flowingDrawing, anchoredDrawing], attrs: {}, }; + const renderDrawingContent = vi.fn(() => { + const placeholder = doc.createElement('div'); + placeholder.classList.add('unexpected-drawing-callback'); + return placeholder; + }); const { cellElement } = renderTableCell({ ...createBaseDeps(), cellMeasure, cell, + renderDrawingContent, }); const drawingImages = cellElement.querySelectorAll('img.superdoc-drawing-image'); @@ -742,6 +748,7 @@ describe('renderTableCell', () => { expect((drawingImages[0] as HTMLImageElement).src).toBe('data:image/png;base64,AAA'); expect((drawingImages[1] as HTMLImageElement).src).toBe('data:image/png;base64,BBB'); expect(drawingImages[1]?.parentElement?.parentElement?.style.position).toBe('absolute'); + expect(renderDrawingContent).not.toHaveBeenCalled(); }); it('pushes text away from wrapSquare anchored images in table cells', () => { From 4d143392b0568b00758a2fb3b0c78e9dc3ea4868 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 18:09:38 -0300 Subject: [PATCH 4/7] fix(painters/dom): guard table drawing image sources --- .../dom/src/drawings/tableDrawingFrame.ts | 4 +- .../painters/dom/src/renderer.ts | 12 +++-- .../dom/src/table/renderTableCell.test.ts | 52 ++++++++++++++++++- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts index b00ccb4559..edb142266d 100644 --- a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts @@ -63,7 +63,9 @@ export const renderTableDrawingFrame = ({ const drawingContent = block.drawingKind === 'image' - ? createDrawingImageElement(doc, block, buildImageHyperlinkAnchor, drawingInner) + ? block.src + ? createDrawingImageElement(doc, block, buildImageHyperlinkAnchor, drawingInner) + : createDrawingPlaceholder(doc) : (renderDrawingContent?.(block, { clipContainer: drawingInner }) ?? createDrawingPlaceholder(doc)); drawingContent.style.width = '100%'; drawingContent.style.height = '100%'; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 5fbdd433b1..8ef2a6a23c 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2667,10 +2667,12 @@ export class DomPainter { ); }; - /** - * Renders drawing content that lives inside a table cell. - * Table-cell vector shapes intentionally skip outer geometry transforms. - */ + const buildTableImageHyperlinkAnchor = ( + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', + ): HTMLElement => buildSharedImageHyperlinkAnchor(this.doc!, imageEl, hyperlink, display); + const renderDrawingContentForTableCell = ( block: DrawingBlock, options?: { clipContainer?: HTMLElement }, @@ -2681,7 +2683,7 @@ export class DomPainter { geometry: 'geometry' in block ? block.geometry : undefined, context, clipContainer: options?.clipContainer, - buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, }); const tableRenderData = this.resolveTableRenderData(fragment, resolvedItem); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index 6976d7c915..09fa9b4c75 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -701,6 +701,7 @@ describe('renderTableCell', () => { id: 'drawing-image-anchor', drawingKind: 'image', src: 'data:image/png;base64,BBB', + hyperlink: { url: 'https://example.com/table-drawing', tooltip: 'Open drawing' }, anchor: { isAnchored: true, alignH: 'left', offsetH: 12, vRelativeFrom: 'paragraph', offsetV: 7 }, wrap: { type: 'None' }, attrs: { anchorParagraphId: 'para-drawing-image-anchor' }, @@ -747,10 +748,59 @@ describe('renderTableCell', () => { expect(drawingImages).toHaveLength(2); expect((drawingImages[0] as HTMLImageElement).src).toBe('data:image/png;base64,AAA'); expect((drawingImages[1] as HTMLImageElement).src).toBe('data:image/png;base64,BBB'); - expect(drawingImages[1]?.parentElement?.parentElement?.style.position).toBe('absolute'); + const anchor = drawingImages[1]?.parentElement as HTMLAnchorElement | null; + expect(anchor?.tagName).toBe('A'); + expect(anchor?.classList.contains('superdoc-link')).toBe(true); + expect(anchor?.href).toBe('https://example.com/table-drawing'); + expect(anchor?.style.display).toBe('block'); + expect(anchor?.style.width).toBe('100%'); + expect(anchor?.style.height).toBe('100%'); + expect(anchor?.parentElement?.parentElement?.style.position).toBe('absolute'); expect(renderDrawingContent).not.toHaveBeenCalled(); }); + it('renders a placeholder for image drawing blocks without a source', () => { + const drawingWithoutSrc: DrawingBlock = { + kind: 'drawing', + id: 'drawing-image-without-src', + drawingKind: 'image', + } as DrawingBlock; + + const cellMeasure: TableCellMeasure = { + blocks: [ + { + kind: 'drawing', + drawingKind: 'image', + width: 30, + height: 15, + scale: 1, + naturalWidth: 30, + naturalHeight: 15, + }, + ], + width: 100, + height: 30, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell: { id: 'cell-with-empty-drawing-image', blocks: [drawingWithoutSrc], attrs: {} }, + renderDrawingContent: () => { + const el = doc.createElement('img'); + el.classList.add('unexpected-drawing-image'); + return el; + }, + }); + + expect(cellElement.querySelector('img.superdoc-drawing-image')).toBeFalsy(); + expect(cellElement.querySelector('.unexpected-drawing-image')).toBeFalsy(); + expect(cellElement.querySelector('.superdoc-drawing-placeholder')).toBeTruthy(); + }); + it('pushes text away from wrapSquare anchored images in table cells', () => { const para: ParagraphBlock = { kind: 'paragraph', From 10bff00d8ff2cbab50116b37b8584085a7f49e75 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 18:12:55 -0300 Subject: [PATCH 5/7] refactor(painters/dom): flatten drawing content renderer --- .../src/drawings/renderDrawingContent.test.ts | 3 +- .../dom/src/drawings/renderDrawingContent.ts | 1461 ++++++++--------- 2 files changed, 729 insertions(+), 735 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts index ee55cf1bbe..2bd7fe6dcb 100644 --- a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts @@ -62,7 +62,8 @@ describe('renderDrawingContent', () => { expect(groupEl.classList.contains('superdoc-shape-group')).toBe(true); expect(groupEl.querySelector('img')).toBeTruthy(); expect(chartEl.classList.contains('superdoc-chart')).toBe(true); - expect(chartEl.textContent).toContain('No chart data'); + expect(chartEl.querySelector('svg')).toBeFalsy(); + expect(chartEl.style.display).toBe('flex'); }); it('renders fallback placeholders through the shared drawing content path', () => { diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts index 09ec524ddf..23d5fef4b4 100644 --- a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts @@ -79,829 +79,822 @@ export const renderDrawingContent = ({ clipContainer, buildImageHyperlinkAnchor, }: RenderDrawingContentParams): HTMLElement => { - const renderer = new DrawingContentRenderer(doc, buildImageHyperlinkAnchor); - return renderer.render(block, geometry, context, clipContainer); -}; - -class DrawingContentRenderer { - constructor( - private readonly doc: Document, - private readonly buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor, - ) {} - - render( - block: DrawingBlock, - geometry?: DrawingGeometry, - context?: FragmentRenderContext, - clipContainer?: HTMLElement, - ): HTMLElement { - if (block.drawingKind === 'image') { - return createDrawingImageElement(this.doc, block, this.buildImageHyperlinkAnchor, clipContainer); - } - if (block.drawingKind === 'vectorShape') { - return this.createVectorShapeElement(block, geometry ?? block.geometry, false, 1, 1, context); - } - if (block.drawingKind === 'shapeGroup') { - return this.createShapeGroupElement(block, context); - } - if (block.drawingKind === 'chart') { - return this.createChartElement(block); - } - return createDrawingPlaceholder(this.doc); - } - - private createVectorShapeElement( - block: VectorShapeDrawingWithEffects, - geometry?: DrawingGeometry, - applyTransforms = false, - groupScaleX = 1, - groupScaleY = 1, - context?: FragmentRenderContext, - ): HTMLElement { - const container = this.doc.createElement('div'); - container.classList.add('superdoc-vector-shape'); - container.style.width = '100%'; - container.style.height = '100%'; - container.style.position = 'relative'; - container.style.overflow = 'hidden'; - - const { offsetX, offsetY, innerWidth, innerHeight } = this.getEffectExtentMetrics(block, geometry); - const contentContainer = this.doc.createElement('div'); - contentContainer.style.position = 'absolute'; - contentContainer.style.left = `${offsetX}px`; - contentContainer.style.top = `${offsetY}px`; - contentContainer.style.width = `${innerWidth}px`; - contentContainer.style.height = `${innerHeight}px`; - if (applyTransforms && geometry) { - this.applyVectorShapeTransforms(contentContainer, geometry); - } + return renderDrawingBlock({ doc, buildImageHyperlinkAnchor }, block, geometry, context, clipContainer); +}; - 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); - if (svgElement) { - svgElement.setAttribute('width', '100%'); - svgElement.setAttribute('height', '100%'); - svgElement.style.display = 'block'; - - if (block.fillColor && typeof block.fillColor === 'object') { - if ('type' in block.fillColor && block.fillColor.type === 'gradient') { - applyGradientToSVG(svgElement, block.fillColor as GradientFill); - } else if ('type' in block.fillColor && block.fillColor.type === 'solidWithAlpha') { - applyAlphaToSVG(svgElement, block.fillColor as SolidFillWithAlpha); - } - } +type DrawingRenderContext = { + doc: Document; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; +}; + +const renderDrawingBlock = ( + renderer: DrawingRenderContext, + block: DrawingBlock, + geometry?: DrawingGeometry, + context?: FragmentRenderContext, + clipContainer?: HTMLElement, +): HTMLElement => { + if (block.drawingKind === 'image') { + return createDrawingImageElement(renderer.doc, block, renderer.buildImageHyperlinkAnchor, clipContainer); + } + if (block.drawingKind === 'vectorShape') { + return createVectorShapeElement(renderer, block, geometry ?? block.geometry, false, context); + } + if (block.drawingKind === 'shapeGroup') { + return createShapeGroupElement(renderer, block, context); + } + if (block.drawingKind === 'chart') { + return createChartElement(renderer, block); + } + return createDrawingPlaceholder(renderer.doc); +}; + +const createVectorShapeElement = ( + renderer: DrawingRenderContext, + block: VectorShapeDrawingWithEffects, + geometry?: DrawingGeometry, + applyTransforms = false, + context?: FragmentRenderContext, +): HTMLElement => { + const container = renderer.doc.createElement('div'); + container.classList.add('superdoc-vector-shape'); + container.style.width = '100%'; + container.style.height = '100%'; + container.style.position = 'relative'; + container.style.overflow = 'hidden'; + + const { offsetX, offsetY, innerWidth, innerHeight } = getEffectExtentMetrics(block, geometry); + const contentContainer = renderer.doc.createElement('div'); + contentContainer.style.position = 'absolute'; + contentContainer.style.left = `${offsetX}px`; + contentContainer.style.top = `${offsetY}px`; + contentContainer.style.width = `${innerWidth}px`; + contentContainer.style.height = `${innerHeight}px`; + if (applyTransforms && geometry) { + applyVectorShapeTransforms(contentContainer, geometry); + } - this.applyLineEnds(svgElement, block); - contentContainer.appendChild(svgElement); - - if (this.hasShapeTextContent(block.textContent)) { - const textElement = this.createShapeTextElement( - block, - innerWidth, - innerHeight, - groupScaleX, - groupScaleY, - context, - ); - contentContainer.appendChild(textElement); + const customGeomSvg = block.customGeometry ? tryCreateCustomGeometrySvg(block, innerWidth, innerHeight) : null; + const svgMarkup = !customGeomSvg && block.shapeKind ? tryCreatePresetSvg(block, innerWidth, innerHeight) : null; + const resolvedSvgMarkup = customGeomSvg || svgMarkup; + + if (resolvedSvgMarkup) { + const svgElement = parseSafeSvg(renderer, resolvedSvgMarkup); + if (svgElement) { + svgElement.setAttribute('width', '100%'); + svgElement.setAttribute('height', '100%'); + svgElement.style.display = 'block'; + + if (block.fillColor && typeof block.fillColor === 'object') { + if ('type' in block.fillColor && block.fillColor.type === 'gradient') { + applyGradientToSVG(svgElement, block.fillColor as GradientFill); + } else if ('type' in block.fillColor && block.fillColor.type === 'solidWithAlpha') { + applyAlphaToSVG(svgElement, block.fillColor as SolidFillWithAlpha); } + } + + applyLineEnds(renderer, svgElement, block); + contentContainer.appendChild(svgElement); - container.appendChild(contentContainer); - return container; + if (hasShapeTextContent(block.textContent)) { + const textElement = createShapeTextElement(renderer, block, innerWidth, innerHeight, context); + contentContainer.appendChild(textElement); } - } - this.applyFallbackShapeStyle(contentContainer, block); - - if (this.hasShapeTextContent(block.textContent)) { - const textElement = this.createShapeTextElement( - block, - innerWidth, - innerHeight, - groupScaleX, - groupScaleY, - context, - ); - contentContainer.appendChild(textElement); + container.appendChild(contentContainer); + return container; } + } + + applyFallbackShapeStyle(contentContainer, block); - container.appendChild(contentContainer); - return container; + if (hasShapeTextContent(block.textContent)) { + const textElement = createShapeTextElement(renderer, block, innerWidth, innerHeight, context); + contentContainer.appendChild(textElement); } - private applyFallbackShapeStyle(container: HTMLElement, block: VectorShapeDrawing): void { - if (block.fillColor === null) { - container.style.background = 'none'; - } else if (typeof block.fillColor === 'string') { - container.style.background = block.fillColor; - } else if (typeof block.fillColor === 'object' && 'type' in block.fillColor) { - if (block.fillColor.type === 'solidWithAlpha') { - const alpha = (block.fillColor as SolidFillWithAlpha).alpha; - const color = (block.fillColor as SolidFillWithAlpha).color; - container.style.background = color; - container.style.opacity = alpha.toString(); - } else if (block.fillColor.type === 'gradient') { - container.style.background = 'rgba(15, 23, 42, 0.1)'; - } - } else { - container.style.background = 'rgba(15, 23, 42, 0.1)'; - } + container.appendChild(contentContainer); + return container; +}; - if (block.strokeColor === null) { - container.style.border = 'none'; - } else if (typeof block.strokeColor === 'string') { - const strokeWidth = block.strokeWidth ?? 1; - container.style.border = `${strokeWidth}px solid ${block.strokeColor}`; - } else { - container.style.border = '1px solid rgba(15, 23, 42, 0.3)'; +const applyFallbackShapeStyle = (container: HTMLElement, block: VectorShapeDrawing): void => { + if (block.fillColor === null) { + container.style.background = 'none'; + } else if (typeof block.fillColor === 'string') { + container.style.background = block.fillColor; + } else if (typeof block.fillColor === 'object' && 'type' in block.fillColor) { + if (block.fillColor.type === 'solidWithAlpha') { + const alpha = (block.fillColor as SolidFillWithAlpha).alpha; + const color = (block.fillColor as SolidFillWithAlpha).color; + container.style.background = color; + container.style.opacity = alpha.toString(); + } else if (block.fillColor.type === 'gradient') { + container.style.background = 'rgba(15, 23, 42, 0.1)'; } + } else { + container.style.background = 'rgba(15, 23, 42, 0.1)'; } - private hasShapeTextContent(textContent?: ShapeTextContent): textContent is ShapeTextContent { - return Array.isArray(textContent?.parts) && textContent.parts.length > 0; + if (block.strokeColor === null) { + container.style.border = 'none'; + } else if (typeof block.strokeColor === 'string') { + const strokeWidth = block.strokeWidth ?? 1; + container.style.border = `${strokeWidth}px solid ${block.strokeColor}`; + } else { + container.style.border = '1px solid rgba(15, 23, 42, 0.3)'; } +}; - private createShapeTextElement( - block: VectorShapeDrawing, - width: number, - height: number, - groupScaleX = 1, - groupScaleY = 1, - context?: FragmentRenderContext, - ): Element { - const textContent = block.textContent; - if (!this.hasShapeTextContent(textContent)) { - return this.doc.createElement('div'); - } +const hasShapeTextContent = (textContent?: ShapeTextContent): textContent is ShapeTextContent => { + return Array.isArray(textContent?.parts) && textContent.parts.length > 0; +}; - if (this.shouldUseWordArtTextRenderer(block)) { - return this.createWordArtTextElement( - textContent, - block.textAlign ?? 'center', - block.textInsets, - width, - height, - context, - ); - } +const createShapeTextElement = ( + renderer: DrawingRenderContext, + block: VectorShapeDrawing, + width: number, + height: number, + context?: FragmentRenderContext, +): Element => { + const textContent = block.textContent; + if (!hasShapeTextContent(textContent)) { + return renderer.doc.createElement('div'); + } - return this.createFallbackTextElement( + if (shouldUseWordArtTextRenderer(block)) { + return createWordArtTextElement( + renderer, textContent, block.textAlign ?? 'center', - block.textVerticalAlign, block.textInsets, - groupScaleX, - groupScaleY, + width, + height, context, ); } - private shouldUseWordArtTextRenderer(block: VectorShapeDrawing): boolean { - return block.attrs?.isWordArt === true && this.hasShapeTextContent(block.textContent); - } - - private createWordArtTextElement( - textContent: ShapeTextContent, - textAlign: string, - textInsets: { top: number; right: number; bottom: number; left: number } | undefined, - width: number, - height: number, - context?: FragmentRenderContext, - ): SVGSVGElement { - const svg = this.doc.createElementNS(SVG_NS, 'svg'); - svg.classList.add('superdoc-wordart-text'); - svg.setAttribute('xmlns', SVG_NS); - svg.setAttribute('viewBox', `0 0 ${width} ${height}`); - svg.setAttribute('preserveAspectRatio', 'none'); - svg.style.position = 'absolute'; - svg.style.left = '0'; - svg.style.top = '0'; - svg.style.width = '100%'; - svg.style.height = '100%'; - svg.style.overflow = 'visible'; - svg.style.pointerEvents = 'none'; - - const insets = textInsets ?? { top: 0, right: 0, bottom: 0, left: 0 }; - const availableWidth = Math.max(1, width - insets.left - insets.right); - const availableHeight = Math.max(1, height - insets.top - insets.bottom); - const lines = this.buildWordArtLines(textContent, context); - const lineCount = Math.max(lines.length, 1); - const lineHeight = availableHeight / lineCount; - const fontSize = Math.max(1, lineHeight * WORDART_LINE_FILL_RATIO); - const textAnchor = this.getWordArtTextAnchor(textAlign); - const textX = this.getWordArtTextX(textAlign, insets.left, availableWidth); - - lines.forEach((parts, lineIndex) => { - if (parts.length === 0) { - return; - } + return createFallbackTextElement( + renderer, + textContent, + block.textAlign ?? 'center', + block.textVerticalAlign, + block.textInsets, + context, + ); +}; + +const shouldUseWordArtTextRenderer = (block: VectorShapeDrawing): boolean => { + return block.attrs?.isWordArt === true && hasShapeTextContent(block.textContent); +}; + +const createWordArtTextElement = ( + renderer: DrawingRenderContext, + textContent: ShapeTextContent, + textAlign: string, + textInsets: { top: number; right: number; bottom: number; left: number } | undefined, + width: number, + height: number, + context?: FragmentRenderContext, +): SVGSVGElement => { + const svg = renderer.doc.createElementNS(SVG_NS, 'svg'); + svg.classList.add('superdoc-wordart-text'); + svg.setAttribute('xmlns', SVG_NS); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.style.position = 'absolute'; + svg.style.left = '0'; + svg.style.top = '0'; + svg.style.width = '100%'; + svg.style.height = '100%'; + svg.style.overflow = 'visible'; + svg.style.pointerEvents = 'none'; + + const insets = textInsets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + const availableWidth = Math.max(1, width - insets.left - insets.right); + const availableHeight = Math.max(1, height - insets.top - insets.bottom); + const lines = buildWordArtLines(textContent, context); + const lineCount = Math.max(lines.length, 1); + const lineHeight = availableHeight / lineCount; + const fontSize = Math.max(1, lineHeight * WORDART_LINE_FILL_RATIO); + const textAnchor = getWordArtTextAnchor(textAlign); + const textX = getWordArtTextX(textAlign, insets.left, availableWidth); + + lines.forEach((parts, lineIndex) => { + if (parts.length === 0) { + return; + } - const textEl = this.doc.createElementNS(SVG_NS, 'text'); - textEl.setAttribute('xml:space', 'preserve'); - textEl.setAttribute('x', String(textX)); - textEl.setAttribute('y', String(insets.top + lineHeight * (lineIndex + 0.5))); - textEl.setAttribute('text-anchor', textAnchor); - textEl.setAttribute('dominant-baseline', 'middle'); - textEl.setAttribute('font-size', String(fontSize)); - textEl.setAttribute('textLength', String(availableWidth)); - textEl.setAttribute('lengthAdjust', 'spacingAndGlyphs'); - - parts.forEach((part) => { - const tspan = this.doc.createElementNS(SVG_NS, 'tspan'); - tspan.setAttribute('xml:space', 'preserve'); - tspan.textContent = part.text; - this.applyWordArtTextFormatting(tspan, part.formatting); - textEl.appendChild(tspan); - }); - - svg.appendChild(textEl); + const textEl = renderer.doc.createElementNS(SVG_NS, 'text'); + textEl.setAttribute('xml:space', 'preserve'); + textEl.setAttribute('x', String(textX)); + textEl.setAttribute('y', String(insets.top + lineHeight * (lineIndex + 0.5))); + textEl.setAttribute('text-anchor', textAnchor); + textEl.setAttribute('dominant-baseline', 'middle'); + textEl.setAttribute('font-size', String(fontSize)); + textEl.setAttribute('textLength', String(availableWidth)); + textEl.setAttribute('lengthAdjust', 'spacingAndGlyphs'); + + parts.forEach((part) => { + const tspan = renderer.doc.createElementNS(SVG_NS, 'tspan'); + tspan.setAttribute('xml:space', 'preserve'); + tspan.textContent = part.text; + applyWordArtTextFormatting(tspan, part.formatting); + textEl.appendChild(tspan); }); - return svg; - } + svg.appendChild(textEl); + }); - private buildWordArtLines( - textContent: ShapeTextContent, - context?: FragmentRenderContext, - ): Array> { - const lines: Array> = [[]]; + return svg; +}; - textContent.parts.forEach((part) => { - if (part.isLineBreak) { - lines.push([]); - return; - } +const buildWordArtLines = ( + textContent: ShapeTextContent, + context?: FragmentRenderContext, +): Array> => { + const lines: Array> = [[]]; - const resolvedText = this.resolveShapeTextPartText(part, context); - if (!resolvedText) { - return; - } + textContent.parts.forEach((part) => { + if (part.isLineBreak) { + lines.push([]); + return; + } - lines[lines.length - 1].push({ - text: resolvedText, - formatting: part.formatting, - }); + const resolvedText = resolveShapeTextPartText(part, context); + if (!resolvedText) { + return; + } + + lines[lines.length - 1].push({ + text: resolvedText, + formatting: part.formatting, }); + }); - const nonEmptyLines = lines.filter((line) => line.length > 0); - return nonEmptyLines.length > 0 ? nonEmptyLines : [[]]; + const nonEmptyLines = lines.filter((line) => line.length > 0); + return nonEmptyLines.length > 0 ? nonEmptyLines : [[]]; +}; + +const resolveShapeTextPartText = (part: ShapeTextContent['parts'][number], context?: FragmentRenderContext): string => { + if (part.fieldType === 'PAGE') { + return context?.pageNumberText ?? String(context?.pageNumber ?? 1); + } + if (part.fieldType === 'NUMPAGES') { + return String(context?.totalPages ?? 1); } + return part.text; +}; - private resolveShapeTextPartText(part: ShapeTextContent['parts'][number], context?: FragmentRenderContext): string { - if (part.fieldType === 'PAGE') { - return context?.pageNumberText ?? String(context?.pageNumber ?? 1); - } - if (part.fieldType === 'NUMPAGES') { - return String(context?.totalPages ?? 1); - } - return part.text; +const getWordArtTextAnchor = (textAlign: string): 'start' | 'middle' | 'end' => { + if (textAlign === 'right' || textAlign === 'r') { + return 'end'; } + if (textAlign === 'center') { + return 'middle'; + } + return 'start'; +}; - private getWordArtTextAnchor(textAlign: string): 'start' | 'middle' | 'end' { - if (textAlign === 'right' || textAlign === 'r') { - return 'end'; - } - if (textAlign === 'center') { - return 'middle'; - } - return 'start'; +const getWordArtTextX = (textAlign: string, leftInset: number, availableWidth: number): number => { + if (textAlign === 'right' || textAlign === 'r') { + return leftInset + availableWidth; + } + if (textAlign === 'center') { + return leftInset + availableWidth / 2; } + return leftInset; +}; - private getWordArtTextX(textAlign: string, leftInset: number, availableWidth: number): number { - if (textAlign === 'right' || textAlign === 'r') { - return leftInset + availableWidth; - } - if (textAlign === 'center') { - return leftInset + availableWidth / 2; +const applyWordArtTextFormatting = ( + element: SVGTextElement | SVGTSpanElement, + formatting?: ShapeTextContent['parts'][number]['formatting'], +): void => { + if (!formatting) { + return; + } + if (formatting.bold) { + element.setAttribute('font-weight', 'bold'); + } + if (formatting.italic) { + element.setAttribute('font-style', 'italic'); + } + if (formatting.fontFamily) { + element.setAttribute('font-family', formatting.fontFamily); + } + if (formatting.color) { + const validatedColor = validateHexColor(formatting.color); + if (validatedColor) { + element.setAttribute('fill', validatedColor); } - return leftInset; } + if (formatting.letterSpacing != null) { + element.setAttribute('letter-spacing', String(formatting.letterSpacing)); + } +}; - private applyWordArtTextFormatting( - element: SVGTextElement | SVGTSpanElement, - formatting?: ShapeTextContent['parts'][number]['formatting'], - ): void { - if (!formatting) { - return; - } - if (formatting.bold) { - element.setAttribute('font-weight', 'bold'); - } - if (formatting.italic) { - element.setAttribute('font-style', 'italic'); - } - if (formatting.fontFamily) { - element.setAttribute('font-family', formatting.fontFamily); - } - if (formatting.color) { - const validatedColor = validateHexColor(formatting.color); - if (validatedColor) { - element.setAttribute('fill', validatedColor); - } - } - if (formatting.letterSpacing != null) { - element.setAttribute('letter-spacing', String(formatting.letterSpacing)); - } +const createFallbackTextElement = ( + renderer: DrawingRenderContext, + textContent: ShapeTextContent, + textAlign: string, + textVerticalAlign?: 'top' | 'center' | 'bottom', + textInsets?: { top: number; right: number; bottom: number; left: number }, + context?: FragmentRenderContext, +): HTMLElement => { + const textDiv = renderer.doc.createElement('div'); + textDiv.style.position = 'absolute'; + textDiv.style.top = '0'; + textDiv.style.left = '0'; + textDiv.style.width = '100%'; + textDiv.style.height = '100%'; + textDiv.style.display = 'flex'; + textDiv.style.flexDirection = 'column'; + + const verticalAlign = textVerticalAlign ?? 'top'; + if (verticalAlign === 'top') { + textDiv.style.justifyContent = 'flex-start'; + } else if (verticalAlign === 'bottom') { + textDiv.style.justifyContent = 'flex-end'; + } else { + textDiv.style.justifyContent = 'center'; } - private createFallbackTextElement( - textContent: ShapeTextContent, - textAlign: string, - textVerticalAlign?: 'top' | 'center' | 'bottom', - textInsets?: { top: number; right: number; bottom: number; left: number }, - groupScaleX = 1, - groupScaleY = 1, - context?: FragmentRenderContext, - ): HTMLElement { - const textDiv = this.doc.createElement('div'); - textDiv.style.position = 'absolute'; - textDiv.style.top = '0'; - textDiv.style.left = '0'; - textDiv.style.width = '100%'; - textDiv.style.height = '100%'; - textDiv.style.display = 'flex'; - textDiv.style.flexDirection = 'column'; - - const verticalAlign = textVerticalAlign ?? 'top'; - if (verticalAlign === 'top') { - textDiv.style.justifyContent = 'flex-start'; - } else if (verticalAlign === 'bottom') { - textDiv.style.justifyContent = 'flex-end'; - } else { - textDiv.style.justifyContent = 'center'; - } + if (textInsets) { + textDiv.style.padding = `${textInsets.top}px ${textInsets.right}px ${textInsets.bottom}px ${textInsets.left}px`; + } else { + textDiv.style.padding = '10px'; + } - if (textInsets) { - textDiv.style.padding = `${textInsets.top}px ${textInsets.right}px ${textInsets.bottom}px ${textInsets.left}px`; - } else { - textDiv.style.padding = '10px'; - } + textDiv.style.boxSizing = 'border-box'; + textDiv.style.wordWrap = 'break-word'; + textDiv.style.overflowWrap = 'break-word'; + textDiv.style.overflow = 'hidden'; + textDiv.style.minWidth = '0'; + textDiv.style.fontSize = '12px'; + textDiv.style.lineHeight = '1.2'; + + if (textAlign === 'center') { + textDiv.style.textAlign = 'center'; + } else if (textAlign === 'right' || textAlign === 'r') { + textDiv.style.textAlign = 'right'; + } else { + textDiv.style.textAlign = 'left'; + } - textDiv.style.boxSizing = 'border-box'; - textDiv.style.wordWrap = 'break-word'; - textDiv.style.overflowWrap = 'break-word'; - textDiv.style.overflow = 'hidden'; - textDiv.style.minWidth = '0'; - textDiv.style.fontSize = '12px'; - textDiv.style.lineHeight = '1.2'; - - if (textAlign === 'center') { - textDiv.style.textAlign = 'center'; - } else if (textAlign === 'right' || textAlign === 'r') { - textDiv.style.textAlign = 'right'; + let currentParagraph = renderer.doc.createElement('div'); + currentParagraph.style.width = '100%'; + currentParagraph.style.minWidth = '0'; + currentParagraph.style.whiteSpace = 'normal'; + + textContent.parts.forEach((part) => { + if (part.isLineBreak) { + textDiv.appendChild(currentParagraph); + currentParagraph = renderer.doc.createElement('div'); + currentParagraph.style.width = '100%'; + currentParagraph.style.minWidth = '0'; + currentParagraph.style.whiteSpace = 'normal'; + if (part.isEmptyParagraph) { + currentParagraph.style.minHeight = '1em'; + } + } else if (part.kind === 'image' && part.src) { + currentParagraph.appendChild(createShapeTextImageElement(renderer.doc, part)); } else { - textDiv.style.textAlign = 'left'; - } - - let currentParagraph = this.doc.createElement('div'); - currentParagraph.style.width = '100%'; - currentParagraph.style.minWidth = '0'; - currentParagraph.style.whiteSpace = 'normal'; - - textContent.parts.forEach((part) => { - if (part.isLineBreak) { - textDiv.appendChild(currentParagraph); - currentParagraph = this.doc.createElement('div'); - currentParagraph.style.width = '100%'; - currentParagraph.style.minWidth = '0'; - currentParagraph.style.whiteSpace = 'normal'; - if (part.isEmptyParagraph) { - currentParagraph.style.minHeight = '1em'; + const span = renderer.doc.createElement('span'); + span.textContent = resolveShapeTextPartText(part, context); + if (part.formatting) { + if (part.formatting.bold) { + span.style.fontWeight = 'bold'; } - } else if (part.kind === 'image' && part.src) { - currentParagraph.appendChild(createShapeTextImageElement(this.doc, part)); - } else { - const span = this.doc.createElement('span'); - span.textContent = this.resolveShapeTextPartText(part, context); - if (part.formatting) { - if (part.formatting.bold) { - span.style.fontWeight = 'bold'; - } - if (part.formatting.italic) { - span.style.fontStyle = 'italic'; - } - if (part.formatting.fontFamily) { - span.style.fontFamily = part.formatting.fontFamily; - } - if (part.formatting.color) { - const validatedColor = validateHexColor(part.formatting.color); - if (validatedColor) { - span.style.color = validatedColor; - } - } - if (part.formatting.fontSize) { - span.style.fontSize = `${part.formatting.fontSize}px`; - } - if (part.formatting.letterSpacing != null) { - span.style.letterSpacing = `${part.formatting.letterSpacing}px`; + if (part.formatting.italic) { + span.style.fontStyle = 'italic'; + } + if (part.formatting.fontFamily) { + span.style.fontFamily = part.formatting.fontFamily; + } + if (part.formatting.color) { + const validatedColor = validateHexColor(part.formatting.color); + if (validatedColor) { + span.style.color = validatedColor; } } - currentParagraph.appendChild(span); + if (part.formatting.fontSize) { + span.style.fontSize = `${part.formatting.fontSize}px`; + } + if (part.formatting.letterSpacing != null) { + span.style.letterSpacing = `${part.formatting.letterSpacing}px`; + } } - }); + currentParagraph.appendChild(span); + } + }); - textDiv.appendChild(currentParagraph); + textDiv.appendChild(currentParagraph); - return textDiv; - } + return textDiv; +}; - private tryCreatePresetSvg( - block: VectorShapeDrawing, - widthOverride?: number, - heightOverride?: number, - ): string | null { - try { - let fillColor: string | undefined; - if (block.fillColor === null) { - fillColor = 'none'; - } else if (typeof block.fillColor === 'string') { - fillColor = block.fillColor; - } - const strokeColor = - block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : undefined; +const tryCreatePresetSvg = ( + block: VectorShapeDrawing, + widthOverride?: number, + heightOverride?: number, +): string | null => { + try { + let fillColor: string | undefined; + if (block.fillColor === null) { + fillColor = 'none'; + } else if (typeof block.fillColor === 'string') { + fillColor = block.fillColor; + } + const strokeColor = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : undefined; - if (block.shapeKind === 'line' || block.shapeKind === 'straightConnector1') { - const width = widthOverride ?? block.geometry.width; - const height = heightOverride ?? block.geometry.height; - const stroke = strokeColor ?? '#000000'; - const strokeWidth = block.strokeWidth ?? 1; + if (block.shapeKind === 'line' || block.shapeKind === 'straightConnector1') { + const width = widthOverride ?? block.geometry.width; + const height = heightOverride ?? block.geometry.height; + const stroke = strokeColor ?? '#000000'; + const strokeWidth = block.strokeWidth ?? 1; - return ` + return ` `; - } - - return getPresetShapeSvg({ - preset: block.shapeKind ?? '', - styleOverrides: () => ({ - fill: fillColor, - stroke: strokeColor, - strokeWidth: block.strokeWidth ?? undefined, - }), - width: widthOverride ?? block.geometry.width, - height: heightOverride ?? block.geometry.height, - }); - } catch (error) { - console.warn(`[DomPainter] Unable to render preset shape "${block.shapeKind}":`, error); - return null; } - } - private tryCreateCustomGeometrySvg(block: VectorShapeDrawing, width: number, height: number): string | null { - const custGeom = block.customGeometry; - if (!custGeom?.paths?.length) return null; + return getPresetShapeSvg({ + preset: block.shapeKind ?? '', + styleOverrides: () => ({ + fill: fillColor, + stroke: strokeColor, + strokeWidth: block.strokeWidth ?? undefined, + }), + width: widthOverride ?? block.geometry.width, + height: heightOverride ?? block.geometry.height, + }); + } catch (error) { + console.warn(`[DomPainter] Unable to render preset shape "${block.shapeKind}":`, error); + return null; + } +}; - let fillColor: string; - if (block.fillColor === null) { - fillColor = 'none'; - } else if (typeof block.fillColor === 'string') { - fillColor = block.fillColor; - } else { - fillColor = '#000000'; - } - const strokeColor = - block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : 'none'; - const strokeWidth = block.strokeColor === null ? 0 : (block.strokeWidth ?? 0); - - const firstPath = custGeom.paths[0]; - const viewW = firstPath.w || width; - const viewH = firstPath.h || height; - - if (viewW === 0 || viewH === 0) return null; - - 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) => { - 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 ` +const 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 = '#000000'; + } + const strokeColor = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : 'none'; + const strokeWidth = block.strokeColor === null ? 0 : (block.strokeWidth ?? 0); + + const firstPath = custGeom.paths[0]; + const viewW = firstPath.w || width; + const viewH = firstPath.h || height; + + if (viewW === 0 || viewH === 0) return null; + + 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) => { + 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) { - return null; - } - const parser = new DOMParserCtor(); - const parsed = parser.parseFromString(markup, 'image/svg+xml'); - if (!parsed || parsed.getElementsByTagName('parsererror').length > 0) { - return null; - } - const svgElement = parsed.documentElement as unknown as SVGElement | null; - if (!svgElement) return null; - this.stripUnsafeSvgContent(svgElement); - const imported = this.doc.importNode(svgElement, true); - return imported ? (imported as unknown as SVGElement) : null; - } - - private stripUnsafeSvgContent(element: Element): void { - element.querySelectorAll('script').forEach((script) => script.remove()); - const sanitize = (node: Element) => { - Array.from(node.attributes).forEach((attr) => { - if (attr.name.toLowerCase().startsWith('on')) { - node.removeAttribute(attr.name); - } - }); - Array.from(node.children).forEach((child) => { - sanitize(child as Element); - }); - }; - sanitize(element); - } - - private getEffectExtentMetrics( - block: VectorShapeDrawingWithEffects, - geometry?: DrawingGeometry, - ): { - offsetX: number; - offsetY: number; - innerWidth: number; - innerHeight: number; - } { - const left = block.effectExtent?.left ?? 0; - const top = block.effectExtent?.top ?? 0; - const right = block.effectExtent?.right ?? 0; - const bottom = block.effectExtent?.bottom ?? 0; - const sourceGeometry = geometry ?? block.geometry; - const width = sourceGeometry.width ?? 0; - const height = sourceGeometry.height ?? 0; - const innerWidth = Math.max(0, width - left - right); - const innerHeight = Math.max(0, height - top - bottom); - return { offsetX: left, offsetY: top, innerWidth, innerHeight }; - } - - private applyLineEnds(svgElement: SVGElement, block: VectorShapeDrawingWithEffects): void { - const lineEnds = block.lineEnds; - if (!lineEnds) return; - if (block.strokeColor === null) return; - const strokeColor = typeof block.strokeColor === 'string' ? block.strokeColor : '#000000'; - const strokeWidth = block.strokeWidth ?? 1; - if (strokeWidth <= 0) return; +const parseSafeSvg = (renderer: DrawingRenderContext, markup: string): SVGElement | null => { + const DOMParserCtor = renderer.doc.defaultView?.DOMParser ?? (typeof DOMParser !== 'undefined' ? DOMParser : null); + if (!DOMParserCtor) { + return null; + } + const parser = new DOMParserCtor(); + const parsed = parser.parseFromString(markup, 'image/svg+xml'); + if (!parsed || parsed.getElementsByTagName('parsererror').length > 0) { + return null; + } + const svgElement = parsed.documentElement as unknown as SVGElement | null; + if (!svgElement) return null; + stripUnsafeSvgContent(svgElement); + const imported = renderer.doc.importNode(svgElement, true); + return imported ? (imported as unknown as SVGElement) : null; +}; - const target = this.findLineEndTarget(svgElement); - if (!target) return; +const stripUnsafeSvgContent = (element: Element): void => { + element.querySelectorAll('script').forEach((script) => script.remove()); + const sanitize = (node: Element) => { + Array.from(node.attributes).forEach((attr) => { + if (attr.name.toLowerCase().startsWith('on')) { + node.removeAttribute(attr.name); + } + }); + Array.from(node.children).forEach((child) => { + sanitize(child as Element); + }); + }; + sanitize(element); +}; - const defs = this.ensureSvgDefs(svgElement); - const baseId = this.sanitizeSvgId(`sd-line-${block.id}`); +const getEffectExtentMetrics = ( + block: VectorShapeDrawingWithEffects, + geometry?: DrawingGeometry, +): { + offsetX: number; + offsetY: number; + innerWidth: number; + innerHeight: number; +} => { + const left = block.effectExtent?.left ?? 0; + const top = block.effectExtent?.top ?? 0; + const right = block.effectExtent?.right ?? 0; + const bottom = block.effectExtent?.bottom ?? 0; + const sourceGeometry = geometry ?? block.geometry; + const width = sourceGeometry.width ?? 0; + const height = sourceGeometry.height ?? 0; + const innerWidth = Math.max(0, width - left - right); + const innerHeight = Math.max(0, height - top - bottom); + return { offsetX: left, offsetY: top, innerWidth, innerHeight }; +}; - if (lineEnds.tail) { - const id = `${baseId}-tail`; - this.appendLineEndMarker(defs, id, lineEnds.tail, strokeColor, true, block.effectExtent ?? undefined); - target.setAttribute('marker-start', `url(#${id})`); - } +const applyLineEnds = ( + renderer: DrawingRenderContext, + svgElement: SVGElement, + block: VectorShapeDrawingWithEffects, +): void => { + const lineEnds = block.lineEnds; + if (!lineEnds) return; + if (block.strokeColor === null) return; + const strokeColor = typeof block.strokeColor === 'string' ? block.strokeColor : '#000000'; + const strokeWidth = block.strokeWidth ?? 1; + if (strokeWidth <= 0) return; + + const target = findLineEndTarget(svgElement); + if (!target) return; + + const defs = ensureSvgDefs(renderer, svgElement); + const baseId = sanitizeSvgId(`sd-line-${block.id}`); + + if (lineEnds.tail) { + const id = `${baseId}-tail`; + appendLineEndMarker(renderer, defs, id, lineEnds.tail, strokeColor, true, block.effectExtent ?? undefined); + target.setAttribute('marker-start', `url(#${id})`); + } - if (lineEnds.head) { - const id = `${baseId}-head`; - this.appendLineEndMarker(defs, id, lineEnds.head, strokeColor, false, block.effectExtent ?? undefined); - target.setAttribute('marker-end', `url(#${id})`); - } + if (lineEnds.head) { + const id = `${baseId}-head`; + appendLineEndMarker(renderer, defs, id, lineEnds.head, strokeColor, false, block.effectExtent ?? undefined); + target.setAttribute('marker-end', `url(#${id})`); } +}; - private findLineEndTarget(svgElement: SVGElement): SVGElement | null { - const line = svgElement.querySelector('line'); - if (line) return line as SVGElement; - const path = svgElement.querySelector('path'); - if (path) return path as SVGElement; - const polyline = svgElement.querySelector('polyline'); - return polyline as SVGElement | null; - } - - private ensureSvgDefs(svgElement: SVGElement): SVGDefsElement { - const existing = svgElement.querySelector('defs'); - if (existing) return existing as SVGDefsElement; - const defs = this.doc.createElementNS('http://www.w3.org/2000/svg', 'defs'); - svgElement.insertBefore(defs, svgElement.firstChild); - return defs; - } - - private appendLineEndMarker( - defs: SVGDefsElement, - id: string, - lineEnd: LineEnd, - strokeColor: string, - isStart: boolean, - effectExtent?: EffectExtent, - ): void { - if (defs.querySelector(`#${id}`)) return; - - const marker = this.doc.createElementNS('http://www.w3.org/2000/svg', 'marker'); - marker.setAttribute('id', id); - marker.setAttribute('viewBox', '0 0 10 10'); - marker.setAttribute('orient', 'auto'); - - const sizeScale = (value?: string): number => { - if (value === 'sm') return 0.75; - if (value === 'lg') return 1.25; - return 1; - }; - const effectMax = effectExtent - ? Math.max(effectExtent.left ?? 0, effectExtent.right ?? 0, effectExtent.top ?? 0, effectExtent.bottom ?? 0) - : 0; - const useEffectExtent = Number.isFinite(effectMax) && effectMax > 0; - const markerWidth = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.length); - const markerHeight = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.width); - marker.setAttribute('markerUnits', useEffectExtent ? 'userSpaceOnUse' : 'strokeWidth'); - marker.setAttribute('markerWidth', markerWidth.toString()); - marker.setAttribute('markerHeight', markerHeight.toString()); - marker.setAttribute('refX', isStart ? '0' : '10'); - marker.setAttribute('refY', '5'); - - const shape = this.createLineEndShape(lineEnd.type ?? 'triangle', strokeColor, isStart); - marker.appendChild(shape); - defs.appendChild(marker); - } - - private createLineEndShape(type: string, strokeColor: string, isStart: boolean): SVGElement { - const normalized = type.toLowerCase(); - if (normalized === 'diamond') { - const path = this.doc.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M 0 5 L 5 0 L 10 5 L 5 10 Z'); - path.setAttribute('fill', strokeColor); - path.setAttribute('stroke', 'none'); - return path; - } - if (normalized === 'oval') { - const circle = this.doc.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx', '5'); - circle.setAttribute('cy', '5'); - circle.setAttribute('r', '5'); - circle.setAttribute('fill', strokeColor); - circle.setAttribute('stroke', 'none'); - return circle; - } +const findLineEndTarget = (svgElement: SVGElement): SVGElement | null => { + const line = svgElement.querySelector('line'); + if (line) return line as SVGElement; + const path = svgElement.querySelector('path'); + if (path) return path as SVGElement; + const polyline = svgElement.querySelector('polyline'); + return polyline as SVGElement | null; +}; - const path = this.doc.createElementNS('http://www.w3.org/2000/svg', 'path'); - const d = isStart ? 'M 10 0 L 0 5 L 10 10 Z' : 'M 0 0 L 10 5 L 0 10 Z'; - path.setAttribute('d', d); +const ensureSvgDefs = (renderer: DrawingRenderContext, svgElement: SVGElement): SVGDefsElement => { + const existing = svgElement.querySelector('defs'); + if (existing) return existing as SVGDefsElement; + const defs = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'defs'); + svgElement.insertBefore(defs, svgElement.firstChild); + return defs; +}; + +const appendLineEndMarker = ( + renderer: DrawingRenderContext, + defs: SVGDefsElement, + id: string, + lineEnd: LineEnd, + strokeColor: string, + isStart: boolean, + effectExtent?: EffectExtent, +): void => { + if (defs.querySelector(`#${id}`)) return; + + const marker = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'marker'); + marker.setAttribute('id', id); + marker.setAttribute('viewBox', '0 0 10 10'); + marker.setAttribute('orient', 'auto'); + + const sizeScale = (value?: string): number => { + if (value === 'sm') return 0.75; + if (value === 'lg') return 1.25; + return 1; + }; + const effectMax = effectExtent + ? Math.max(effectExtent.left ?? 0, effectExtent.right ?? 0, effectExtent.top ?? 0, effectExtent.bottom ?? 0) + : 0; + const useEffectExtent = Number.isFinite(effectMax) && effectMax > 0; + const markerWidth = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.length); + const markerHeight = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.width); + marker.setAttribute('markerUnits', useEffectExtent ? 'userSpaceOnUse' : 'strokeWidth'); + marker.setAttribute('markerWidth', markerWidth.toString()); + marker.setAttribute('markerHeight', markerHeight.toString()); + marker.setAttribute('refX', isStart ? '0' : '10'); + marker.setAttribute('refY', '5'); + + const shape = createLineEndShape(renderer, lineEnd.type ?? 'triangle', strokeColor, isStart); + marker.appendChild(shape); + defs.appendChild(marker); +}; + +const createLineEndShape = ( + renderer: DrawingRenderContext, + type: string, + strokeColor: string, + isStart: boolean, +): SVGElement => { + const normalized = type.toLowerCase(); + if (normalized === 'diamond') { + const path = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M 0 5 L 5 0 L 10 5 L 5 10 Z'); path.setAttribute('fill', strokeColor); path.setAttribute('stroke', 'none'); return path; } + if (normalized === 'oval') { + const circle = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', '5'); + circle.setAttribute('cy', '5'); + circle.setAttribute('r', '5'); + circle.setAttribute('fill', strokeColor); + circle.setAttribute('stroke', 'none'); + return circle; + } + + const path = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'path'); + const d = isStart ? 'M 10 0 L 0 5 L 10 10 Z' : 'M 0 0 L 10 5 L 0 10 Z'; + path.setAttribute('d', d); + path.setAttribute('fill', strokeColor); + path.setAttribute('stroke', 'none'); + return path; +}; + +const sanitizeSvgId = (value: string): string => { + return value.replace(/[^a-zA-Z0-9_-]/g, ''); +}; + +const applyVectorShapeTransforms = (target: HTMLElement | SVGElement, geometry: DrawingGeometry): void => { + const transforms: string[] = []; + if (geometry.rotation) { + transforms.push(`rotate(${geometry.rotation}deg)`); + } + if (geometry.flipH) { + transforms.push('scaleX(-1)'); + } + if (geometry.flipV) { + transforms.push('scaleY(-1)'); + } + if (transforms.length > 0) { + target.style.transformOrigin = 'center'; + target.style.transform = transforms.join(' '); + } else { + target.style.removeProperty('transform'); + target.style.removeProperty('transform-origin'); + } +}; - private sanitizeSvgId(value: string): string { - return value.replace(/[^a-zA-Z0-9_-]/g, ''); +const createShapeGroupElement = ( + renderer: DrawingRenderContext, + block: ShapeGroupDrawing, + context?: FragmentRenderContext, +): HTMLElement => { + const groupEl = renderer.doc.createElement('div'); + groupEl.classList.add('superdoc-shape-group'); + groupEl.style.position = 'relative'; + groupEl.style.width = '100%'; + groupEl.style.height = '100%'; + + const groupTransform = block.groupTransform; + let contentContainer: HTMLElement = groupEl; + + const visibleWidth = groupTransform?.width ?? block.geometry.width ?? 0; + const visibleHeight = groupTransform?.height ?? block.geometry.height ?? 0; + + if (groupTransform) { + const inner = renderer.doc.createElement('div'); + inner.style.position = 'absolute'; + inner.style.left = '0'; + inner.style.top = '0'; + inner.style.width = `${Math.max(1, visibleWidth)}px`; + inner.style.height = `${Math.max(1, visibleHeight)}px`; + groupEl.appendChild(inner); + contentContainer = inner; } - private applyVectorShapeTransforms(target: HTMLElement | SVGElement, geometry: DrawingGeometry): void { + block.shapes.forEach((child) => { + const childContent = createGroupChildContent(renderer, child, context); + if (!childContent) return; + const attrs = (child as ShapeGroupChild).attrs ?? {}; + const wrapper = renderer.doc.createElement('div'); + wrapper.classList.add('superdoc-shape-group__child'); + wrapper.style.position = 'absolute'; + + 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 (geometry.rotation) { - transforms.push(`rotate(${geometry.rotation}deg)`); + if (attrs.rotation) { + transforms.push(`rotate(${attrs.rotation}deg)`); } - if (geometry.flipH) { + if (attrs.flipH) { transforms.push('scaleX(-1)'); } - if (geometry.flipV) { + if (attrs.flipV) { transforms.push('scaleY(-1)'); } if (transforms.length > 0) { - target.style.transformOrigin = 'center'; - target.style.transform = transforms.join(' '); - } else { - target.style.removeProperty('transform'); - target.style.removeProperty('transform-origin'); - } - } - - private createShapeGroupElement(block: ShapeGroupDrawing, context?: FragmentRenderContext): HTMLElement { - const groupEl = this.doc.createElement('div'); - groupEl.classList.add('superdoc-shape-group'); - groupEl.style.position = 'relative'; - groupEl.style.width = '100%'; - groupEl.style.height = '100%'; - - const groupTransform = block.groupTransform; - let contentContainer: HTMLElement = groupEl; - - const visibleWidth = groupTransform?.width ?? block.geometry.width ?? 0; - const visibleHeight = groupTransform?.height ?? block.geometry.height ?? 0; - - if (groupTransform) { - const inner = this.doc.createElement('div'); - inner.style.position = 'absolute'; - inner.style.left = '0'; - inner.style.top = '0'; - inner.style.width = `${Math.max(1, visibleWidth)}px`; - inner.style.height = `${Math.max(1, visibleHeight)}px`; - groupEl.appendChild(inner); - contentContainer = inner; + wrapper.style.transform = transforms.join(' '); } + childContent.style.width = '100%'; + childContent.style.height = '100%'; + wrapper.appendChild(childContent); + contentContainer.appendChild(wrapper); + }); - block.shapes.forEach((child) => { - const childContent = this.createGroupChildContent(child, 1, 1, context); - if (!childContent) return; - const attrs = (child as ShapeGroupChild).attrs ?? {}; - const wrapper = this.doc.createElement('div'); - wrapper.classList.add('superdoc-shape-group__child'); - wrapper.style.position = 'absolute'; - - 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) { - transforms.push(`rotate(${attrs.rotation}deg)`); - } - if (attrs.flipH) { - transforms.push('scaleX(-1)'); - } - if (attrs.flipV) { - transforms.push('scaleY(-1)'); - } - if (transforms.length > 0) { - wrapper.style.transform = transforms.join(' '); - } - childContent.style.width = '100%'; - childContent.style.height = '100%'; - wrapper.appendChild(childContent); - contentContainer.appendChild(wrapper); - }); + return groupEl; +}; - return groupEl; - } - - private createGroupChildContent( - child: ShapeGroupChild, - groupScaleX: number = 1, - groupScaleY: number = 1, - context?: FragmentRenderContext, - ): HTMLElement | null { - if (child.shapeType === 'vectorShape' && 'fillColor' in child.attrs) { - const attrs = child.attrs as PositionedDrawingGeometry & - VectorShapeStyle & { - kind?: string; - customGeometry?: CustomGeometryData; - shapeId?: string; - shapeName?: string; - textContent?: ShapeTextContent; - textAlign?: string; - lineEnds?: LineEnds; - }; - const childGeometry = { - width: attrs.width ?? 0, - height: attrs.height ?? 0, - rotation: attrs.rotation ?? 0, - flipH: attrs.flipH ?? false, - flipV: attrs.flipV ?? false, - }; - const vectorChild: VectorShapeDrawingWithEffects = { - drawingKind: 'vectorShape', - kind: 'drawing', - id: `${attrs.shapeId ?? child.shapeType}`, - geometry: childGeometry, - padding: undefined, - margin: undefined, - anchor: undefined, - wrap: undefined, - attrs: child.attrs, - drawingContentId: undefined, - drawingContent: undefined, - shapeKind: attrs.kind, - customGeometry: attrs.customGeometry, - fillColor: attrs.fillColor, - strokeColor: attrs.strokeColor, - strokeWidth: attrs.strokeWidth, - lineEnds: attrs.lineEnds, - textContent: attrs.textContent, - textAlign: attrs.textAlign, - textVerticalAlign: attrs.textVerticalAlign, - textInsets: attrs.textInsets, +const createGroupChildContent = ( + renderer: DrawingRenderContext, + child: ShapeGroupChild, + context?: FragmentRenderContext, +): HTMLElement | null => { + if (child.shapeType === 'vectorShape' && 'fillColor' in child.attrs) { + const attrs = child.attrs as PositionedDrawingGeometry & + VectorShapeStyle & { + kind?: string; + customGeometry?: CustomGeometryData; + shapeId?: string; + shapeName?: string; + textContent?: ShapeTextContent; + textAlign?: string; + lineEnds?: LineEnds; }; - return this.createVectorShapeElement(vectorChild, childGeometry, false, groupScaleX, groupScaleY, context); - } - if (child.shapeType === 'image' && 'src' in child.attrs) { - return createShapeGroupImageElement(this.doc, child); - } - return createDrawingPlaceholder(this.doc); + const childGeometry = { + width: attrs.width ?? 0, + height: attrs.height ?? 0, + rotation: attrs.rotation ?? 0, + flipH: attrs.flipH ?? false, + flipV: attrs.flipV ?? false, + }; + const vectorChild: VectorShapeDrawingWithEffects = { + drawingKind: 'vectorShape', + kind: 'drawing', + id: `${attrs.shapeId ?? child.shapeType}`, + geometry: childGeometry, + padding: undefined, + margin: undefined, + anchor: undefined, + wrap: undefined, + attrs: child.attrs, + drawingContentId: undefined, + drawingContent: undefined, + shapeKind: attrs.kind, + customGeometry: attrs.customGeometry, + fillColor: attrs.fillColor, + strokeColor: attrs.strokeColor, + strokeWidth: attrs.strokeWidth, + lineEnds: attrs.lineEnds, + textContent: attrs.textContent, + textAlign: attrs.textAlign, + textVerticalAlign: attrs.textVerticalAlign, + textInsets: attrs.textInsets, + }; + return createVectorShapeElement(renderer, vectorChild, childGeometry, false, context); } - - private createChartElement(block: ChartDrawing): HTMLElement { - return renderChartToElement(this.doc, block.chartData, block.geometry); + if (child.shapeType === 'image' && 'src' in child.attrs) { + return createShapeGroupImageElement(renderer.doc, child); } -} + return createDrawingPlaceholder(renderer.doc); +}; + +const createChartElement = (renderer: DrawingRenderContext, block: ChartDrawing): HTMLElement => { + return renderChartToElement(renderer.doc, block.chartData, block.geometry); +}; From 571faed45d829c9ae5838a45e6dea7c4cefe9b68 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 18:49:39 -0300 Subject: [PATCH 6/7] refactor(painters/dom): route table drawings through callback --- .../painters/dom/src/drawings/placeholder.ts | 12 ++++ .../dom/src/drawings/renderDrawingContent.ts | 17 ++--- .../dom/src/drawings/tableDrawingFrame.ts | 12 +--- .../dom/src/table/renderTableCell.test.ts | 66 +++++++++++++------ .../painters/dom/src/table/renderTableCell.ts | 18 +++-- 5 files changed, 79 insertions(+), 46 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/drawings/placeholder.ts diff --git a/packages/layout-engine/painters/dom/src/drawings/placeholder.ts b/packages/layout-engine/painters/dom/src/drawings/placeholder.ts new file mode 100644 index 0000000000..e3a9cc255e --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/placeholder.ts @@ -0,0 +1,12 @@ +export const createDrawingPlaceholder = (doc: Document): HTMLElement => { + const placeholder = doc.createElement('div'); + placeholder.classList.add('superdoc-drawing-placeholder'); + placeholder.style.width = '100%'; + placeholder.style.height = '100%'; + const stripePattern = + 'repeating-linear-gradient(45deg, rgba(15,23,42,0.1), rgba(15,23,42,0.1) 6px, rgba(15,23,42,0.2) 6px, rgba(15,23,42,0.2) 12px)'; + placeholder.style.background = stripePattern; + placeholder.style.backgroundImage = stripePattern; + placeholder.style.border = '1px dashed rgba(15, 23, 42, 0.3)'; + return placeholder; +}; diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts index 23d5fef4b4..4ea127e19e 100644 --- a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts @@ -22,6 +22,7 @@ import { import type { BuildImageHyperlinkAnchor } from '../images/types.js'; import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from '../svg-utils.js'; import type { FragmentRenderContext } from '../renderer.js'; +import { createDrawingPlaceholder } from './placeholder.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; const WORDART_LINE_FILL_RATIO = 0.9; @@ -58,19 +59,6 @@ export type RenderDrawingContentParams = { buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; }; -export const createDrawingPlaceholder = (doc: Document): HTMLElement => { - const placeholder = doc.createElement('div'); - placeholder.classList.add('superdoc-drawing-placeholder'); - placeholder.style.width = '100%'; - placeholder.style.height = '100%'; - const stripePattern = - 'repeating-linear-gradient(45deg, rgba(15,23,42,0.1), rgba(15,23,42,0.1) 6px, rgba(15,23,42,0.2) 6px, rgba(15,23,42,0.2) 12px)'; - placeholder.style.background = stripePattern; - placeholder.style.backgroundImage = stripePattern; - placeholder.style.border = '1px dashed rgba(15, 23, 42, 0.3)'; - return placeholder; -}; - export const renderDrawingContent = ({ doc, block, @@ -95,6 +83,9 @@ const renderDrawingBlock = ( clipContainer?: HTMLElement, ): HTMLElement => { if (block.drawingKind === 'image') { + if (!block.src) { + return createDrawingPlaceholder(renderer.doc); + } return createDrawingImageElement(renderer.doc, block, renderer.buildImageHyperlinkAnchor, clipContainer); } if (block.drawingKind === 'vectorShape') { diff --git a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts index edb142266d..288d0bd929 100644 --- a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts @@ -1,7 +1,5 @@ import type { DrawingBlock, SdtMetadata } from '@superdoc/contracts'; -import { createDrawingImageElement } from '../images/drawing-image.js'; -import type { BuildImageHyperlinkAnchor } from '../images/types.js'; -import { createDrawingPlaceholder } from './renderDrawingContent.js'; +import { createDrawingPlaceholder } from './placeholder.js'; export type RenderTableDrawingFrameParams = { doc: Document; @@ -14,7 +12,6 @@ export type RenderTableDrawingFrameParams = { zIndex?: number; flexShrink?: string; renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; - buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; }; @@ -29,7 +26,6 @@ export const renderTableDrawingFrame = ({ zIndex, flexShrink, renderDrawingContent, - buildImageHyperlinkAnchor, applySdtDataset, }: RenderTableDrawingFrameParams): HTMLElement => { const drawingWrapper = doc.createElement('div'); @@ -62,11 +58,7 @@ export const renderTableDrawingFrame = ({ drawingInner.style.overflow = 'hidden'; const drawingContent = - block.drawingKind === 'image' - ? block.src - ? createDrawingImageElement(doc, block, buildImageHyperlinkAnchor, drawingInner) - : createDrawingPlaceholder(doc) - : (renderDrawingContent?.(block, { clipContainer: drawingInner }) ?? createDrawingPlaceholder(doc)); + renderDrawingContent?.(block, { clipContainer: drawingInner }) ?? createDrawingPlaceholder(doc); drawingContent.style.width = '100%'; drawingContent.style.height = '100%'; drawingInner.appendChild(drawingContent); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index 09fa9b4c75..b96793df02 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { renderTableCell, getCellSegmentCount } from './renderTableCell.js'; import { getCellLines } from '@superdoc/layout-engine'; import type { @@ -682,7 +682,7 @@ describe('renderTableCell', () => { expect(drawingWrapper?.style.top).toBe('7px'); }); - it('renders image drawing blocks inside table cells without using a drawing callback', () => { + it('renders image drawing blocks inside table cells through the shared drawing renderer', () => { const para: ParagraphBlock = { kind: 'paragraph', id: 'para-drawing-image-anchor', @@ -731,17 +731,11 @@ describe('renderTableCell', () => { blocks: [para, flowingDrawing, anchoredDrawing], attrs: {}, }; - const renderDrawingContent = vi.fn(() => { - const placeholder = doc.createElement('div'); - placeholder.classList.add('unexpected-drawing-callback'); - return placeholder; - }); const { cellElement } = renderTableCell({ ...createBaseDeps(), cellMeasure, cell, - renderDrawingContent, }); const drawingImages = cellElement.querySelectorAll('img.superdoc-drawing-image'); @@ -756,7 +750,6 @@ describe('renderTableCell', () => { expect(anchor?.style.width).toBe('100%'); expect(anchor?.style.height).toBe('100%'); expect(anchor?.parentElement?.parentElement?.style.position).toBe('absolute'); - expect(renderDrawingContent).not.toHaveBeenCalled(); }); it('renders a placeholder for image drawing blocks without a source', () => { @@ -789,16 +782,53 @@ describe('renderTableCell', () => { ...createBaseDeps(), cellMeasure, cell: { id: 'cell-with-empty-drawing-image', blocks: [drawingWithoutSrc], attrs: {} }, + }); + + expect(cellElement.querySelector('img.superdoc-drawing-image')).toBeFalsy(); + expect(cellElement.querySelector('.superdoc-drawing-placeholder')).toBeTruthy(); + }); + + it('uses the drawing content callback for image drawing blocks inside table cells', () => { + const drawing: DrawingBlock = { + kind: 'drawing', + id: 'drawing-image-callback', + drawingKind: 'image', + src: 'data:image/png;base64,AAA', + } as DrawingBlock; + + let callbackCount = 0; + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [ + { + kind: 'drawing', + drawingKind: 'image', + width: 30, + height: 15, + scale: 1, + naturalWidth: 30, + naturalHeight: 15, + }, + ], + width: 100, + height: 30, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-with-callback-drawing-image', blocks: [drawing], attrs: {} }, renderDrawingContent: () => { - const el = doc.createElement('img'); - el.classList.add('unexpected-drawing-image'); + callbackCount += 1; + const el = doc.createElement('div'); + el.classList.add('callback-drawing-image'); return el; }, }); + expect(callbackCount).toBe(1); + expect(cellElement.querySelector('.callback-drawing-image')).toBeTruthy(); expect(cellElement.querySelector('img.superdoc-drawing-image')).toBeFalsy(); - expect(cellElement.querySelector('.unexpected-drawing-image')).toBeFalsy(); - expect(cellElement.querySelector('.superdoc-drawing-placeholder')).toBeTruthy(); }); it('pushes text away from wrapSquare anchored images in table cells', () => { @@ -3705,7 +3735,7 @@ describe('renderTableCell', () => { expect(vectorShapeEl.style.height).toBe('100%'); }); - it('should use placeholder fallback when callback is undefined', () => { + it('should use the shared drawing renderer when callback is undefined', () => { const shapeGroupBlock = { kind: 'drawing' as const, id: 'drawing-3', @@ -3742,14 +3772,12 @@ describe('renderTableCell', () => { // renderDrawingContent is undefined }); - // Should render placeholder with diagonal stripes pattern const drawingWrapper = cellElement.querySelector('.superdoc-table-drawing') as HTMLElement; expect(drawingWrapper).toBeTruthy(); - const placeholder = drawingWrapper.firstChild as HTMLElement; - expect(placeholder).toBeTruthy(); - expect(placeholder.classList.contains('superdoc-drawing-placeholder')).toBe(true); - expect(placeholder.style.border).toContain('dashed'); + const shapeGroup = drawingWrapper.firstChild as HTMLElement; + expect(shapeGroup).toBeTruthy(); + expect(shapeGroup.classList.contains('superdoc-shape-group')).toBe(true); }); it('should pass correct DrawingBlock parameter to callback', () => { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index ba28460d8e..4be5072ce4 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -33,6 +33,7 @@ import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; import { renderTableDrawingFrame } from '../drawings/tableDrawingFrame.js'; +import { renderDrawingContent as renderSharedDrawingContent } from '../drawings/renderDrawingContent.js'; type TableRowMeasure = TableMeasure['rows'][number]; type TableCellMeasure = TableRowMeasure['cells'][number]; @@ -694,6 +695,17 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen hyperlink: ImageHyperlink | undefined, display: 'block' | 'inline-block', ): HTMLElement => buildImageHyperlinkAnchor(doc, imageEl, hyperlink, display); + const renderTableCellDrawingContent = + renderDrawingContent ?? + ((block: DrawingBlock, options?: { clipContainer?: HTMLElement }): HTMLElement => + renderSharedDrawingContent({ + doc, + block, + geometry: 'geometry' in block ? block.geometry : undefined, + context, + clipContainer: options?.clipContainer, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + })); // RTL: swap left↔right cell margins (ECMA-376 Part 4 §14.3.3–14.3.4, §14.3.7–14.3.8) const paddingLeft = isRtl ? (padding.right ?? 4) : (padding.left ?? 4); @@ -899,8 +911,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen height: blockMeasure.height, position: 'relative', flexShrink: '0', - renderDrawingContent, - buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + renderDrawingContent: renderTableCellDrawingContent, applySdtDataset, }); content.appendChild(drawingWrapper); @@ -1073,8 +1084,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen left, top, zIndex, - renderDrawingContent, - buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + renderDrawingContent: renderTableCellDrawingContent, applySdtDataset, }); content.appendChild(drawingWrapper); From 0e2b687e40576230c76532bdc9bb8d31dc4026aa Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 18:54:37 -0300 Subject: [PATCH 7/7] refactor(painters/dom): extract drawing fragment renderer Move renderDrawingFragment and isHeaderWordArtWatermark from DomPainter into a standalone drawings/renderDrawingFragment.ts module, drop the redundant renderDrawingContent wrapper method, and pass the painter's frame/anchor helpers in via parameters. --- .../dom/src/drawings/renderDrawingFragment.ts | 113 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 94 ++------------- 2 files changed, 125 insertions(+), 82 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts new file mode 100644 index 0000000000..26a5e943a4 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts @@ -0,0 +1,113 @@ +import type { DrawingBlock, DrawingFragment, ResolvedDrawingItem } from '@superdoc/contracts'; +import type { FragmentRenderContext } from '../renderer.js'; +import { CLASS_NAMES, fragmentStyles } from '../styles.js'; +import { applyStyles } from '../utils/apply-styles.js'; +import type { BuildImageHyperlinkAnchor } from '../images/types.js'; +import { renderDrawingContent } from './renderDrawingContent.js'; + +type RenderDrawingFragmentOptions = { + doc: Document | null; + fragment: DrawingFragment; + context: FragmentRenderContext; + resolvedItem?: ResolvedDrawingItem; + applyResolvedFragmentFrame: ( + el: HTMLElement, + item: ResolvedDrawingItem, + fragment: DrawingFragment, + section?: 'body' | 'header' | 'footer', + ) => void; + applyFragmentFrame: (el: HTMLElement, fragment: DrawingFragment, section?: 'body' | 'header' | 'footer') => void; + applyFragmentWrapperZIndex: (el: HTMLElement, fragment: DrawingFragment) => void; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; + createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; +}; + +export const renderDrawingFragment = ({ + doc, + fragment, + context, + resolvedItem, + applyResolvedFragmentFrame, + applyFragmentFrame, + applyFragmentWrapperZIndex, + buildImageHyperlinkAnchor, + createErrorPlaceholder, +}: RenderDrawingFragmentOptions): HTMLElement => { + try { + if (resolvedItem?.block?.kind !== 'drawing') { + throw new Error(`DomPainter: missing resolved drawing block for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as DrawingBlock; + + if (!doc) { + throw new Error('DomPainter: document is not available'); + } + + const fragmentEl = doc.createElement('div'); + fragmentEl.classList.add(CLASS_NAMES.fragment, 'superdoc-drawing-fragment'); + applyStyles(fragmentEl, fragmentStyles); + if (resolvedItem) { + applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); + } else { + applyFragmentFrame(fragmentEl, fragment, context.section); + fragmentEl.style.height = `${fragment.height}px`; + applyFragmentWrapperZIndex(fragmentEl, fragment); + } + fragmentEl.style.position = 'absolute'; + fragmentEl.style.overflow = 'hidden'; + + const innerWrapper = doc.createElement('div'); + innerWrapper.classList.add('superdoc-drawing-inner'); + innerWrapper.style.position = 'absolute'; + innerWrapper.style.left = '50%'; + innerWrapper.style.top = '50%'; + innerWrapper.style.width = `${fragment.geometry.width}px`; + innerWrapper.style.height = `${fragment.geometry.height}px`; + innerWrapper.style.transformOrigin = 'center'; + + const scale = fragment.scale ?? 1; + const transforms: string[] = ['translate(-50%, -50%)']; + transforms.push(`rotate(${fragment.geometry.rotation ?? 0}deg)`); + transforms.push(`scaleX(${fragment.geometry.flipH ? -1 : 1})`); + transforms.push(`scaleY(${fragment.geometry.flipV ? -1 : 1})`); + transforms.push(`scale(${scale})`); + innerWrapper.style.transform = transforms.join(' '); + + innerWrapper.appendChild( + renderDrawingContent({ + doc, + block, + geometry: fragment.geometry, + context, + buildImageHyperlinkAnchor, + }), + ); + fragmentEl.appendChild(innerWrapper); + + return fragmentEl; + } catch (error) { + console.error('[DomPainter] Drawing fragment rendering failed:', { fragment, error }); + return createErrorPlaceholder(fragment.blockId, error); + } +}; + +export const isHeaderWordArtWatermark = (block: DrawingBlock | undefined): boolean => { + if (!block || block.kind !== 'drawing' || block.drawingKind !== 'vectorShape') { + return false; + } + + const attrs = (block.attrs as Record | undefined) ?? {}; + const hasTextContent = Array.isArray(block.textContent?.parts) && block.textContent.parts.length > 0; + + return ( + attrs.isWordArt === true && + attrs.isTextBox === true && + hasTextContent && + block.anchor?.isAnchored === true && + block.anchor.hRelativeFrom === 'page' && + block.anchor.alignH === 'center' && + block.anchor.vRelativeFrom === 'page' && + block.anchor.alignV === 'center' && + block.wrap?.type === 'None' + ); +}; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 8ef2a6a23c..55511474ff 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -70,6 +70,10 @@ import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './ import { applyStyles } from './utils/apply-styles.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; import { renderDrawingContent as renderSharedDrawingContent } from './drawings/renderDrawingContent.js'; +import { + isHeaderWordArtWatermark, + renderDrawingFragment as renderDrawingFragmentElement, +} from './drawings/renderDrawingFragment.js'; export type { PaintSnapshotStructuredContentBlockEntity, @@ -2531,69 +2535,16 @@ export class DomPainter { context: FragmentRenderContext, resolvedItem?: ResolvedDrawingItem, ): HTMLElement { - try { - // Pre-extracted block from the resolved item. - if (resolvedItem?.block?.kind !== 'drawing') { - throw new Error(`DomPainter: missing resolved drawing block for fragment ${fragment.blockId}`); - } - const block = resolvedItem.block as DrawingBlock; - if (!this.doc) { - throw new Error('DomPainter: document is not available'); - } - const fragmentEl = this.doc.createElement('div'); - fragmentEl.classList.add(CLASS_NAMES.fragment, 'superdoc-drawing-fragment'); - applyStyles(fragmentEl, fragmentStyles); - if (resolvedItem) { - this.applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); - } else { - this.applyFragmentFrame(fragmentEl, fragment, context.section); - fragmentEl.style.height = `${fragment.height}px`; - this.applyFragmentWrapperZIndex(fragmentEl, fragment); - } - fragmentEl.style.position = 'absolute'; - fragmentEl.style.overflow = 'hidden'; - - const innerWrapper = this.doc.createElement('div'); - innerWrapper.classList.add('superdoc-drawing-inner'); - innerWrapper.style.position = 'absolute'; - innerWrapper.style.left = '50%'; - innerWrapper.style.top = '50%'; - innerWrapper.style.width = `${fragment.geometry.width}px`; - innerWrapper.style.height = `${fragment.geometry.height}px`; - innerWrapper.style.transformOrigin = 'center'; - - const scale = fragment.scale ?? 1; - const transforms: string[] = ['translate(-50%, -50%)']; - transforms.push(`rotate(${fragment.geometry.rotation ?? 0}deg)`); - transforms.push(`scaleX(${fragment.geometry.flipH ? -1 : 1})`); - transforms.push(`scaleY(${fragment.geometry.flipV ? -1 : 1})`); - transforms.push(`scale(${scale})`); - innerWrapper.style.transform = transforms.join(' '); - - innerWrapper.appendChild(this.renderDrawingContent(block, fragment, context)); - fragmentEl.appendChild(innerWrapper); - - return fragmentEl; - } catch (error) { - console.error('[DomPainter] Drawing fragment rendering failed:', { fragment, error }); - return this.createErrorPlaceholder(fragment.blockId, error); - } - } - - private renderDrawingContent( - block: DrawingBlock, - fragment: DrawingFragment, - context?: FragmentRenderContext, - ): HTMLElement { - if (!this.doc) { - throw new Error('DomPainter: document is not available'); - } - return renderSharedDrawingContent({ + return renderDrawingFragmentElement({ doc: this.doc, - block, - geometry: fragment.geometry, + fragment, context, + resolvedItem, + applyResolvedFragmentFrame: this.applyResolvedFragmentFrame.bind(this), + applyFragmentFrame: this.applyFragmentFrame.bind(this), + applyFragmentWrapperZIndex: this.applyFragmentWrapperZIndex.bind(this), buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), + createErrorPlaceholder: this.createErrorPlaceholder.bind(this), }); } @@ -2883,28 +2834,7 @@ export class DomPainter { return true; } - return section === 'header' && fragment.kind === 'drawing' && this.isHeaderWordArtWatermark(resolvedItem?.block); - } - - private isHeaderWordArtWatermark(block: DrawingBlock | undefined): boolean { - if (!block || block.kind !== 'drawing' || block.drawingKind !== 'vectorShape') { - return false; - } - - const attrs = (block.attrs as Record | undefined) ?? {}; - const hasTextContent = Array.isArray(block.textContent?.parts) && block.textContent.parts.length > 0; - - return ( - attrs.isWordArt === true && - attrs.isTextBox === true && - hasTextContent && - block.anchor?.isAnchored === true && - block.anchor.hRelativeFrom === 'page' && - block.anchor.alignH === 'center' && - block.anchor.vRelativeFrom === 'page' && - block.anchor.alignV === 'center' && - block.wrap?.type === 'None' - ); + return section === 'header' && fragment.kind === 'drawing' && isHeaderWordArtWatermark(resolvedItem?.block); } /**