How could I render a table? #1107
|
How could I render a table on the PDF page? After skimming through the documentation, I find react-pdf to be very intuitive and I can easily follow the examples. I would love to use it however I need to render simple tables which hold tabular data. I don't see a component for a table, however wager it could probably be built using either some text components with styling or the canvas component. I'm not familiar with either to be sure however would be grateful if someone could point me in the right direction. Thank you! |
Replies: 6 comments 7 replies
|
There is no table component officially just yet, but you can check this one out |
|
I've built a simple example that shows how to implement a Table. It's pretty straightfoward, check it out: https://github.com/Chagall/react-pdf-table-example |
|
Thank you both! Those projects look awesome |
|
i am using GPT for creating table from my data. Now, when i download my PDF file which includes that table, it distorts the format of my table what to do? |
|
@diegomura may be we can create a feature request for Table component? Or may be you can expose the measurements of View/Text like heights/widths to calculate those spans for Table. |
|
I have implemented id that handle row span, col span, any complex table structure, PDFStyles.ts/* eslint-disable @typescript-eslint/no-explicit-any */
import { pxToPt, parseBorderWidth, parseMargin } from './PDFHelpers';
export type FlexJustify = 'flex-start' | 'center' | 'flex-end';
export type FlexAlign = 'flex-start' | 'center' | 'flex-end' | 'stretch' | 'baseline';
export type AlignSelf = 'flex-start' | 'center' | 'flex-end';
export const alignToJustify = (align: string | undefined): FlexJustify => {
if (align === 'center') return 'center';
if (align === 'right') return 'flex-end';
return 'flex-start';
};
export const valignToJustify = (valign: string | undefined): FlexJustify => {
if (valign === 'middle') return 'center';
if (valign === 'bottom') return 'flex-end';
return 'flex-start';
};
export const valignToAlignSelf = (valign: string | undefined): AlignSelf => {
if (valign === 'middle' || valign === 'center') return 'center';
if (valign === 'bottom') return 'flex-end';
return 'flex-start';
};
export const resolveElementMargins = (element: Record<string, unknown>): {
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
} => ({
marginTop: parseMargin((element.marginTop as number | string | undefined) ?? 0),
marginBottom: parseMargin((element.marginBottom as number | string | undefined) ?? 0),
marginLeft: element.marginLeft !== undefined ? pxToPt(Number(element.marginLeft)) : 0,
marginRight: element.marginRight !== undefined ? pxToPt(Number(element.marginRight)) : 0,
});
export const resolveImageDimensions = (
element: Record<string, unknown>,
maxWidth: number,
): { width: number; height: number | undefined; scale: number } => {
const originalWidth =
typeof element.width === 'number'
? pxToPt(element.width)
: typeof element.width === 'string' && !(element.width as string).includes('%')
? pxToPt(parseFloat(element.width as string))
: typeof element.width === 'string' && (element.width as string).includes('%')
? (parseFloat(element.width as string) / 100) * maxWidth
: 100;
const width = Math.min(originalWidth, maxWidth);
const scale = originalWidth > 0 ? width / originalWidth : 1;
const rawHeight =
element.height && element.height !== 'auto'
? typeof element.height === 'number'
? pxToPt(element.height)
: typeof element.height === 'string'
? pxToPt(parseFloat(element.height as string))
: undefined
: undefined;
const height = rawHeight !== undefined ? rawHeight * scale : undefined;
return { width, height, scale };
};
export const buildImageSource = (
imageSrc: string,
s3Key?: string,
): string | { uri: string; method: 'POST'; headers: Record<string, string>; body: string } => {
if (imageSrc.startsWith('data:image')) return imageSrc;
const key = s3Key || imageSrc;
return {
uri: `/api/proxy-image?url=${encodeURIComponent(key)}`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: key }),
};
};
export const buildParagraphBorderStyle = (element: Record<string, unknown>): Record<string, unknown> => {
const style: Record<string, unknown> = {};
if (!element.borderWidth) return style;
const bw = parseBorderWidth(element.borderWidth as number | string);
const bc = String(element.borderColor ?? '#000000');
if (element.borderTop) { style.borderTopWidth = bw; style.borderTopColor = bc; style.borderTopStyle = 'solid'; }
if (element.borderBottom) { style.borderBottomWidth = bw; style.borderBottomColor = bc; style.borderBottomStyle = 'solid'; }
if (element.borderLeft) { style.borderLeftWidth = bw; style.borderLeftColor = bc; style.borderLeftStyle = 'solid'; }
if (element.borderRight) { style.borderRightWidth = bw; style.borderRightColor = bc; style.borderRightStyle = 'solid'; }
return style;
};
export const buildTableContainerStyle = (
resolvedTableWidth: number,
tableAlign: string,
parentIsTableCell: boolean,
): Record<string, unknown> => {
const style: Record<string, unknown> = {
marginVertical: 4,
width: resolvedTableWidth,
maxWidth: resolvedTableWidth,
overflow: 'hidden',
};
if (parentIsTableCell) {
style.maxWidth = '100%';
style.width = '100%';
}
if (tableAlign === 'center') {
style.alignSelf = 'center';
style.marginLeft = 'auto';
style.marginRight = 'auto';
} else if (tableAlign === 'right') {
style.alignSelf = 'flex-end';
style.marginLeft = 'auto';
}
return style;
};
export const buildRowStyle = (
isEmptyRow: boolean,
rowMinHeight: number | undefined,
): Record<string, unknown> => ({
display: 'flex',
flexDirection: 'row',
width: '100%',
alignItems: 'stretch',
flexWrap: 'nowrap',
...(isEmptyRow
? { height: rowMinHeight ?? 4, maxHeight: rowMinHeight ?? 4 }
: rowMinHeight ? { minHeight: rowMinHeight } : {}),
});
export interface CellPaddingResult {
paddingTop: number;
paddingRight: number;
paddingBottom: number;
paddingLeft: number;
}
const parsePaddingValue = (value: number | string | undefined): number => {
if (value === undefined) return 4;
if (typeof value === 'number') return pxToPt(value);
const match = (value as string).match(/^(\d+)(px|pt)?$/);
if (match) {
const val = parseFloat(match[1]);
return match[2] === 'pt' ? val : pxToPt(val);
}
return 4;
};
export const resolveCellPadding = (
cell: Record<string, unknown>,
isEmptyCell: boolean,
isEmptyRow: boolean,
): CellPaddingResult => {
if (isEmptyCell || isEmptyRow) {
return { paddingTop: 0, paddingRight: 0, paddingBottom: 0, paddingLeft: 0 };
}
const rawPad = cell.padding !== undefined && cell.padding !== null
? (cell.padding as number)
: 4;
const basePadding = parsePaddingValue(rawPad);
const hasSidePadding =
cell.paddingTop !== undefined ||
cell.paddingRight !== undefined ||
cell.paddingBottom !== undefined ||
cell.paddingLeft !== undefined;
if (hasSidePadding) {
return {
paddingTop: parsePaddingValue((cell.paddingTop ?? cell.padding) as any),
paddingRight: parsePaddingValue((cell.paddingRight ?? cell.padding) as any),
paddingBottom: parsePaddingValue((cell.paddingBottom ?? cell.padding) as any),
paddingLeft: parsePaddingValue((cell.paddingLeft ?? cell.padding) as any),
};
}
return { paddingTop: basePadding, paddingRight: basePadding, paddingBottom: basePadding, paddingLeft: basePadding };
};
export interface CellBorderResult {
bw: number;
bc: string;
hasCellSideBorders: boolean;
}
export const resolveCellBorderBase = (
cell: Record<string, unknown>,
tableBorderWidth: number,
tableBorderColor: string,
): CellBorderResult => {
const rawCellBorder =
cell.borderWidth !== undefined && cell.borderWidth !== null
? parseBorderWidth(cell.borderWidth as number | string)
: tableBorderWidth;
const bw = typeof rawCellBorder === 'number' && isFinite(rawCellBorder) ? rawCellBorder : 0;
const bc = String(cell.borderColor ?? tableBorderColor ?? '#000000');
const hasCellSideBorders =
cell.borderTopWidth !== undefined ||
cell.borderRightWidth !== undefined ||
cell.borderBottomWidth !== undefined ||
cell.borderLeftWidth !== undefined;
return { bw, bc, hasCellSideBorders };
};
interface CellMapEntry {
cell: Record<string, unknown>;
physColStart: number;
rowspan: number;
colspan: number;
}
export const applyCellBorderStyle = (
cellStyle: Record<string, unknown>,
cell: Record<string, unknown>,
{ bw, bc, hasCellSideBorders }: CellBorderResult,
isFirstRow: boolean,
isFirstCol: boolean,
physColStart: number,
rowIndex: number,
cellMap: CellMapEntry[][],
): void => {
const isBorderVisible = (w: number, c: string) => w > 0 && c !== 'transparent';
if (hasCellSideBorders) {
const btw = parseBorderWidth((cell.borderTopWidth as number | string | undefined) ?? bw);
const btc = String(cell.borderTopColor ?? bc);
const brw = parseBorderWidth((cell.borderRightWidth as number | string | undefined) ?? bw);
const brc = String(cell.borderRightColor ?? bc);
const bbw = parseBorderWidth((cell.borderBottomWidth as number | string | undefined) ?? bw);
const bbc = String(cell.borderBottomColor ?? bc);
const blw = parseBorderWidth((cell.borderLeftWidth as number | string | undefined) ?? bw);
const blc = String(cell.borderLeftColor ?? bc);
if (isBorderVisible(bbw, bbc)) {
cellStyle.borderBottomWidth = bbw;
cellStyle.borderBottomColor = bbc;
cellStyle.borderBottomStyle = 'solid';
}
if (isBorderVisible(brw, brc)) {
cellStyle.borderRightWidth = brw;
cellStyle.borderRightColor = brc;
cellStyle.borderRightStyle = 'solid';
}
if (isBorderVisible(btw, btc)) {
if (isFirstRow) {
cellStyle.borderTopWidth = btw;
cellStyle.borderTopColor = btc;
cellStyle.borderTopStyle = 'solid';
} else {
const aboveRow = cellMap[rowIndex - 1];
const aboveCell = aboveRow?.find(
e => physColStart >= e.physColStart && physColStart < e.physColStart + e.colspan,
);
const aboveBbw = aboveCell ? parseBorderWidth((aboveCell.cell.borderBottomWidth as any) ?? 0) : 0;
const aboveBbc = aboveCell ? String(aboveCell.cell.borderBottomColor ?? '') : '';
if (!(aboveBbw > 0 && aboveBbc !== 'transparent')) {
cellStyle.borderTopWidth = btw;
cellStyle.borderTopColor = btc;
cellStyle.borderTopStyle = 'solid';
}
}
}
if (isBorderVisible(blw, blc)) {
if (isFirstCol) {
cellStyle.borderLeftWidth = blw;
cellStyle.borderLeftColor = blc;
cellStyle.borderLeftStyle = 'solid';
} else {
const leftCell = cellMap[rowIndex]?.find(e => e.physColStart + e.colspan === physColStart);
const leftBrw = leftCell ? parseBorderWidth((leftCell.cell.borderRightWidth as any) ?? 0) : 0;
const leftBrc = leftCell ? String(leftCell.cell.borderRightColor ?? '') : '';
if (!(leftBrw > 0 && leftBrc !== 'transparent')) {
cellStyle.borderLeftWidth = blw;
cellStyle.borderLeftColor = blc;
cellStyle.borderLeftStyle = 'solid';
}
}
}
} else if (bw > 0) {
if (isFirstRow) {
cellStyle.borderTopWidth = bw;
cellStyle.borderTopColor = bc;
cellStyle.borderTopStyle = 'solid';
}
if (isFirstCol) {
cellStyle.borderLeftWidth = bw;
cellStyle.borderLeftColor = bc;
cellStyle.borderLeftStyle = 'solid';
}
cellStyle.borderBottomWidth = bw;
cellStyle.borderBottomColor = bc;
cellStyle.borderBottomStyle = 'solid';
cellStyle.borderRightWidth = bw;
cellStyle.borderRightColor = bc;
cellStyle.borderRightStyle = 'solid';
}
};
export const resolveEffectiveBorderH = (
cell: Record<string, unknown>,
{ bw, hasCellSideBorders }: CellBorderResult,
isFirstCol: boolean,
): number => {
if (hasCellSideBorders) {
const leftBw = isFirstCol ? parseBorderWidth((cell.borderLeftWidth as any) ?? bw) : 0;
const rightBw = parseBorderWidth((cell.borderRightWidth as any) ?? bw);
return leftBw + rightBw;
}
return isFirstCol ? bw * 2 : bw;
};
export const buildListContainerStyle = (
marginTop: number,
marginBottom: number,
marginLeft: number,
listAlign: string,
isInTableCell: boolean,
): Record<string, unknown> => ({
marginLeft,
marginTop,
marginBottom,
paddingLeft: 20,
lineHeight: 1.3,
width: '100%',
maxWidth: isInTableCell ? '100%' : undefined,
alignItems: alignToJustify(listAlign),
});
export const buildListItemRowStyle = (
itemAlign: string,
isInTableCell: boolean,
): Record<string, unknown> => ({
flexDirection: 'row',
marginBottom: 1,
width: '100%',
maxWidth: isInTableCell ? '100%' : undefined,
lineHeight: 1.0,
justifyContent: alignToJustify(itemAlign),
});
export const buildListItemContentStyle = (listAlign: string): Record<string, unknown> => ({
flex: listAlign === 'left' ? 1 : undefined,
lineHeight: 1.3,
flexWrap: 'wrap',
});
export const buildParagraphContainerStyle = (options: {
marginBottom: number;
marginTop: number;
marginLeft: number;
marginRight: number;
paraTextIndent: number;
elementLineHeight: number | undefined;
fixedLineHeightPt: number | undefined;
paraBgColor: string | undefined;
align: string;
isInTableCell: boolean;
}): Record<string, unknown> => {
const {
marginBottom, marginTop, marginLeft, marginRight, paraTextIndent,
elementLineHeight, fixedLineHeightPt, paraBgColor, align, isInTableCell,
} = options;
const style: Record<string, unknown> = {
marginBottom,
marginTop,
marginLeft: marginLeft + paraTextIndent,
marginRight,
lineHeight: elementLineHeight ?? 1.2,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: alignToJustify(align),
alignItems: 'baseline',
};
if (fixedLineHeightPt) {
style.lineHeight = undefined;
style.minHeight = fixedLineHeightPt;
}
if (paraBgColor) style.backgroundColor = paraBgColor;
if (isInTableCell) {
style.width = '100%';
style.maxWidth = '100%';
style.flexWrap = 'wrap';
style.flexShrink = 1;
style.wordBreak = 'break-word';
style.overflowWrap = 'break-word';
}
if (align === 'center' || align === 'right') {
style.width = '100%';
}
return style;
};
export const buildTextContainerStyle = (options: {
marginBottom: number;
marginTop: number;
marginLeft: number;
marginRight: number;
paraTextIndent: number;
elementLineHeight: number | undefined;
fixedLineHeightPt: number | undefined;
paraBgColor: string | undefined;
align: string;
isInTableCell: boolean;
}): Record<string, unknown> => {
const {
marginBottom, marginTop, marginLeft, marginRight, paraTextIndent,
elementLineHeight, fixedLineHeightPt, paraBgColor, align, isInTableCell,
} = options;
const style: Record<string, unknown> = {
marginBottom,
marginTop,
marginLeft: marginLeft + paraTextIndent,
marginRight,
lineHeight: elementLineHeight ?? 1.2,
};
if (align) style.textAlign = align;
if (isInTableCell) {
style.width = '100%';
style.flexShrink = 1;
style.hyphenationCallback = (word: string) => [word];
}
if (paraBgColor) style.backgroundColor = paraBgColor;
if (fixedLineHeightPt) {
style.lineHeight = undefined;
style.minHeight = fixedLineHeightPt;
}
return style;
};PDFRenderer.tsx/* eslint-disable jsx-a11y/alt-text */
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react';
import { Descendant, Element as SlateElement, Text as SlateText } from 'slate';
import { View, Text, Image, Link } from '@react-pdf/renderer';
import {
getPDFFontFamily,
replaceTagsWithData,
pxToPt,
parseFontSize,
parseMargin,
parseBorderWidth,
} from './PDFHelpers';
import {
alignToJustify,
valignToAlignSelf,
resolveElementMargins,
resolveImageDimensions,
buildImageSource,
buildParagraphBorderStyle,
buildTableContainerStyle,
buildRowStyle,
resolveCellPadding,
resolveCellBorderBase,
applyCellBorderStyle,
resolveEffectiveBorderH,
buildListContainerStyle,
buildListItemRowStyle,
buildListItemContentStyle,
buildParagraphContainerStyle,
buildTextContainerStyle,
} from './PDFStyles';
interface RenderContext {
parentType?: string;
tableBorderWidth?: number;
align?: string;
verticalAlign?: 'top' | 'middle' | 'bottom';
isInTableCell?: boolean;
isListItem?: boolean;
fontSize?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
availableWidth?: number;
cellWidth?: number | string;
cellPadding?: number;
cellBorderWidth?: number;
cellBorderColor?: string;
isHeader?: boolean;
isFooter?: boolean;
pageHeight?: number;
pageWidth?: number;
headerAreaHeight?: number;
footerAreaHeight?: number;
bodyAreaHeight?: number;
remainingPageHeight?: number;
rowHeights?: { [rowIndex: number]: number };
colWidths?: number[];
currentRowIndex?: number;
currentColIndex?: number;
pageMarginLeft?: number;
pageMarginRight?: number;
}
interface CellEntry {
cell: Record<string, unknown>;
physColStart: number;
rowspan: number;
colspan: number;
}
const HIGHLIGHT_COLORS_PDF: Record<string, string> = {
yellow: '#FFFF00', green: '#00FF00', cyan: '#00FFFF', magenta: '#FF00FF',
blue: '#0000FF', red: '#FF0000', darkBlue: '#00008B', darkCyan: '#008B8B',
darkGreen: '#006400', darkMagenta: '#8B008B', darkRed: '#8B0000',
darkYellow: '#808000', darkGray: '#A9A9A9', lightGray: '#D3D3D3',
black: '#000000', white: '#FFFFFF',
};
const isBase64ImageData = (str: string): boolean => {
if (!str) return false;
const s = str.replace(/\s/g, '');
return (
s.startsWith('iVBORw0KGgo') ||
s.startsWith('/9j/') ||
s.startsWith('R0lGOD') ||
s.startsWith('UklGR') ||
s.startsWith('Qk0') ||
(s.length > 100 && /^[A-Za-z0-9+/]+=*$/.test(s))
);
};
const extractRawBase64 = (text: string): string | null => {
if (!text) return null;
const clean = text.replace(/\s/g, '');
const patterns = ['iVBORw0KGgo', '/9j/', 'R0lGOD', 'UklGR', 'Qk0'];
for (const p of patterns) {
if (clean.startsWith(p)) return clean;
}
for (const line of text.split('\n')) {
const l = line.trim().replace(/\s/g, '');
for (const p of patterns) {
if (l.startsWith(p)) return l;
}
if (l.length > 100 && /^[A-Za-z0-9+/]+=*$/.test(l)) return l;
}
return null;
};
const detectImageMime = (base64: string): string => {
const s = base64.replace(/\s/g, '');
if (s.startsWith('iVBORw0KGgo')) return 'image/png';
if (s.startsWith('/9j/')) return 'image/jpeg';
if (s.startsWith('R0lGOD')) return 'image/gif';
if (s.startsWith('UklGR')) return 'image/webp';
if (s.startsWith('Qk0')) return 'image/bmp';
return 'image/png';
};
const resolveBase64Src = (raw: string): string => {
const mime = detectImageMime(raw);
return `data:${mime};base64,${raw}`;
};
const hasTextContent = (nodes: Descendant[]): boolean =>
nodes.some(node => {
if (SlateText.isText(node)) return node.text !== undefined;
if (SlateElement.isElement(node)) {
const el = node as unknown as Record<string, unknown>;
if (['image', 'table', 'link', 'checkbox', 'paragraph'].includes(el.type as string)) return true;
if (el.children && Array.isArray(el.children))
return hasTextContent(el.children as Descendant[]);
}
return false;
});
const getMaxFontSize = (nodes: Descendant[], max = 0): number => {
nodes.forEach(node => {
if (SlateText.isText(node) && node.fontSize) {
const size = parseFontSize(node.fontSize);
if (size > max) max = size;
} else if (SlateElement.isElement(node)) {
const el = node as unknown as { children?: Descendant[] };
if (el.children) max = getMaxFontSize(el.children, max);
}
});
return Math.max(max, 12);
};
export const calculateContentHeight = (nodes: Descendant[], context?: RenderContext): number => {
let height = 0;
const availableWidth = context?.availableWidth;
nodes.forEach(node => {
if (SlateElement.isElement(node)) {
const el = node as unknown as Record<string, unknown>;
const { marginTop, marginBottom } = resolveElementMargins(el);
if (el.type === 'paragraph') {
const children = el.children as Descendant[] | undefined;
const isEmptyParagraph = children?.length === 1 && SlateText.isText(children[0]) && children[0].text === '';
if (isEmptyParagraph) {
const maxFontSize = getMaxFontSize(children ?? []);
height += maxFontSize * 0.8 + marginTop + marginBottom;
} else {
height += calculateContentHeight(children ?? [], { ...context, availableWidth }) + marginTop + marginBottom;
}
} else if (el.type === 'table') {
height += 100 + marginTop + marginBottom;
} else if (el.type === 'image') {
height += pxToPt(el.height ? parseFloat(String(el.height)) : 100) + marginTop + marginBottom;
} else if (el.type === 'bulleted-list' || el.type === 'numbered-list') {
let listH = 0;
((el.children as Array<Record<string, unknown>>) ?? []).forEach(item => {
if (item.type === 'list-item') {
listH += calculateContentHeight((item.children as Descendant[]) ?? [], { ...context, availableWidth }) + 4;
}
});
height += listH + marginTop + marginBottom;
} else if (el.children) {
height += calculateContentHeight(el.children as Descendant[], { ...context, availableWidth }) + marginTop + marginBottom;
}
} else if (SlateText.isText(node)) {
const fontSize = node.fontSize ? parseFontSize(node.fontSize) : 12;
const text = node.text ?? '';
if (availableWidth && text.length > 0) {
const charsPerLine = Math.max(1, Math.floor(availableWidth / (fontSize * 0.6)));
height += fontSize * 1.3 * Math.ceil(text.length / charsPerLine);
} else {
height += fontSize * 1.3;
}
}
});
return height;
};
const buildLeafStyle = (
node: SlateText,
context?: RenderContext,
inheritedStyle: Record<string, any> = {},
): Record<string, any> => {
const style: Record<string, any> = { ...inheritedStyle };
if (node.bold) style.fontWeight = 'bold';
if (node.italic) style.fontStyle = 'italic';
const decs: string[] = [];
if (node.underline) decs.push('underline');
if (node.strikethrough) decs.push('line-through');
if (decs.length > 0) style.textDecoration = decs.join(' ');
if (node.superscript) {
const base = node.fontSize ? parseFontSize(node.fontSize) : (context?.fontSize ?? style.fontSize ?? 12);
style.verticalAlign = 'super';
style.fontSize = base * 0.6;
} else if (node.subscript) {
const base = node.fontSize ? parseFontSize(node.fontSize) : (context?.fontSize ?? style.fontSize ?? 12);
style.verticalAlign = 'sub';
style.fontSize = base * 0.6;
} else if (node.fontSize) {
style.fontSize = parseFontSize(node.fontSize);
}
if (node.fontFamily) style.fontFamily = getPDFFontFamily(node.fontFamily);
if (node.color) style.color = node.color;
if (node.highlight) {
style.backgroundColor = HIGHLIGHT_COLORS_PDF[node.highlight] ?? node.highlight;
} else if (node.backgroundColor) {
style.backgroundColor = node.backgroundColor;
}
if (node.smallCaps) style.fontVariant = 'small-caps';
return style;
};
const buildTextRuns = (
nodes: Descendant[],
dataSource: Record<string, unknown>,
inheritedStyle: Record<string, any> = {},
): React.ReactNode[] => {
const runs: React.ReactNode[] = [];
let idx = 0;
const process = (node: Descendant, style: Record<string, any>) => {
if (SlateText.isText(node)) {
let text = node.text ? replaceTagsWithData(node.text, dataSource) : '';
if (text.trim() === '' && !node.text?.trim()) return;
const raw = extractRawBase64(text);
if (raw && isBase64ImageData(raw)) {
runs.push(<Image key={`tr-img-${idx++}`} src={resolveBase64Src(raw)} style={{ width: 200, height: 60 }} />);
return;
}
text = text.replace(/\t/g, '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0');
const leafStyle = buildLeafStyle(node, undefined, style);
if (node.caps && text) text = text.toUpperCase();
runs.push(<Text key={idx++} style={leafStyle}>{text}</Text>);
} else if (SlateElement.isElement(node)) {
const el = node as unknown as Record<string, unknown>;
const children = (el.children as Descendant[]) ?? [];
const linkStyle = el.type === 'link' ? { ...style, color: '#0000EE', textDecoration: 'underline' } : style;
children.forEach(child => process(child, linkStyle));
}
};
nodes.forEach(node => process(node, inheritedStyle));
return runs;
};
const renderTextNode = (
textNode: SlateText,
index: number,
dataSource: Record<string, unknown>,
contextAlign?: string,
context?: RenderContext,
): React.ReactNode => {
if (textNode.text === undefined || textNode.text === null) return null;
let text = textNode.text ? replaceTagsWithData(textNode.text, dataSource) : '';
if (text.trim() === '' && !textNode.text?.trim()) return null;
const raw = extractRawBase64(text);
if (raw && isBase64ImageData(raw)) {
const maxW = context?.availableWidth ?? 565;
const width = Math.min(250, maxW);
return (
<Image
key={index}
src={buildImageSource(resolveBase64Src(raw))}
style={{ width, height: Math.min(80, width * 0.32), marginVertical: 4, alignSelf: 'flex-start' }}
/>
);
}
text = text.replace(/\t/g, '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0');
const textStyle: Record<string, any> = { lineHeight: 1.2 };
if (context?.fontSize && !textNode.fontSize) textStyle.fontSize = context.fontSize;
if (contextAlign) textStyle.textAlign = contextAlign;
if (context?.isInTableCell) { textStyle.flexShrink = 1; textStyle.wordBreak = 'break-word'; textStyle.overflowWrap = 'break-word'; textStyle.whiteSpace = 'pre-wrap'; }
if (context?.isHeader || context?.isFooter) {
if (!textNode.fontSize) textStyle.fontSize = 10;
if (!textNode.color) textStyle.color = '#666666';
}
Object.assign(textStyle, buildLeafStyle(textNode, context));
if (textNode.caps && text) text = text.toUpperCase();
if (text.includes('\n')) {
const lines = text.split('\n');
return (
<Text key={index} style={textStyle}>
{lines.map((line, i) => (
<React.Fragment key={i}>
{line}{i < lines.length - 1 && '\n'}
</React.Fragment>
))}
</Text>
);
}
return <Text key={index} style={textStyle}>{text.trim() === '' ? ' ' : text}</Text>;
};
const isSegmentEffectivelyEmpty = (nodes: Descendant[], dataSource: Record<string, unknown>): boolean =>
nodes.every(node => {
if (SlateText.isText(node)) {
return (node.text ? replaceTagsWithData(node.text, dataSource) : '').trim() === '';
}
return false;
});
const renderInlineContentWithImages = (
children: Descendant[],
dataSource: Record<string, unknown>,
align: string,
context: RenderContext,
): React.ReactNode[] => {
type Segment = { type: 'text'; children: Descendant[] } | { type: 'image'; element: any };
const segments: Segment[] = [];
let textBuf: Descendant[] = [];
const flush = () => { if (textBuf.length) { segments.push({ type: 'text', children: [...textBuf] }); textBuf = []; } };
children.forEach(child => {
if (SlateText.isText(child)) {
textBuf.push(child);
} else if (SlateElement.isElement(child)) {
const el = child as unknown as Record<string, unknown>;
if (el.type === 'image') { flush(); segments.push({ type: 'image', element: child }); }
else textBuf.push(child);
}
});
flush();
const fontSize = context.fontSize ?? 12;
const textLineHeight = fontSize * 1.3;
const imageSegments = segments.filter(s => s.type === 'image');
const hasNonEmptyText = segments.some(s => s.type === 'text' && !isSegmentEffectivelyEmpty((s as any).children, dataSource));
let maxImageHeight = textLineHeight;
imageSegments.forEach(seg => {
const imgEl = (seg as any).element as any;
let src = String(imgEl.url ?? '');
src = replaceTagsWithData(src, dataSource);
const raw = extractRawBase64(src);
if (raw && isBase64ImageData(raw)) src = resolveBase64Src(raw);
const maxW = context.availableWidth ?? 565;
const origW = typeof imgEl.width === 'number' ? pxToPt(imgEl.width) : 100;
const origH = typeof imgEl.height === 'number' ? pxToPt(imgEl.height) : textLineHeight;
const w = Math.min(origW, maxW);
const h = origH * (origW > 0 ? w / origW : 1);
if (h > maxImageHeight) maxImageHeight = h;
});
const rowHeight = Math.max(textLineHeight, maxImageHeight);
const dominantVA = imageSegments.length > 0 ? ((imageSegments[0] as any).element as any).verticalAlign ?? 'top' : 'top';
const parentAlignItems = valignToAlignSelf(dominantVA) as any;
const alignSelf = valignToAlignSelf(dominantVA);
if (!hasNonEmptyText && imageSegments.length > 0) {
return imageSegments.map((seg, i) => {
const imgEl = (seg as any).element as any;
let src = String(imgEl.url ?? '');
src = replaceTagsWithData(src, dataSource);
const raw = extractRawBase64(src);
if (raw && isBase64ImageData(raw)) src = resolveBase64Src(raw);
if (!src) return null;
const maxW = context.availableWidth ?? 565;
const origW = typeof imgEl.width === 'number' ? pxToPt(imgEl.width) : 100;
const origH = typeof imgEl.height === 'number' ? pxToPt(imgEl.height) : origW * 0.75;
const w = Math.min(origW, maxW);
const h = origH * (origW > 0 ? w / origW : 1);
const imgAlign = (imgEl.align as string) ?? align ?? 'left';
const justifyContent = alignToJustify(imgAlign);
return (
<View key={`img-block-${i}`} style={{ width: '100%', flexDirection: 'row', justifyContent, minHeight: h }}>
<Image src={buildImageSource(src, String(imgEl.s3Key ?? ''))} style={{ width: w, height: h }} />
</View>
);
}).filter(Boolean) as React.ReactNode[];
}
const rowChildren: React.ReactNode[] = [];
let wIdx = 0;
segments.forEach((segment, si) => {
if (segment.type === 'text') {
(segment.children as Descendant[]).forEach(node => {
if (!SlateText.isText(node)) return;
let text = node.text ? replaceTagsWithData(node.text, dataSource) : '';
if (text.trim() === '' && !node.text?.trim()) return;
const raw = extractRawBase64(text);
if (raw && isBase64ImageData(raw)) {
rowChildren.push(<Image key={`wf-img-${wIdx++}`} src={buildImageSource(resolveBase64Src(raw))} style={{ width: 200, height: 60 }} />);
return;
}
text = text.replace(/\t/g, '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0');
const leafStyle = buildLeafStyle(node, context, { lineHeight: 1.3, fontSize, alignSelf });
if (node.caps && text) text = text.toUpperCase();
text.split(/(?=\s)|(?<=\s)/).filter(Boolean).forEach(token => {
rowChildren.push(<Text key={`seg${si}-w${wIdx++}`} style={leafStyle}>{token}</Text>);
});
});
} else {
const imgEl = (segment as any).element as any;
let src = String(imgEl.url ?? '');
src = replaceTagsWithData(src, dataSource);
const raw = extractRawBase64(src);
if (raw && isBase64ImageData(raw)) src = resolveBase64Src(raw);
if (!src) return;
const maxW = context.availableWidth ?? 565;
const { width, height } = resolveImageDimensions(imgEl, maxW);
const imgAS = valignToAlignSelf(imgEl.verticalAlign ?? 'top');
rowChildren.push(
<Image key={`img-${si}`} src={buildImageSource(src, String(imgEl.s3Key ?? ''))} style={{ width, height, alignSelf: imgAS }} />,
);
}
});
return [
<View key="inline-row" style={{ flexDirection: 'row', flexWrap: 'wrap', alignItems: parentAlignItems, minHeight: rowHeight, justifyContent: alignToJustify(align) }}>
{rowChildren}
</View>,
];
};
const renderListItems = (
listChildren: Array<Record<string, unknown>> | undefined,
listAlign: string,
context: RenderContext,
dataSource: Record<string, unknown>,
isBulleted: boolean,
): React.ReactNode[] => {
if (!listChildren) return [];
return listChildren.map((listItem, li) => {
if (listItem.type !== 'list-item') return null;
const itemAlign = String((listItem.align as string) ?? listAlign);
const itemChildren = listItem.children as Descendant[] | undefined;
const itemFontSize = isBulleted ? getMaxFontSize(itemChildren ?? []) : 12;
const listItemContext: RenderContext = { ...context, isListItem: true, align: itemAlign };
return (
<View key={li} style={buildListItemRowStyle(itemAlign, context.isInTableCell ?? false) as any}>
<Text style={{ fontSize: itemFontSize, lineHeight: 1.0, marginRight: 6, width: isBulleted ? 10 : 20, textAlign: isBulleted ? 'left' : 'right' }}>
{isBulleted ? '•' : `${li + 1}.`}
</Text>
<View style={buildListItemContentStyle(listAlign) as any}>
{itemChildren ? renderSlateToPDF(itemChildren, listItemContext, dataSource) : null}
</View>
</View>
);
});
};
const calculateMaxTableWidth = (context: RenderContext): number => {
const pageWidth = context.pageWidth ?? 595.28;
const marginLeft = context.pageMarginLeft ?? 20;
const marginRight = context.pageMarginRight ?? 20;
const maxWidth = pageWidth - marginLeft - marginRight;
if (context.isInTableCell && context.availableWidth) {
return Math.min(context.availableWidth, maxWidth);
}
return context.availableWidth ?? maxWidth;
};
const getCellHorizontalAlignment = (align: string | undefined): 'flex-start' | 'center' | 'flex-end' => {
if (align === 'center') return 'center';
if (align === 'right') return 'flex-end';
return 'flex-start';
};
const getCellVerticalAlignment = (valign: string | undefined): 'flex-start' | 'center' | 'flex-end' => {
if (valign === 'middle' || valign === 'center') return 'center';
if (valign === 'bottom') return 'flex-end';
return 'flex-start';
};
const getAvailableContentWidth = (context: RenderContext): number => {
const pageWidth = context.pageWidth ?? 595.28;
const marginLeft = context.pageMarginLeft ?? 20;
const marginRight = context.pageMarginRight ?? 20;
const maxWidth = pageWidth - marginLeft - marginRight;
if (context.isInTableCell && context.availableWidth) {
return Math.min(context.availableWidth, maxWidth);
}
return context.availableWidth ?? maxWidth;
};
export const renderSlateToPDF = (
nodes: Descendant[],
context: RenderContext = {},
dataSource: Record<string, unknown> = {},
): React.ReactNode[] => {
if (!nodes?.length) return [];
const elements: React.ReactNode[] = [];
const availableContentWidth = getAvailableContentWidth(context);
nodes.forEach((node, index) => {
if (SlateElement.isElement(node)) {
const element = node as unknown as Record<string, unknown>;
switch (element.type) {
case 'header': {
const children = element.children as Descendant[] | undefined;
const headerWidth = context.pageWidth ? context.pageWidth - (context.pageMarginLeft ?? 20) - (context.pageMarginRight ?? 20) : availableContentWidth;
const hCtx: RenderContext = {
...context,
isHeader: true,
align: String(element.align ?? 'left'),
availableWidth: headerWidth,
headerAreaHeight: context.headerAreaHeight
};
const hHeight = children ? calculateContentHeight(children, hCtx) : 0;
const maxH = context.headerAreaHeight ?? 80;
const hContent = hHeight > maxH ? children?.slice(0, 3) : children;
elements.push(
<View key={index}
style={{
position: 'absolute',
top: 0,
left: (context.pageMarginLeft ?? 20),
right: (context.pageMarginRight ?? 20),
paddingHorizontal: 0,
paddingVertical: 10,
backgroundColor: '#FFFFFF',
zIndex: 10,
width: headerWidth,
...(hHeight > maxH ? { maxHeight: maxH, overflow: 'hidden' } : {})
}}>
{hContent ? renderSlateToPDF(hContent, hCtx, dataSource) : null}
{hHeight > maxH && <Text style={{ fontSize: 8, color: '#999999', marginTop: 4 }}>[Header truncated - content exceeds page margin]</Text>}
</View>,
);
break;
}
case 'footer': {
const children = element.children as Descendant[] | undefined;
const footerWidth = context.pageWidth ? context.pageWidth - (context.pageMarginLeft ?? 20) - (context.pageMarginRight ?? 20) : availableContentWidth;
const fCtx: RenderContext = {
...context,
isFooter: true,
align: String(element.align ?? 'left'),
availableWidth: footerWidth,
footerAreaHeight: context.footerAreaHeight
};
const fHeight = children ? calculateContentHeight(children, fCtx) : 0;
const maxH = context.footerAreaHeight ?? 80;
const fContent = fHeight > maxH ? children?.slice(0, 3) : children;
elements.push(
<View key={index}
style={{
position: 'absolute',
bottom: 0,
left: (context.pageMarginLeft ?? 20),
right: (context.pageMarginRight ?? 20),
paddingHorizontal: 0,
paddingVertical: 10,
backgroundColor: '#FFFFFF',
zIndex: 10,
width: footerWidth,
...(fHeight > maxH ? { maxHeight: maxH, overflow: 'hidden' } : {})
}}>
{fContent ? renderSlateToPDF(fContent, fCtx, dataSource) : null}
{fHeight > maxH && <Text style={{ fontSize: 8, color: '#999999', marginTop: 4 }}>[Footer truncated - content exceeds page margin]</Text>}
</View>,
);
break;
}
case 'paragraph': {
const align = String(element.align ?? 'left');
const defaultMargin = context.isInTableCell ? 0 : 3;
const marginBottom = parseMargin((element.marginBottom as any) ?? defaultMargin);
const marginTop = parseMargin((element.marginTop as any) ?? defaultMargin);
const marginLeft = element.marginLeft !== undefined ? Math.max(0, pxToPt(Number(element.marginLeft))) : 0;
const marginRight = element.marginRight !== undefined ? Math.max(0, pxToPt(Number(element.marginRight))) : 0;
const elementLineHeight = element.lineHeight !== undefined
? (element.lineHeightRule === 'exact' || element.lineHeightRule === 'atLeast' ? undefined : Number(element.lineHeight))
: 1.2;
const fixedLineHeightPt = (element.lineHeightRule === 'exact' || element.lineHeightRule === 'atLeast') && element.lineHeight !== undefined
? pxToPt(Number(element.lineHeight)) : undefined;
const paraBgColor = element.backgroundColor ? String(element.backgroundColor) : undefined;
const paraTextIndent = element.indentFirstLine ? Math.max(0, pxToPt(Number(element.indentFirstLine))) : 0;
const children = element.children as Descendant[] | undefined;
const maxFontSize = children ? getMaxFontSize(children) : 12;
if ((context.isHeader || context.isFooter) && !context.isInTableCell) {
const maxHF = context.isHeader ? (context.headerAreaHeight ?? 80) : (context.footerAreaHeight ?? 80);
const elementHeight = maxFontSize + marginTop + marginBottom;
if (elementHeight > maxHF) break;
}
const isEmptyParagraph = children?.length === 1 && SlateText.isText(children[0]) && children[0].text === '';
if (isEmptyParagraph) {
const emptyH = maxFontSize * 0.6;
if (emptyH <= 0) break;
elements.push(<Text key={index} style={{ height: emptyH, marginTop, marginBottom, marginLeft, marginRight }}> </Text>);
break;
}
if (!children?.length || !hasTextContent(children)) break;
const styleOptions = { marginBottom, marginTop, marginLeft, marginRight, paraTextIndent, elementLineHeight, fixedLineHeightPt, paraBgColor, align, isInTableCell: context.isInTableCell ?? false };
const borderStyle = buildParagraphBorderStyle(element);
const hasInlineImages = children.some(c => SlateElement.isElement(c) && (c as any).type === 'image');
if (hasInlineImages) {
elements.push(
<View key={index} style={{ marginBottom, marginTop, marginLeft: Math.max(0, marginLeft + paraTextIndent), marginRight, width: '100%', ...(paraBgColor ? { backgroundColor: paraBgColor } : {}) }}>
{renderInlineContentWithImages(children, dataSource, align, { ...context, fontSize: maxFontSize, availableWidth: availableContentWidth })}
</View>,
);
break;
}
const isSimple = children.every(c => {
if (SlateText.isText(c)) return true;
if (SlateElement.isElement(c)) return (c as any).type === 'link';
return false;
});
if (isSimple) {
const baseStyle: Record<string, any> = { lineHeight: elementLineHeight ?? 1.2 };
if (fixedLineHeightPt) baseStyle.lineHeight = undefined;
if (maxFontSize) baseStyle.fontSize = maxFontSize;
if (align) baseStyle.textAlign = align;
const textContainerStyle = { ...buildTextContainerStyle(styleOptions), ...borderStyle };
elements.push(
<Text key={index} style={textContainerStyle as any}>
{buildTextRuns(children, dataSource, baseStyle)}
</Text>,
);
} else {
const containerStyle = { ...buildParagraphContainerStyle(styleOptions), ...borderStyle };
elements.push(
<View key={index} style={containerStyle as any}>
{renderSlateToPDF(children, { ...context, align, availableWidth: availableContentWidth }, dataSource)}
</View>,
);
}
break;
}
case 'table': {
const cellAvailableWidth = typeof context.cellWidth === 'number'
? context.cellWidth
: availableContentWidth;
const parentIsTableCell = context.isInTableCell ?? false;
const cellPadding = context.cellPadding ?? 4;
const cellBorderWidth = context.cellBorderWidth ?? 0;
const maxAllowedWidth = calculateMaxTableWidth(context);
const effectiveTableWidth = parentIsTableCell
? Math.max(0, cellAvailableWidth - cellPadding * 2 - cellBorderWidth * 2)
: Math.min(cellAvailableWidth, maxAllowedWidth);
const tableBorderWidth = parseBorderWidth((element.borderWidth as any) ?? 0);
const tableBorderColor = String(element.borderColor ?? '#000000');
const columnWidths = (element.columnWidths as Array<Record<string, unknown>>) ?? [];
const tableAlign = String(element.align ?? 'left');
const isFullWidth = element.width === undefined || element.width === '100%' || element.width === 'auto' || parentIsTableCell;
let explicitTableWidth: number | null = null;
if (!isFullWidth && element.width != null) {
if (typeof element.width === 'number') {
explicitTableWidth = pxToPt(element.width);
} else if (String(element.width).includes('%')) {
explicitTableWidth = (parseFloat(String(element.width)) / 100) * effectiveTableWidth;
} else {
explicitTableWidth = pxToPt(parseFloat(String(element.width)));
}
}
let resolvedTableWidth: number;
if (explicitTableWidth !== null && !isNaN(explicitTableWidth) && explicitTableWidth > 0) {
resolvedTableWidth = Math.min(explicitTableWidth, maxAllowedWidth);
} else if (isFullWidth) {
resolvedTableWidth = effectiveTableWidth;
} else {
let totalColumnWidth = 0;
let hasDefinedWidths = false;
columnWidths.forEach(col => {
if (col.width) {
const ws = String(col.width);
if (ws.includes('%')) {
const w = (parseFloat(ws) / 100) * effectiveTableWidth;
totalColumnWidth += w;
} else {
const w = Math.max(0, pxToPt(typeof col.width === 'number' ? col.width : parseFloat(ws)));
totalColumnWidth += w;
}
hasDefinedWidths = true;
}
});
if (hasDefinedWidths && totalColumnWidth > 0) {
resolvedTableWidth = Math.min(totalColumnWidth, maxAllowedWidth);
} else {
resolvedTableWidth = effectiveTableWidth;
}
}
resolvedTableWidth = Math.min(Math.max(resolvedTableWidth, 100), maxAllowedWidth);
const rows = (element.children as Array<Record<string, unknown>>) ?? [];
const totalColumns = columnWidths.length > 0
? columnWidths.length
: Math.max(...rows.map(row => {
if (row.type !== 'table-row') return 0;
return ((row.children as Array<Record<string, unknown>>) ?? [])
.filter(c => c.type === 'table-cell')
.reduce((s, c) => s + ((c.colspan as number) ?? 1), 0);
}), 1);
const colWidths: number[] = [];
if (columnWidths.length > 0) {
let totalWidth = 0;
const widths: number[] = [];
for (let i = 0; i < totalColumns; i++) {
const colDef = columnWidths[i];
if (colDef?.width) {
const ws = String(colDef.width);
let w: number;
if (ws.includes('%')) {
w = (parseFloat(ws) / 100) * resolvedTableWidth;
} else {
w = Math.max(30, pxToPt(typeof colDef.width === 'number' ? colDef.width : parseFloat(ws)));
}
widths[i] = w;
totalWidth += w;
} else {
widths[i] = 0;
}
}
const definedColumns = widths.filter(w => w > 0).length;
const undefinedColumns = totalColumns - definedColumns;
const remainingSpace = Math.max(0, resolvedTableWidth - totalWidth);
const defaultWidth = undefinedColumns > 0 ? remainingSpace / undefinedColumns : resolvedTableWidth / totalColumns;
for (let i = 0; i < totalColumns; i++) {
if (widths[i] > 0) {
colWidths[i] = widths[i];
} else {
colWidths[i] = Math.max(30, defaultWidth);
}
}
} else {
const equalWidth = resolvedTableWidth / totalColumns;
for (let i = 0; i < totalColumns; i++) {
colWidths[i] = Math.max(30, equalWidth);
}
}
const actualTotalWidth = colWidths.reduce((sum, w) => sum + w, 0);
if (Math.abs(actualTotalWidth - resolvedTableWidth) > 0.1 && actualTotalWidth > 0) {
const scale = resolvedTableWidth / actualTotalWidth;
for (let i = 0; i < colWidths.length; i++) {
colWidths[i] = colWidths[i] * scale;
}
}
const rowHeights: number[] = rows.map(row => {
if (row.type !== 'table-row') return 0;
const rh = typeof row.minHeight === 'number' ? pxToPt(row.minHeight) : 0;
return rh > 0 ? rh : Boolean((row as any).isEmptyRow) ? pxToPt(4) : 0;
});
const visibleRowIndices: number[] = [];
rows.forEach((row, i) => { if (row.type === 'table-row') visibleRowIndices.push(i); });
const newContext: RenderContext = {
...context,
parentType: 'table',
tableBorderWidth,
availableWidth: resolvedTableWidth,
colWidths: colWidths
};
const cellMap: CellEntry[][] = rows.map(() => []);
const occupiedUntil: number[] = new Array(totalColumns).fill(0);
rows.forEach((row, rowIndex) => {
if (row.type !== 'table-row') return;
const cells = ((row.children as Array<Record<string, unknown>>) ?? []).filter(c => c.type === 'table-cell');
let physCol = 0;
cells.forEach(cell => {
while (physCol < totalColumns && occupiedUntil[physCol] > rowIndex) physCol++;
if (Boolean(cell.isMerged)) { physCol += (cell.colspan as number) ?? 1; return; }
const colspan = (cell.colspan as number) ?? 1;
const rowspan = (cell.rowspan as number) ?? 1;
for (let c = 0; c < colspan; c++) {
if (physCol + c < totalColumns) occupiedUntil[physCol + c] = Math.max(occupiedUntil[physCol + c], rowIndex + rowspan);
}
cellMap[rowIndex].push({ cell, physColStart: physCol, rowspan, colspan });
physCol += colspan;
});
});
const renderCell = (
cell: Record<string, unknown>,
physColStart: number,
fixedHeight: number | undefined,
isFirstRow: boolean,
rowIsEmpty: boolean,
cellKey: string,
rowIndex: number,
): React.ReactNode => {
const colspan = (cell.colspan as number) ?? 1;
const isCellEmpty = Boolean(cell.isEmptyRow);
let cellWidth = 0;
for (let i = 0; i < colspan && physColStart + i < colWidths.length; i++) cellWidth += colWidths[physColStart + i] ?? 0;
cellWidth = Math.max(1, cellWidth);
const { paddingTop, paddingRight, paddingBottom, paddingLeft } = resolveCellPadding(cell, isCellEmpty, rowIsEmpty);
const borderBase = resolveCellBorderBase(cell, tableBorderWidth, tableBorderColor);
const { bw, bc } = borderBase;
const isFirstCol = physColStart === 0;
const horizontalAlign = getCellHorizontalAlignment(cell.align as string);
const verticalAlign = getCellVerticalAlignment(cell.valign as string);
const cellStyle: Record<string, any> = {
width: cellWidth,
paddingTop, paddingRight, paddingBottom, paddingLeft,
backgroundColor: cell.backgroundColor ? String(cell.backgroundColor) : undefined,
flexDirection: 'column',
justifyContent: verticalAlign === 'center' ? 'center' : (verticalAlign === 'flex-end' ? 'flex-end' : 'flex-start'),
alignItems: horizontalAlign === 'center' ? 'center' : (horizontalAlign === 'flex-end' ? 'flex-end' : 'flex-start'),
flexShrink: 0,
...(fixedHeight !== undefined && fixedHeight > 0 ? { minHeight: fixedHeight } : {}),
};
applyCellBorderStyle(cellStyle, cell, borderBase, isFirstRow, isFirstCol, physColStart, rowIndex, cellMap);
const effectiveBorderH = resolveEffectiveBorderH(cell, borderBase, isFirstCol);
const cellContentWidth = Math.max(0, cellWidth - paddingLeft - paddingRight - effectiveBorderH);
const cellContext: RenderContext = {
...newContext,
isInTableCell: true,
align: String(cell.align ?? 'left'),
cellWidth: cellContentWidth,
cellPadding: (paddingTop + paddingBottom) / 2,
cellBorderWidth: bw,
cellBorderColor: bc,
availableWidth: cellContentWidth,
};
const cellContent = (cell.children as Descendant[]) ?? [];
const rendered = rowIsEmpty || isCellEmpty ? null : renderSlateToPDF(cellContent, cellContext, dataSource);
return <View key={cellKey} style={cellStyle}>{rendered}</View>;
};
const processed = new Set<number>();
const tableRows: React.ReactNode[] = [];
rows.forEach((_, startRowIndex) => {
if (processed.has(startRowIndex)) return;
if (rows[startRowIndex]?.type !== 'table-row') { processed.add(startRowIndex); return; }
let groupEnd = startRowIndex + 1;
for (let ri = startRowIndex; ri < groupEnd; ri++) {
cellMap[ri]?.forEach(e => { groupEnd = Math.max(groupEnd, ri + e.rowspan); });
}
const groupRowIndices: number[] = [];
for (let ri = startRowIndex; ri < groupEnd; ri++) { groupRowIndices.push(ri); processed.add(ri); }
const isFirstGroupRow = visibleRowIndices[0] === startRowIndex;
if (groupRowIndices.length === 1) {
const rowIndex = groupRowIndices[0];
const row = rows[rowIndex] as Record<string, unknown>;
const isEmptyRow = Boolean(row.isEmptyRow);
const rowMinHeight = rowHeights[rowIndex] > 0 ? rowHeights[rowIndex] : undefined;
const rowCells = cellMap[rowIndex].map((entry, ci) =>
renderCell(entry.cell, entry.physColStart, undefined, isFirstGroupRow, isEmptyRow, `r${rowIndex}-c${ci}`, rowIndex),
);
tableRows.push(<View key={`row-${rowIndex}`} style={buildRowStyle(isEmptyRow, rowMinHeight) as any}>{rowCells}</View>);
} else {
const colBoundaries = new Set<number>([0, totalColumns]);
groupRowIndices.forEach(ri => {
cellMap[ri]?.forEach(e => {
if (e.rowspan > 1) { colBoundaries.add(e.physColStart); colBoundaries.add(e.physColStart + e.colspan); }
});
});
const colBands = Array.from(colBoundaries).sort((a, b) => a - b);
interface BandInfo {
colStart: number; colEnd: number; width: number;
rowspanCell?: CellEntry; rowspanRowIndex?: number;
localRows: { rowIndex: number; entries: CellEntry[] }[];
}
const bands: BandInfo[] = [];
for (let bi = 0; bi < colBands.length - 1; bi++) {
const colStart = colBands[bi];
const colEnd = colBands[bi + 1];
let bandWidth = 0;
for (let c = colStart; c < colEnd; c++) bandWidth += colWidths[c] ?? 0;
let rowspanCell: CellEntry | undefined;
let rowspanRowIndex: number | undefined;
for (const ri of groupRowIndices) {
const entry = cellMap[ri]?.find(e => e.physColStart <= colStart && e.physColStart + e.colspan >= colEnd && e.rowspan > 1 && ri + e.rowspan >= groupEnd);
if (entry) { rowspanCell = entry; rowspanRowIndex = ri; break; }
}
const localRows: { rowIndex: number; entries: CellEntry[] }[] = [];
for (const ri of groupRowIndices) {
const entries = (cellMap[ri] ?? []).filter(e => e.physColStart < colEnd && e.physColStart + e.colspan > colStart && !(rowspanCell && e === rowspanCell));
if (entries.length > 0) localRows.push({ rowIndex: ri, entries });
}
bands.push({ colStart, colEnd, width: bandWidth, rowspanCell, rowspanRowIndex, localRows });
}
let totalGroupHeight = 0;
groupRowIndices.forEach(ri => { totalGroupHeight += rowHeights[ri] > 0 ? rowHeights[ri] : 0; });
const bandViews = bands.map((band, bandIndex) => {
const rsCell = band.rowspanCell;
const fixedH = totalGroupHeight > 0 ? totalGroupHeight : undefined;
const key = (suffix: string) => `grp-${startRowIndex}-band${bandIndex}-${suffix}`;
if (rsCell && band.localRows.length === 0) {
return renderCell(rsCell.cell, band.colStart, fixedH, isFirstGroupRow, false, key('rs'), startRowIndex);
}
const buildLocalRowViews = (lri0: boolean) =>
band.localRows.map(({ rowIndex, entries }, lri) => {
const row = rows[rowIndex] as Record<string, unknown>;
const isEmptyRow = Boolean(row.isEmptyRow);
const rowH = rowHeights[rowIndex];
const style = buildRowStyle(isEmptyRow, rowH > 0 ? rowH : undefined);
const cellViews = entries.map((entry, ci) =>
renderCell(entry.cell, entry.physColStart, isEmptyRow && rowH > 0 ? rowH : rowH > 0 ? rowH : undefined, lri0 && lri === 0, isEmptyRow, key(`r${rowIndex}-c${ci}`), rowIndex),
);
return <View key={key(`row${rowIndex}`)} style={style as any}>{cellViews}</View>;
});
if (rsCell) {
const rsView = renderCell(rsCell.cell, band.colStart, fixedH, isFirstGroupRow, false, key('rs'), startRowIndex);
return (
<View key={key('main')} style={{ flexDirection: 'row', flexShrink: 0 }}>
{rsView}
<View style={{ flexDirection: 'column', flexShrink: 0 }}>{buildLocalRowViews(isFirstGroupRow)}</View>
</View>
);
}
return (
<View key={key('col')} style={{ flexDirection: 'column', flexShrink: 0 }}>
{buildLocalRowViews(isFirstGroupRow)}
</View>
);
});
const groupHeight = totalGroupHeight > 0 ? totalGroupHeight : undefined;
tableRows.push(
<View key={`grp-${startRowIndex}`} style={{ flexDirection: 'row', width: '100%', flexWrap: 'nowrap', flexShrink: 0, ...(groupHeight ? { minHeight: groupHeight } : {}) }}>
{bandViews}
</View>,
);
}
});
elements.push(
<View key={index} style={buildTableContainerStyle(resolvedTableWidth, tableAlign, parentIsTableCell) as any}>
<View style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
{tableRows}
</View>
</View>,
);
break;
}
case 'checkbox': {
const checked = Boolean(element.checked);
const fs = context.fontSize ?? 8;
elements.push(
<View key={index} style={{ width: fs, height: fs, borderWidth: 1, borderColor: '#000', borderStyle: 'solid', marginRight: 2, marginLeft: 1, marginTop: fs * 0.1, backgroundColor: checked ? '#000' : '#fff', flexShrink: 0, alignSelf: 'center' }} />,
);
break;
}
case 'divider': {
const thickness = Math.max(0.75, Number(element.dividerThickness ?? 1));
const color = String(element.dividerColor ?? '#000000');
const lineStyle = String(element.dividerStyle ?? 'solid');
const widthPercent = Number(element.dividerWidth ?? 100);
const divAlign = String(element.dividerAlign ?? 'left');
const availW = availableContentWidth;
const lineWidth = (widthPercent / 100) * availW;
const justifyContent = alignToJustify(divAlign);
if (lineStyle === 'dashed' || lineStyle === 'dotted') {
const isDashed = lineStyle === 'dashed';
const segH = Math.max(0.5, thickness * 0.75);
const segW = isDashed ? 6 : Math.max(0.5, thickness * 0.75);
const gap = isDashed ? 2 : Math.max(1.5, segW);
const count = Math.floor(lineWidth / (segW + gap));
const segs = Array.from({ length: count }, (_, i) => (
<View key={i} style={{ width: segW, height: segH, ...(isDashed ? {} : { borderRadius: segW / 2 }), backgroundColor: color, marginRight: gap, flexShrink: 0 }} />
));
elements.push(
<View key={index} style={{ marginVertical: 2, flexDirection: 'row', justifyContent, width: '100%' }}>
<View style={{ width: lineWidth, flexDirection: 'row', flexWrap: 'nowrap', overflow: 'hidden', alignItems: 'center' }}>{segs}</View>
</View>,
);
} else {
elements.push(
<View key={index} style={{ marginVertical: 2, flexDirection: 'row', justifyContent, width: '100%' }}>
<View style={{ width: lineWidth, height: Math.max(0.5, thickness * 0.75), backgroundColor: color }} />
</View>,
);
}
break;
}
case 'bulleted-list':
case 'numbered-list': {
const { marginTop, marginBottom, marginLeft } = resolveElementMargins(element);
const listAlign = String(element.align ?? 'left');
const listChildren = element.children as Array<Record<string, unknown>> | undefined;
elements.push(
<View key={index} style={buildListContainerStyle(marginTop, marginBottom, marginLeft, listAlign, context.isInTableCell ?? false) as any}>
{renderListItems(listChildren, listAlign, context, dataSource, element.type === 'bulleted-list')}
</View>,
);
break;
}
case 'image': {
let imageSrc = String(element.url ?? '');
imageSrc = replaceTagsWithData(imageSrc, dataSource);
const raw = extractRawBase64(imageSrc);
if (raw && isBase64ImageData(raw)) imageSrc = resolveBase64Src(raw);
if (!imageSrc) {
elements.push(
<View key={index} style={{ marginLeft: element.marginLeft !== undefined ? Math.max(0, pxToPt(Number(element.marginLeft))) : 0, marginRight: element.marginRight !== undefined ? Math.max(0, pxToPt(Number(element.marginRight))) : 0, marginTop: element.marginTop !== undefined ? Math.max(0, pxToPt(Number(element.marginTop))) : 0, marginBottom: element.marginBottom !== undefined ? Math.max(0, pxToPt(Number(element.marginBottom))) : 0, display: 'flex', width: context.isInTableCell ? '100%' : 100, maxWidth: context.isInTableCell ? '100%' : undefined, alignSelf: 'stretch', justifyContent: 'flex-start' }}>
<Text style={{ color: '#666', fontSize: 8, textAlign: 'center', padding: 4, border: '1px dashed #ccc' }}>
[Empty image - {element.s3Key ? String(element.s3Key).split('/').pop() : 'Unknown'}]
</Text>
</View>,
);
break;
}
const maxW = typeof context.cellWidth === 'number' ? context.cellWidth : availableContentWidth;
const { width, height } = resolveImageDimensions(element, maxW);
const { marginTop, marginBottom, marginLeft, marginRight } = resolveElementMargins(element);
if ((context.isHeader || context.isFooter) && height && height > (context.headerAreaHeight ?? 80) - 20) break;
const alignSelf = valignToAlignSelf((element as any).verticalAlign);
const s3Key = String(element.s3Key ?? '');
const imageSource = buildImageSource(imageSrc, s3Key);
elements.push(
<Image
key={index}
src={imageSource as any}
style={{ width, height, marginTop, marginBottom, marginLeft, marginRight, maxWidth: context.isInTableCell ? '100%' : undefined, maxHeight: context.isHeader || context.isFooter ? (context.headerAreaHeight ?? 80) - 20 : undefined, alignSelf }}
/>,
);
break;
}
case 'link': {
const url = String(element.url ?? '');
const finalUrl = url && !url.startsWith('http://') && !url.startsWith('https://') ? 'https://' + url : url;
const linkStyle: Record<string, any> = { color: '#0000EE', textDecoration: 'underline', lineHeight: 1.3 };
if (context.align) linkStyle.textAlign = context.align;
if (context.isInTableCell) { linkStyle.width = '100%'; linkStyle.wordBreak = 'break-word'; linkStyle.overflowWrap = 'break-word'; }
if (context.isHeader || context.isFooter) { linkStyle.fontSize = 10; linkStyle.color = '#666666'; }
const linkChildren = element.children as Descendant[] | undefined;
elements.push(
<Link key={index} src={finalUrl} style={linkStyle}>
{linkChildren ? renderSlateToPDF(linkChildren, context, dataSource) : null}
</Link>,
);
break;
}
default: {
if (element.children) {
const { marginTop, marginBottom, marginLeft, marginRight } = resolveElementMargins(element);
elements.push(
<View key={index} style={{ marginBottom, marginTop, marginLeft, marginRight, lineHeight: 1.3, width: context.isInTableCell ? '100%' : undefined, maxWidth: context.isInTableCell ? '100%' : undefined, flexWrap: 'wrap' }}>
{renderSlateToPDF(element.children as Descendant[], context, dataSource)}
</View>,
);
}
break;
}
}
} else if (SlateText.isText(node)) {
const rendered = renderTextNode(node, index, dataSource, context.align, context);
if (rendered) elements.push(rendered);
}
});
return elements.filter((el): el is React.ReactNode => el !== null);
};PDFHelpers.tsimport Configuration from "@/app/_api/configuration";
import axios from "axios";
import { Descendant } from "slate";
import { TagData, TAGS } from "../SlateEditor";
/* eslint-disable @typescript-eslint/no-explicit-any */
export const STANDARD_FONTS: Record<string, string> = {
// A
'Arial': 'Helvetica',
'Arial Black': 'Helvetica-Bold',
'Arial Narrow': 'Helvetica',
'Andale Mono': 'Courier',
'Avant Garde': 'Helvetica',
// B
'Baskerville': 'Times-Roman',
'Berlin Sans FB': 'Helvetica',
'Bodoni MT': 'Times-Roman',
'Book Antiqua': 'Times-Roman',
'Brush Script MT': 'Times-Italic',
// C
'Calibri': 'Helvetica',
'Cambria': 'Times-Roman',
'Candara': 'Helvetica',
'Century Gothic': 'Helvetica',
'Consolas': 'Courier',
'Constantia': 'Times-Roman',
'Copperplate Gothic': 'Helvetica-Bold',
'Corbel': 'Helvetica',
'Courier': 'Courier',
'Courier New': 'Courier',
// D
'Didot': 'Times-Roman',
// F
'Franklin Gothic Medium': 'Helvetica-Bold',
'Frutiger': 'Helvetica',
'Futura': 'Helvetica',
// G
'Garamond': 'Times-Roman',
'Geneva': 'Helvetica',
'Georgia': 'Times-Roman',
'Gill Sans': 'Helvetica',
// H
'Haettenschweiler': 'Helvetica-Bold',
'Helvetica': 'Helvetica',
// I
'Impact': 'Helvetica-Bold',
'Inter': 'Helvetica',
// L
'Lato': 'Helvetica',
'Lucida Console': 'Courier',
'Lucida Handwriting': 'Times-Italic',
// M
'Microsoft Sans Serif': 'Helvetica',
'Monaco': 'Courier',
'Montserrat': 'Helvetica',
'MS Sans Serif': 'Helvetica',
'MS Serif': 'Times-Roman',
'Myriad Pro': 'Helvetica',
// O
'Open Sans': 'Helvetica',
'Optima': 'Helvetica',
// P
'Palatino': 'Times-Roman',
'Palatino Linotype': 'Times-Roman',
'Papyrus': 'Helvetica',
'Perpetua': 'Times-Roman',
'Poppins': 'Helvetica',
// R
'Raleway': 'Helvetica',
'Roboto': 'Helvetica',
'Rockwell': 'Times-Roman',
// S
'Segoe UI': 'Helvetica',
'Source Sans Pro': 'Helvetica',
'System': 'Helvetica',
// T
'Tahoma': 'Helvetica',
'Times': 'Times-Roman',
'Times New Roman': 'Times-Roman',
'Trebuchet MS': 'Helvetica',
// U
'Ubuntu': 'Helvetica',
// V
'Verdana': 'Helvetica',
};
export const getPDFFontFamily = (fontFamily: string): string => {
if (!fontFamily) return 'Times-Roman';
const fontMap: Record<string, string> = {
'Calibri': 'Helvetica',
'Arial': 'Helvetica',
'Arial Black': 'Helvetica-Bold',
'Verdana': 'Helvetica',
'Helvetica': 'Helvetica',
'Times New Roman': 'Times-Roman',
'Times': 'Times-Roman',
'Georgia': 'Georgia',
'Courier New': 'Courier',
'Courier': 'Courier',
'Comic Sans MS': 'Comic-Sans',
'Impact': 'Impact',
'Trebuchet MS': 'Trebuchet',
'Palatino': 'Palatino',
};
if (fontMap[fontFamily]) {
return fontMap[fontFamily];
}
const lower = fontFamily.toLowerCase();
if (lower.includes('bold') || lower.includes('black')) return 'Helvetica-Bold';
if (lower.includes('italic')) return 'Times-Italic';
if (lower.includes('times') || lower.includes('serif')) return 'Times-Roman';
if (lower.includes('courier') || lower.includes('mono')) return 'Courier';
if (lower.includes('sans') || lower.includes('arial') || lower.includes('helvetica')) return 'Helvetica';
return 'Times-Roman';
};
export const fetchImageAsDataUrl = async (s3Key: string): Promise<string> => {
try {
const encodedS3Key = encodeURIComponent(s3Key);
const proxyUrl = `${Configuration.url}/api/v1.0/common/templates/images/proxy/${encodedS3Key}`;
const token = axios.defaults.headers.common.granitesess;
const response = await fetch(proxyUrl, {
headers: {
'granitesess': String(token || ''),
},
});
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (error) {
console.error('Failed to fetch image via proxy:', error);
throw error;
}
};
export const getImageSource = async (imageUrl: string, s3Key?: string): Promise<string> => {
if (!s3Key) {
return imageUrl;
}
try {
return await fetchImageAsDataUrl(s3Key);
} catch (error) {
console.error('Error fetching image from S3, falling back to imageUrl:', error);
return imageUrl;
}
};
export const processSlateContentForPDF = async (
content: Descendant[]
): Promise<Descendant[]> => {
const processNode = async (node: any): Promise<any> => {
if (node.type === 'image' && node.s3Key) {
try {
const dataUrl = await fetchImageAsDataUrl(node.s3Key);
return {
...node,
url: dataUrl,
};
} catch (error) {
console.error('Failed to process image for PDF:', node.s3Key, error);
return node;
}
}
if (node.children && Array.isArray(node.children)) {
const processedChildren = await Promise.all(
node.children.map(processNode)
);
return { ...node, children: processedChildren };
}
return node;
};
return Promise.all(content.map(processNode));
};
export const replaceTagsWithData = (text: string, dataSource: any): string => {
if (!text || typeof text !== 'string') return text || '';
let result = text.replace(/\{\{([^}]+)\}\}/g, (match, tagName) => {
const trimmedTag = tagName.trim();
if (!dataSource) {
const extractTagKey = (value: string): string => {
return value.replace(/{{|}}/g, '').trim();
};
const buildExampleData = (tags: TagData[]): Record<string, string> => {
const result: Record<string, string> = {};
const processTags = (items: TagData[]) => {
for (const tag of items) {
if (tag.value) {
const key = extractTagKey(tag.value);
if (!result[key]) {
result[key] = '';
}
}
if (tag.children?.length) {
processTags(tag.children);
}
}
};
processTags(tags);
return result;
};
const exampleData = buildExampleData(TAGS);
return exampleData[trimmedTag] || `[${trimmedTag}]`;
}
let value = '';
if (Array.isArray(dataSource) && dataSource.length > 0) {
value = dataSource[0][trimmedTag];
} else if (typeof dataSource === 'object') {
value = dataSource[trimmedTag];
}
if (value !== undefined && value !== null) {
if (value === '') {
return '';
}
let stringValue = String(value);
const isImageData = stringValue.includes('iVBORw0KGgo') ||
stringValue.includes('/9j/') ||
stringValue.includes('R0lGOD');
if (isImageData) {
stringValue = stringValue.replace(/\s/g, '');
}
return stringValue;
}
return '';
});
result = result.replace(/\\n/g, '\n');
return result;
};
export const pxToPt = (px: number): number => px * 0.75;
export const parseFontSize = (fontSize: string | number): number => {
if (typeof fontSize === 'number') return fontSize;
if (typeof fontSize === 'string') {
const lower = fontSize.toLowerCase().trim();
const sizeStr = lower.replace(/px|pt|em|rem/g, '').trim();
const size = parseFloat(sizeStr);
if (isNaN(size)) return 12;
if (lower.includes('px')) {
// Convert px to pt (1px = 0.75pt)
return size * 0.75;
}
if (lower.includes('em')) {
return size * 12;
}
// bare number strings or explicit 'pt' — treat as pt directly
return size;
}
return 12;
};
export const parseMargin = (margin: any): number => {
if (margin === undefined || margin === null) return 0;
if (typeof margin === 'number') return margin;
if (typeof margin === 'string') {
const marginStr = margin.replace(/px|pt|em|rem/gi, '').trim();
const marginVal = parseFloat(marginStr);
return isNaN(marginVal) ? 0 : Math.max(0, marginVal - 2);
}
return 0;
};
export const parseBorderWidth = (borderWidth: any): number => {
if (borderWidth === undefined || borderWidth === null) return 0;
if (typeof borderWidth === 'number') return Math.max(0, borderWidth);
if (typeof borderWidth === 'string') {
const borderStr = borderWidth.replace(/px|pt/gi, '').trim();
const borderVal = parseFloat(borderStr);
return isNaN(borderVal) ? 0 : Math.max(0, borderVal);
}
return 0;
};
export const parseColor = (color: any): string => {
if (!color || typeof color !== 'string') return '#FFFFFF';
if (color.startsWith('#')) return color;
if (color.startsWith('rgb')) return color;
const namedColors: Record<string, string> = {
'black': '#000000',
'white': '#FFFFFF',
'red': '#FF0000',
'green': '#008000',
'blue': '#0000FF',
'yellow': '#FFFF00',
'gray': '#808080',
'grey': '#808080',
};
return namedColors[color.toLowerCase()] || '#FFFFFF';
}; |

I've built a simple example that shows how to implement a Table. It's pretty straightfoward, check it out: https://github.com/Chagall/react-pdf-table-example