From 372ab2419bc3a407ce0cfed9d4097e6e440aeea9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 16:12:10 -0300 Subject: [PATCH 1/3] refactor(painter-dom): unify textbox text rendering across shapes Extract WordArt and fallback shape-text rendering from renderDrawingContent.ts and svg-utils.ts into a shared textbox/renderTextboxContent module so vector shapes, shape groups, and table-cell shapes go through one code path. Adds tests covering the shared renderer from both the drawing and table-cell entry points. --- .../src/drawings/renderDrawingContent.test.ts | 34 ++ .../dom/src/drawings/renderDrawingContent.ts | 290 +---------------- .../painters/dom/src/svg-utils.ts | 90 +----- .../dom/src/table/renderTableCell.test.ts | 49 +++ .../src/textbox/renderTextboxContent.test.ts | 167 ++++++++++ .../dom/src/textbox/renderTextboxContent.ts | 299 ++++++++++++++++++ 6 files changed, 561 insertions(+), 368 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.test.ts create mode 100644 packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.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 index 2bd7fe6dcb..5a44268dd4 100644 --- a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts @@ -29,6 +29,40 @@ describe('renderDrawingContent', () => { expect(el.querySelector('svg')).toBeTruthy(); }); + it('renders vector shape text through the textbox renderer', () => { + const doc = createDoc(); + const block: DrawingBlock = { + kind: 'drawing', + id: 'shape-text-1', + drawingKind: 'vectorShape', + geometry: { width: 120, height: 60 }, + shapeKind: 'rect', + fillColor: '#ffffff', + strokeColor: '#000000', + textAlign: 'left', + textVerticalAlign: 'center', + textInsets: { top: 2, right: 4, bottom: 6, left: 8 }, + textContent: { + parts: [{ text: 'Shape text', formatting: { bold: true } }], + }, + }; + + const el = renderDrawingContent({ + doc, + block, + geometry: block.geometry, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + + const textOverlay = el.querySelector('div[style*="display: flex"]') as HTMLElement | null; + const span = textOverlay?.querySelector('span') as HTMLSpanElement | null; + expect(textOverlay).toBeTruthy(); + expect(textOverlay?.style.justifyContent).toBe('center'); + expect(textOverlay?.style.padding).toBe('2px 4px 6px 8px'); + expect(span?.textContent).toBe('Shape text'); + expect(span?.style.fontWeight).toBe('bold'); + }); + it('renders shape groups and charts through the shared drawing content path', () => { const doc = createDoc(); const shapeGroup: DrawingBlock = { diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts index 4ea127e19e..c78ce73dbe 100644 --- a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts @@ -14,19 +14,13 @@ import type { } 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 { createDrawingImageElement, createShapeGroupImageElement } from '../images/drawing-image.js'; import type { BuildImageHyperlinkAnchor } from '../images/types.js'; -import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from '../svg-utils.js'; +import { applyAlphaToSVG, applyGradientToSVG } from '../svg-utils.js'; import type { FragmentRenderContext } from '../renderer.js'; +import { hasShapeTextContent, renderTextboxContent } from '../textbox/renderTextboxContent.js'; import { createDrawingPlaceholder } from './placeholder.js'; -const SVG_NS = 'http://www.w3.org/2000/svg'; -const WORDART_LINE_FILL_RATIO = 0.9; - type LineEnd = { type?: string; width?: string; @@ -196,10 +190,6 @@ const applyFallbackShapeStyle = (container: HTMLElement, block: VectorShapeDrawi } }; -const hasShapeTextContent = (textContent?: ShapeTextContent): textContent is ShapeTextContent => { - return Array.isArray(textContent?.parts) && textContent.parts.length > 0; -}; - const createShapeTextElement = ( renderer: DrawingRenderContext, block: VectorShapeDrawing, @@ -212,275 +202,17 @@ const createShapeTextElement = ( return renderer.doc.createElement('div'); } - if (shouldUseWordArtTextRenderer(block)) { - return createWordArtTextElement( - renderer, - textContent, - block.textAlign ?? 'center', - block.textInsets, - width, - height, - context, - ); - } - - return createFallbackTextElement( - renderer, + return renderTextboxContent({ + doc: renderer.doc, textContent, - block.textAlign ?? 'center', - block.textVerticalAlign, - block.textInsets, + textAlign: block.textAlign ?? 'center', + textVerticalAlign: block.textVerticalAlign, + textInsets: block.textInsets, + isWordArt: block.attrs?.isWordArt === true, + width, + height, 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 = 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); - }); - - svg.appendChild(textEl); }); - - return svg; -}; - -const buildWordArtLines = ( - textContent: ShapeTextContent, - context?: FragmentRenderContext, -): Array> => { - const lines: Array> = [[]]; - - textContent.parts.forEach((part) => { - if (part.isLineBreak) { - lines.push([]); - return; - } - - 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 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'; -}; - -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; -}; - -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); - } - } - 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'; - } - - 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 = 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 { - const span = renderer.doc.createElement('span'); - span.textContent = 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; }; const tryCreatePresetSvg = ( diff --git a/packages/layout-engine/painters/dom/src/svg-utils.ts b/packages/layout-engine/painters/dom/src/svg-utils.ts index 936a3627ca..d722525f8a 100644 --- a/packages/layout-engine/painters/dom/src/svg-utils.ts +++ b/packages/layout-engine/painters/dom/src/svg-utils.ts @@ -3,7 +3,7 @@ * Ported from super-editor to TypeScript for reuse across layout-engine */ -import type { GradientFill, GradientStop, SolidFillWithAlpha, ShapeTextContent, TextPart } from '@superdoc/contracts'; +import type { GradientFill, GradientStop, SolidFillWithAlpha } from '@superdoc/contracts'; /** * Validates and sanitizes a hex color string to prevent XSS attacks. @@ -141,94 +141,6 @@ export function createGradient( return gradient; } -/** - * Creates an SVG foreignObject with formatted text content - */ -export function createTextElement( - textContent: ShapeTextContent, - textAlign: string, - width: number, - height: number, -): SVGForeignObjectElement { - // Use foreignObject with HTML for proper text wrapping - const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); - foreignObject.setAttribute('x', '0'); - foreignObject.setAttribute('y', '0'); - foreignObject.setAttribute('width', width.toString()); - foreignObject.setAttribute('height', height.toString()); - - // Create HTML div for text content - const div = document.createElement('div'); - div.style.width = '100%'; - div.style.height = '100%'; - div.style.display = 'flex'; - div.style.flexDirection = 'column'; - div.style.justifyContent = 'center'; // Vertically center the text block - div.style.padding = '10px'; - div.style.boxSizing = 'border-box'; - div.style.wordWrap = 'break-word'; - div.style.overflowWrap = 'break-word'; - - // Set text alignment (horizontal alignment for each paragraph) - if (textAlign === 'center') { - div.style.textAlign = 'center'; - } else if (textAlign === 'right' || textAlign === 'r') { - div.style.textAlign = 'right'; - } else { - div.style.textAlign = 'left'; - } - - // Create paragraphs by splitting on line breaks - let currentParagraph = document.createElement('div'); - - // Add text content with formatting - textContent.parts.forEach((part: TextPart) => { - if (part.isLineBreak) { - // Finish current paragraph and start a new one - div.appendChild(currentParagraph); - currentParagraph = document.createElement('div'); - // Empty paragraphs create extra spacing (blank line) - if (part.isEmptyParagraph) { - currentParagraph.style.minHeight = '1em'; - } - } else { - const span = document.createElement('span'); - span.textContent = part.text; - - // Apply formatting - 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`; - } - } - - currentParagraph.appendChild(span); - } - }); - - // Add the final paragraph - div.appendChild(currentParagraph); - foreignObject.appendChild(div); - - return foreignObject; -} - /** * Applies a gradient to all filled elements in an SVG. * 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 9fc20d51aa..1c6a886489 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -3780,6 +3780,55 @@ describe('renderTableCell', () => { expect(shapeGroup.classList.contains('superdoc-shape-group')).toBe(true); }); + it('uses the shared textbox renderer for vector shape text when callback is undefined', () => { + const vectorShapeBlock = { + kind: 'drawing' as const, + id: 'drawing-table-textbox', + drawingKind: 'vectorShape' as const, + geometry: { width: 120, height: 60, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect' as const, + fillColor: '#ffffff', + strokeColor: '#000000', + textAlign: 'center', + textVerticalAlign: 'center' as const, + textContent: { + parts: [{ text: 'Page ' }, { text: '', fieldType: 'PAGE' as const, formatting: { bold: true } }], + }, + }; + + const drawingMeasure = { + kind: 'drawing' as const, + width: 120, + height: 60, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + context: { section: 'body', pageIndex: 0, pageNumber: 5, pageNumberText: 'v', totalPages: 8 }, + cellMeasure: { + blocks: [drawingMeasure], + width: 140, + height: 80, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-shared-textbox', + blocks: [vectorShapeBlock], + attrs: {}, + }, + }); + + const shape = cellElement.querySelector('.superdoc-vector-shape') as HTMLElement | null; + const textOverlay = shape?.querySelector('div[style*="display: flex"]') as HTMLElement | null; + const boldPage = Array.from(textOverlay?.querySelectorAll('span') ?? []).find((span) => span.textContent === 'v'); + expect(shape).toBeTruthy(); + expect(textOverlay?.textContent).toBe('Page v'); + expect(textOverlay?.style.justifyContent).toBe('center'); + expect(boldPage?.style.fontWeight).toBe('bold'); + }); + it('should pass correct DrawingBlock parameter to callback', () => { const shapeGroupBlock = { kind: 'drawing' as const, diff --git a/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.test.ts b/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.test.ts new file mode 100644 index 0000000000..ea56feb885 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import type { FragmentRenderContext } from '../renderer.js'; +import { hasShapeTextContent, renderTextboxContent } from './renderTextboxContent.js'; + +describe('renderTextboxContent', () => { + const createDoc = (): Document => document.implementation.createHTMLDocument('textbox-content'); + + it('renders formatted fallback textbox text with alignment, vertical alignment, and insets', () => { + const doc = createDoc(); + + const el = renderTextboxContent({ + doc, + width: 160, + height: 80, + textAlign: 'right', + textVerticalAlign: 'bottom', + textInsets: { top: 1, right: 2, bottom: 3, left: 4 }, + textContent: { + parts: [ + { + text: 'Hello', + formatting: { + bold: true, + italic: true, + fontFamily: 'Arial', + fontSize: 14, + color: '336699', + letterSpacing: 1.5, + }, + }, + ], + }, + }) as HTMLElement; + + const span = el.querySelector('span') as HTMLSpanElement | null; + expect(el.style.display).toBe('flex'); + expect(el.style.justifyContent).toBe('flex-end'); + expect(el.style.padding).toBe('1px 2px 3px 4px'); + expect(el.style.textAlign).toBe('right'); + expect(span?.textContent).toBe('Hello'); + expect(span?.style.fontWeight).toBe('bold'); + expect(span?.style.fontStyle).toBe('italic'); + expect(span?.style.fontFamily).toBe('Arial'); + expect(span?.style.fontSize).toBe('14px'); + expect(['#336699', 'rgb(51, 102, 153)']).toContain(span?.style.color); + expect(span?.style.letterSpacing).toBe('1.5px'); + }); + + it('preserves line breaks and empty paragraph spacing in fallback textbox text', () => { + const doc = createDoc(); + + const el = renderTextboxContent({ + doc, + width: 160, + height: 80, + textContent: { + parts: [{ text: 'First' }, { text: '', isLineBreak: true, isEmptyParagraph: true }, { text: 'Second' }], + }, + }) as HTMLElement; + + const paragraphs = Array.from(el.children) as HTMLElement[]; + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]?.textContent).toBe('First'); + expect(paragraphs[1]?.textContent).toBe('Second'); + expect(paragraphs[1]?.style.minHeight).toBe('1em'); + }); + + it('resolves PAGE and NUMPAGES field tokens from the fragment context', () => { + const doc = createDoc(); + const context: FragmentRenderContext = { + pageNumber: 3, + pageNumberText: 'iii', + totalPages: 9, + pageIndex: 2, + section: 'body', + }; + + const el = renderTextboxContent({ + doc, + width: 160, + height: 80, + context, + textContent: { + parts: [{ text: '', fieldType: 'PAGE' }, { text: ' of ' }, { text: '', fieldType: 'NUMPAGES' }], + }, + }) as HTMLElement; + + expect(el.textContent).toBe('iii of 9'); + }); + + it('renders inline image parts inside fallback textbox text', () => { + const doc = createDoc(); + + const el = renderTextboxContent({ + doc, + width: 160, + height: 80, + textContent: { + parts: [ + { text: 'Before ' }, + { + text: '', + kind: 'image', + src: 'data:image/png;base64,AAA', + alt: 'Inline picture', + width: 32, + height: 18, + }, + ], + }, + }) as HTMLElement; + + const img = el.querySelector('img') as HTMLImageElement | null; + expect(img?.src).toBe('data:image/png;base64,AAA'); + expect(img?.alt).toBe('Inline picture'); + expect(img?.style.width).toBe('32px'); + expect(img?.style.height).toBe('18px'); + expect(img?.style.display).toBe('inline-block'); + }); + + it('renders WordArt text with SVG sizing, formatting, and field resolution', () => { + const doc = createDoc(); + + const svg = renderTextboxContent({ + doc, + width: 200, + height: 80, + isWordArt: true, + textAlign: 'center', + textInsets: { top: 5, right: 20, bottom: 5, left: 10 }, + context: { pageNumber: 7, totalPages: 10, pageIndex: 6, section: 'body' }, + textContent: { + parts: [ + { + text: 'Page ', + formatting: { fontFamily: 'Arial', color: 'C0C0C0', letterSpacing: 2 }, + }, + { + text: '', + fieldType: 'PAGE', + formatting: { bold: true, italic: true }, + }, + ], + }, + }) as SVGSVGElement; + + const text = svg.querySelector('text') as SVGTextElement | null; + const tspans = svg.querySelectorAll('tspan'); + expect(svg.classList.contains('superdoc-wordart-text')).toBe(true); + expect(svg.getAttribute('viewBox')).toBe('0 0 200 80'); + expect(text?.textContent).toBe('Page 7'); + expect(text?.getAttribute('x')).toBe('95'); + expect(text?.getAttribute('textLength')).toBe('170'); + expect(text?.getAttribute('lengthAdjust')).toBe('spacingAndGlyphs'); + expect(tspans[0]?.getAttribute('font-family')).toBe('Arial'); + expect(tspans[0]?.getAttribute('fill')).toBe('#C0C0C0'); + expect(tspans[0]?.getAttribute('letter-spacing')).toBe('2'); + expect(tspans[1]?.getAttribute('font-weight')).toBe('bold'); + expect(tspans[1]?.getAttribute('font-style')).toBe('italic'); + }); + + it('reports only non-empty shape text content as renderable', () => { + expect(hasShapeTextContent(undefined)).toBe(false); + expect(hasShapeTextContent({ parts: [] })).toBe(false); + expect(hasShapeTextContent({ parts: [{ text: 'value' }] })).toBe(true); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.ts b/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.ts new file mode 100644 index 0000000000..df45d18973 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.ts @@ -0,0 +1,299 @@ +import type { ShapeTextContent } from '@superdoc/contracts'; +import { createShapeTextImageElement } from '../images/drawing-image.js'; +import type { FragmentRenderContext } from '../renderer.js'; +import { validateHexColor } from '../svg-utils.js'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const WORDART_LINE_FILL_RATIO = 0.9; + +type TextInsets = { + top: number; + right: number; + bottom: number; + left: number; +}; + +export type RenderTextboxContentParams = { + doc: Document; + textContent?: ShapeTextContent; + textAlign?: string; + textVerticalAlign?: 'top' | 'center' | 'bottom'; + textInsets?: TextInsets; + isWordArt?: boolean; + width: number; + height: number; + context?: FragmentRenderContext; +}; + +export const hasShapeTextContent = (textContent?: ShapeTextContent): textContent is ShapeTextContent => { + return Array.isArray(textContent?.parts) && textContent.parts.length > 0; +}; + +export const renderTextboxContent = ({ + doc, + textContent, + textAlign = 'center', + textVerticalAlign, + textInsets, + isWordArt = false, + width, + height, + context, +}: RenderTextboxContentParams): HTMLElement | SVGSVGElement => { + if (!hasShapeTextContent(textContent)) { + return doc.createElement('div'); + } + + if (isWordArt) { + return createWordArtTextElement(doc, textContent, textAlign, textInsets, width, height, context); + } + + return createFallbackTextElement(doc, textContent, textAlign, textVerticalAlign, textInsets, context); +}; + +const createWordArtTextElement = ( + doc: Document, + textContent: ShapeTextContent, + textAlign: string, + textInsets: TextInsets | undefined, + width: number, + height: number, + context?: FragmentRenderContext, +): SVGSVGElement => { + const svg = 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 = 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 = doc.createElementNS(SVG_NS, 'tspan'); + tspan.setAttribute('xml:space', 'preserve'); + tspan.textContent = part.text; + applyWordArtTextFormatting(tspan, part.formatting); + textEl.appendChild(tspan); + }); + + svg.appendChild(textEl); + }); + + return svg; +}; + +const buildWordArtLines = ( + textContent: ShapeTextContent, + context?: FragmentRenderContext, +): Array> => { + const lines: Array> = [[]]; + + textContent.parts.forEach((part) => { + if (part.isLineBreak) { + lines.push([]); + return; + } + + 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 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'; +}; + +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; +}; + +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); + } + } + if (formatting.letterSpacing != null) { + element.setAttribute('letter-spacing', String(formatting.letterSpacing)); + } +}; + +const createFallbackTextElement = ( + doc: Document, + textContent: ShapeTextContent, + textAlign: string, + textVerticalAlign?: 'top' | 'center' | 'bottom', + textInsets?: TextInsets, + context?: FragmentRenderContext, +): HTMLElement => { + const textDiv = 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 = createTextParagraph(doc); + + textContent.parts.forEach((part) => { + if (part.isLineBreak) { + textDiv.appendChild(currentParagraph); + currentParagraph = createTextParagraph(doc); + if (part.isEmptyParagraph) { + currentParagraph.style.minHeight = '1em'; + } + } else if (part.kind === 'image' && part.src) { + currentParagraph.appendChild(createShapeTextImageElement(doc, part)); + } else { + const span = doc.createElement('span'); + span.textContent = 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; +}; + +const createTextParagraph = (doc: Document): HTMLElement => { + const paragraph = doc.createElement('div'); + paragraph.style.width = '100%'; + paragraph.style.minWidth = '0'; + paragraph.style.whiteSpace = 'normal'; + return paragraph; +}; From cc9f268259dac402cdee8c133b9bc3ce8e6d9f7d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 16:27:35 -0300 Subject: [PATCH 2/3] refactor(painter-dom): move WordArt watermark detector to textbox module Relocate isHeaderWordArtWatermark out of renderDrawingFragment.ts into textbox/wordArtWatermark.ts as isWordArtTextboxWatermarkBlock, with direct tests for the predicate. The header-suppression logic in renderer.ts now imports from the new module. --- .../dom/src/drawings/renderDrawingFragment.ts | 21 ------ .../painters/dom/src/renderer.ts | 8 +-- .../dom/src/textbox/wordArtWatermark.test.ts | 65 +++++++++++++++++++ .../dom/src/textbox/wordArtWatermark.ts | 22 +++++++ 4 files changed, 90 insertions(+), 26 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/textbox/wordArtWatermark.test.ts create mode 100644 packages/layout-engine/painters/dom/src/textbox/wordArtWatermark.ts diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts index 26a5e943a4..ecef3183e1 100644 --- a/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts @@ -90,24 +90,3 @@ export const renderDrawingFragment = ({ 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 27079b1d20..f3f6ea5077 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -66,10 +66,8 @@ import { renderImageFragment as renderImageFragmentElement } from './images/imag import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; import { applyStyles } from './utils/apply-styles.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; -import { - isHeaderWordArtWatermark, - renderDrawingFragment as renderDrawingFragmentElement, -} from './drawings/renderDrawingFragment.js'; +import { renderDrawingFragment as renderDrawingFragmentElement } from './drawings/renderDrawingFragment.js'; +import { isWordArtTextboxWatermarkBlock } from './textbox/wordArtWatermark.js'; import { applyNoteStoryFrameAttributes } from './notes/frame.js'; import { isNonBodyStoryBlockId } from './notes/story.js'; @@ -2707,7 +2705,7 @@ export class DomPainter { return true; } - return section === 'header' && fragment.kind === 'drawing' && isHeaderWordArtWatermark(resolvedItem?.block); + return section === 'header' && fragment.kind === 'drawing' && isWordArtTextboxWatermarkBlock(resolvedItem?.block); } /** diff --git a/packages/layout-engine/painters/dom/src/textbox/wordArtWatermark.test.ts b/packages/layout-engine/painters/dom/src/textbox/wordArtWatermark.test.ts new file mode 100644 index 0000000000..a1b46168e5 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/textbox/wordArtWatermark.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import type { DrawingBlock } from '@superdoc/contracts'; +import { isWordArtTextboxWatermarkBlock } from './wordArtWatermark.js'; + +describe('isWordArtTextboxWatermarkBlock', () => { + const createWatermarkBlock = (overrides: Partial = {}): DrawingBlock => ({ + kind: 'drawing', + id: 'wordart-watermark', + drawingKind: 'vectorShape', + geometry: { width: 200, height: 60 }, + shapeKind: 'rect', + fillColor: null, + strokeColor: null, + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + alignH: 'center', + vRelativeFrom: 'page', + alignV: 'center', + }, + wrap: { type: 'None' }, + textContent: { + parts: [{ text: 'AUTE' }], + }, + attrs: { isWordArt: true, isTextBox: true }, + ...overrides, + }); + + it('detects centered anchored page-relative WordArt textboxes with no wrapping', () => { + expect(isWordArtTextboxWatermarkBlock(createWatermarkBlock())).toBe(true); + }); + + it('rejects blocks without the WordArt textbox shape requirements', () => { + expect(isWordArtTextboxWatermarkBlock(undefined)).toBe(false); + expect(isWordArtTextboxWatermarkBlock(createWatermarkBlock({ drawingKind: 'image' }))).toBe(false); + expect(isWordArtTextboxWatermarkBlock(createWatermarkBlock({ attrs: { isTextBox: true } }))).toBe(false); + expect(isWordArtTextboxWatermarkBlock(createWatermarkBlock({ attrs: { isWordArt: true } }))).toBe(false); + expect(isWordArtTextboxWatermarkBlock(createWatermarkBlock({ textContent: { parts: [] } }))).toBe(false); + }); + + it('rejects blocks that are not anchored page-centered no-wrap watermarks', () => { + expect(isWordArtTextboxWatermarkBlock(createWatermarkBlock({ anchor: { isAnchored: false } }))).toBe(false); + expect( + isWordArtTextboxWatermarkBlock( + createWatermarkBlock({ anchor: { isAnchored: true, hRelativeFrom: 'margin', alignH: 'center' } }), + ), + ).toBe(false); + expect( + isWordArtTextboxWatermarkBlock( + createWatermarkBlock({ anchor: { isAnchored: true, hRelativeFrom: 'page', alignH: 'left' } }), + ), + ).toBe(false); + expect( + isWordArtTextboxWatermarkBlock( + createWatermarkBlock({ anchor: { isAnchored: true, vRelativeFrom: 'margin', alignV: 'center' } }), + ), + ).toBe(false); + expect( + isWordArtTextboxWatermarkBlock( + createWatermarkBlock({ anchor: { isAnchored: true, vRelativeFrom: 'page', alignV: 'top' } }), + ), + ).toBe(false); + expect(isWordArtTextboxWatermarkBlock(createWatermarkBlock({ wrap: { type: 'Square' } }))).toBe(false); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/textbox/wordArtWatermark.ts b/packages/layout-engine/painters/dom/src/textbox/wordArtWatermark.ts new file mode 100644 index 0000000000..b6d4f0e96b --- /dev/null +++ b/packages/layout-engine/painters/dom/src/textbox/wordArtWatermark.ts @@ -0,0 +1,22 @@ +import type { DrawingBlock } from '@superdoc/contracts'; + +export const isWordArtTextboxWatermarkBlock = (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' + ); +}; From f7d378c7c85e08f9306d888ecf4e0c25c5d8651f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 19 May 2026 17:51:44 -0300 Subject: [PATCH 3/3] fix(painter-dom): always use shared image renderer for table drawings Route image drawing blocks through the shared renderer even when a renderDrawingContent callback is supplied, so table-specific image styling and hyperlink anchoring are preserved. The callback now only applies to non-image drawings (vector shapes, shape groups). --- .../dom/src/table/renderTableCell.test.ts | 10 +++-- .../painters/dom/src/table/renderTableCell.ts | 41 +++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) 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 1c6a886489..3e64adae36 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -788,7 +788,7 @@ describe('renderTableCell', () => { expect(cellElement.querySelector('.superdoc-drawing-placeholder')).toBeTruthy(); }); - it('uses the drawing content callback for image drawing blocks inside table cells', () => { + it('keeps image drawing blocks on the shared renderer when a drawing content callback is provided', () => { const drawing: DrawingBlock = { kind: 'drawing', id: 'drawing-image-callback', @@ -826,9 +826,11 @@ describe('renderTableCell', () => { }, }); - expect(callbackCount).toBe(1); - expect(cellElement.querySelector('.callback-drawing-image')).toBeTruthy(); - expect(cellElement.querySelector('img.superdoc-drawing-image')).toBeFalsy(); + expect(callbackCount).toBe(0); + expect(cellElement.querySelector('.callback-drawing-image')).toBeFalsy(); + const image = cellElement.querySelector('img.superdoc-drawing-image') as HTMLImageElement | null; + expect(image).toBeTruthy(); + expect(image?.src).toBe('data:image/png;base64,AAA'); }); it('pushes text away from wrapSquare anchored images in table cells', () => { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 4f0a70fc29..0c10e229be 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -379,11 +379,8 @@ type TableCellRenderDependencies = { options?: { inTableParagraph?: boolean; wrapperEl?: HTMLElement }, ) => void; /** - * Optional callback function to render drawing content (vectorShapes, shapeGroups). - * If provided, this callback is used to render DrawingBlocks with drawingKind of 'vectorShape' or 'shapeGroup'. - * The callback receives a DrawingBlock and must return an HTMLElement. - * 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. + * Optional callback function to render non-image drawing content. + * Image drawings always use the shared image renderer so table image styling and hyperlinks are preserved. */ renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; /** Rendering context */ @@ -464,7 +461,7 @@ export type TableCellRenderResult = { * useDefaultBorder: false, * renderLine, * renderDrawingContent: (block) => { - * // Custom drawing renderer for vectorShapes and shapeGroups + * // Custom renderer for non-image drawings * const el = document.createElement('div'); * // Render drawing content... * return el; @@ -509,17 +506,27 @@ 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, - })); + const renderSharedTableCellDrawingContent = ( + block: DrawingBlock, + options?: { clipContainer?: HTMLElement }, + ): HTMLElement => + renderSharedDrawingContent({ + doc, + block, + geometry: 'geometry' in block ? block.geometry : undefined, + context, + clipContainer: options?.clipContainer, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }); + const renderTableCellDrawingContent = ( + block: DrawingBlock, + options?: { clipContainer?: HTMLElement }, + ): HTMLElement => { + if (block.drawingKind === 'image' || !renderDrawingContent) { + return renderSharedTableCellDrawingContent(block, options); + } + return renderDrawingContent(block, options); + }; // 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);