Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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<Array<{ text: string; formatting?: ShapeTextContent['parts'][number]['formatting'] }>> => {
const lines: Array<Array<{ text: string; formatting?: ShapeTextContent['parts'][number]['formatting'] }>> = [[]];

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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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'
);
};
Loading
Loading