diff --git a/components/slide-renderer/components/element/ShapeElement/index.tsx b/components/slide-renderer/components/element/ShapeElement/index.tsx index 4227b9bbcb..90e69dc172 100644 --- a/components/slide-renderer/components/element/ShapeElement/index.tsx +++ b/components/slide-renderer/components/element/ShapeElement/index.tsx @@ -63,8 +63,23 @@ export function ShapeElement({ elementInfo, selectElement }: ShapeElementProps) defaultColor: '#000000', }; if (!elementInfo.text) return defaultText; - return elementInfo.text; + return { + ...elementInfo.text, + content: typeof elementInfo.text.content === 'string' ? elementInfo.text.content : '', + }; }, [elementInfo.text]); + const viewBoxWidth = + Array.isArray(elementInfo.viewBox) && + Number.isFinite(elementInfo.viewBox[0]) && + elementInfo.viewBox[0] > 0 + ? elementInfo.viewBox[0] + : elementInfo.width || 1; + const viewBoxHeight = + Array.isArray(elementInfo.viewBox) && + Number.isFinite(elementInfo.viewBox[1]) && + elementInfo.viewBox[1] > 0 + ? elementInfo.viewBox[1] + : elementInfo.height || 1; // Update text content const updateText = useCallback( @@ -84,7 +99,8 @@ export function ShapeElement({ elementInfo, selectElement }: ShapeElementProps) const checkEmptyText = useCallback(() => { if (!elementInfo.text) return; - const pureText = elementInfo.text.content.replace(/<[^>]+>/g, ''); + const content = typeof elementInfo.text.content === 'string' ? elementInfo.text.content : ''; + const pureText = content.replace(/<[^>]+>/g, ''); if (!pureText) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 'text' is specific to PPTShapeElement, not in keyof PPTElement union removeElementProps({ id: elementInfo.id, propName: 'text' as any }); @@ -144,8 +160,8 @@ export function ShapeElement({ elementInfo, selectElement }: ShapeElementProps) )} (Array.isArray(data) ? data : []), [data]); + const tableColWidths = useMemo(() => (Array.isArray(colWidths) ? colWidths : []), [colWidths]); + const safeWidth = Number.isFinite(width) && width > 0 ? width : 1; + const safeCellMinHeight = + Number.isFinite(cellMinHeight) && cellMinHeight >= 0 ? cellMinHeight : 40; - const hiddenCells = useMemo(() => getHiddenCells(data), [data]); + const hiddenCells = useMemo(() => getHiddenCells(tableData), [tableData]); const [subThemeDark, subThemeLight] = useMemo(() => { if (!theme) return ['', '']; @@ -42,8 +47,8 @@ export function StaticTable({ elementInfo }: StaticTableProps) { if (cellBackcolor) return cellBackcolor; if (!theme) return undefined; - const rowCount = data.length; - const colCount = data[0]?.length ?? 0; + const rowCount = tableData.length; + const colCount = tableData[0]?.length ?? 0; // Row header (first row) gets theme color if (theme.rowHeader && rowIdx === 0) return theme.color; @@ -66,7 +71,7 @@ export function StaticTable({ elementInfo }: StaticTableProps) { */ const getHeaderTextColor = (rowIdx: number): string | undefined => { if (!theme) return undefined; - const rowCount = data.length; + const rowCount = tableData.length; if (theme.rowHeader && rowIdx === 0) return '#fff'; if (theme.rowFooter && rowIdx === rowCount - 1) return '#fff'; return undefined; @@ -81,19 +86,22 @@ export function StaticTable({ elementInfo }: StaticTableProps) { }} > - {colWidths.map((w, i) => ( - + {tableColWidths.map((w, i) => ( + ))} - {data.map((row, rowIdx) => ( - - {row.map((cell, colIdx) => { + {tableData.map((row, rowIdx) => ( + + {(Array.isArray(row) ? row : []).map((cell, colIdx) => { if (hiddenCells.has(`${rowIdx}_${colIdx}`)) return null; + if (!cell) return null; const bgColor = getCellBg(rowIdx, colIdx, cell.style?.backcolor); const headerColor = getHeaderTextColor(rowIdx); const textStyle = getTextStyle(cell.style); + const colspan = Number.isFinite(cell.colspan) && cell.colspan > 0 ? cell.colspan : 1; + const rowspan = Number.isFinite(cell.rowspan) && cell.rowspan > 0 ? cell.rowspan : 1; // Header text color should be overridden only if cell doesn't have its own color if (headerColor && !cell.style?.color) { @@ -102,9 +110,9 @@ export function StaticTable({ elementInfo }: StaticTableProps) { return ( 1 ? cell.colspan : undefined} - rowSpan={cell.rowspan > 1 ? cell.rowspan : undefined} + key={cell.id || `${rowIdx}-${colIdx}`} + colSpan={colspan > 1 ? colspan : undefined} + rowSpan={rowspan > 1 ? rowspan : undefined} style={{ border: borderStyle, backgroundColor: bgColor, diff --git a/components/slide-renderer/components/element/TableElement/tableUtils.ts b/components/slide-renderer/components/element/TableElement/tableUtils.ts index 6bedbbca4b..377284e604 100644 --- a/components/slide-renderer/components/element/TableElement/tableUtils.ts +++ b/components/slide-renderer/components/element/TableElement/tableUtils.ts @@ -27,7 +27,8 @@ export function getTextStyle(style?: TableCellStyle): CSSProperties { /** * Format text: convert \n to
and spaces to   */ -export function formatText(text: string): string { +export function formatText(text: unknown): string { + if (typeof text !== 'string') return ''; return text.replace(/\n/g, '
').replace(/ /g, ' '); } @@ -39,16 +40,19 @@ export function getHiddenCells(data: TableCell[][]): Set { const hidden = new Set(); for (let rowIdx = 0; rowIdx < data.length; rowIdx++) { + const row = data[rowIdx]; + if (!Array.isArray(row)) continue; + let realColIdx = 0; - for (let colIdx = 0; colIdx < data[rowIdx].length; colIdx++) { + for (let colIdx = 0; colIdx < row.length; colIdx++) { // Skip positions already occupied by a previous merge while (hidden.has(`${rowIdx}_${realColIdx}`)) { realColIdx++; } - const cell = data[rowIdx][colIdx]; - const colspan = cell.colspan ?? 1; - const rowspan = cell.rowspan ?? 1; + const cell = row[colIdx]; + const colspan = Number.isFinite(cell?.colspan) && cell.colspan > 0 ? cell.colspan : 1; + const rowspan = Number.isFinite(cell?.rowspan) && cell.rowspan > 0 ? cell.rowspan : 1; if (colspan > 1 || rowspan > 1) { for (let r = 0; r < rowspan; r++) { diff --git a/components/slide-renderer/components/element/TextElement/index.tsx b/components/slide-renderer/components/element/TextElement/index.tsx index 8171285aa0..147471e098 100644 --- a/components/slide-renderer/components/element/TextElement/index.tsx +++ b/components/slide-renderer/components/element/TextElement/index.tsx @@ -147,7 +147,8 @@ export function TextElement({ elementInfo, selectElement }: TextElementProps) { const checkEmptyText = useCallback(() => { const debouncedCheck = debounce( () => { - const pureText = elementInfo.content.replace(/<[^>]+>/g, ''); + const content = typeof elementInfo.content === 'string' ? elementInfo.content : ''; + const pureText = content.replace(/<[^>]+>/g, ''); if (!pureText) deleteElement(elementInfo.id); }, 300, @@ -209,7 +210,7 @@ export function TextElement({ elementInfo, selectElement }: TextElementProps) { defaultColor={elementInfo.defaultColor} defaultFontName={elementInfo.defaultFontName} editable={!elementInfo.lock} - value={elementInfo.content} + value={typeof elementInfo.content === 'string' ? elementInfo.content : ''} onUpdate={({ value, ignore }) => updateContent(value, ignore)} onMouseDown={(e) => handleSelectElement(e as React.MouseEvent, false)} /> diff --git a/tests/slide-renderer/table-utils.test.ts b/tests/slide-renderer/table-utils.test.ts new file mode 100644 index 0000000000..3cce3fe7ef --- /dev/null +++ b/tests/slide-renderer/table-utils.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import { getHiddenCells } from '@/components/slide-renderer/components/element/TableElement/tableUtils'; + +describe('tableUtils', () => { + it('tolerates malformed rows and null cells when computing hidden cells', () => { + const data = [[{ id: 'a', text: 'x', colspan: 2 }, null], null, 5] as never; + + expect(() => getHiddenCells(data)).not.toThrow(); + expect(getHiddenCells(data)).toEqual(new Set(['0_1'])); + }); +});