Files
pn-new-crm/src/lib/pdf/tiptap-to-pdfme.ts

572 lines
17 KiB
TypeScript
Raw Normal View History

/**
* TipTap JSON @pdfme Template Serializer
*
* Converts a TipTap document JSON into a @pdfme Template suitable for PDF generation.
* Supports a constrained formatting subset; unsupported nodes are rejected at validation time.
*
* Supported nodes:
* paragraph, heading (h1-h3), bulletList, orderedList, listItem,
* table, tableRow, tableCell, tableHeader, image, hardBreak,
* text (with marks: bold, italic, underline)
*
* Unsupported (rejected at save time):
* blockquote, codeBlock, horizontalRule, taskList
*/
import type { Template } from '@pdfme/common';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface TipTapMark {
type: string;
attrs?: Record<string, unknown>;
}
export interface TipTapNode {
type: string;
content?: TipTapNode[];
text?: string;
marks?: TipTapMark[];
attrs?: Record<string, unknown>;
}
// @pdfme schema field shape (matches pdfme text plugin schema)
// We use an index signature to satisfy @pdfme/common's Schema type requirement
interface SchemaField {
name: string;
type: 'text';
position: { x: number; y: number };
width: number;
height: number;
fontSize: number;
fontName?: string;
fontColor?: string;
alignment?: 'left' | 'center' | 'right';
lineHeight?: number;
[key: string]: unknown;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const PAGE_WIDTH_MM = 170; // A4 content width (210 - 20mm margins each side)
const PAGE_BREAK_THRESHOLD = 250; // y position (mm from page top) to start new logical page
const MARGIN_X_MM = 20; // Left margin
const MARGIN_TOP_MM = 20; // Top margin
const UNSUPPORTED_NODES = new Set([
'blockquote',
'codeBlock',
'horizontalRule',
'taskList',
'taskItem',
]);
// Line heights per node type (mm)
const PARAGRAPH_HEIGHT = 6;
const H1_HEIGHT = 12;
const H2_HEIGHT = 9;
const H3_HEIGHT = 7;
const LIST_ITEM_HEIGHT = 6;
const TABLE_ROW_HEIGHT = 8;
// Font sizes
const PARAGRAPH_FONT_SIZE = 10;
const H1_FONT_SIZE = 20;
const H2_FONT_SIZE = 16;
const H3_FONT_SIZE = 14;
const LIST_FONT_SIZE = 10;
const TABLE_FONT_SIZE = 9;
// ─── Template Variables ───────────────────────────────────────────────────────
export const TEMPLATE_VARIABLES: Array<{ key: string; label: string; example: string }> = [
{ key: 'client.name', label: 'Client Full Name', example: 'John Smith' },
{ key: 'client.company', label: 'Company Name', example: 'Smith Holdings' },
{ key: 'client.email', label: 'Client Email', example: 'john@smithholdings.com' },
{ key: 'client.phone', label: 'Client Phone', example: '+61 400 000 000' },
{ key: 'interest.stage', label: 'Pipeline Stage', example: 'Signed EOI/NDA' },
{ key: 'interest.berthNumber', label: 'Berth Number (from interest)', example: 'A23' },
{ key: 'berth.mooring_number', label: 'Berth Number', example: 'A23' },
{ key: 'berth.price', label: 'Berth Price', example: '$45,000' },
{ key: 'berth.tenure_type', label: 'Tenure Type', example: 'Freehold' },
{ key: 'port.name', label: 'Port Name', example: 'Port Nimara' },
{ key: 'port.currency', label: 'Port Currency', example: 'AUD' },
{ key: 'date.today', label: "Today's Date", example: '2026-03-15' },
{ key: 'date.year', label: 'Current Year', example: '2026' },
];
// ─── Validation ───────────────────────────────────────────────────────────────
/**
* Recursively walks a TipTap node tree and collects any unsupported node types.
* Returns an array of unsupported type names found, or empty array if valid.
*/
export function validateTipTapDocument(doc: TipTapNode): string[] {
const found = new Set<string>();
function walk(node: TipTapNode): void {
if (UNSUPPORTED_NODES.has(node.type)) {
found.add(node.type);
}
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
}
walk(doc);
return Array.from(found);
}
// ─── Text extraction helpers ──────────────────────────────────────────────────
/**
* Extracts plain text from a TipTap node and its children.
* Preserves hardBreak as newline.
*/
function extractText(node: TipTapNode): string {
if (node.type === 'text') {
return node.text ?? '';
}
if (node.type === 'hardBreak') {
return '\n';
}
if (!node.content || node.content.length === 0) {
return '';
}
return node.content.map(extractText).join('');
}
/**
* For a paragraph's inline content, returns text and a flag indicating bold.
* (pdfme text schema doesn't support inline mixed formatting; we honour the
* dominant mark on the paragraph level for simplicity.)
*/
function extractParagraphContent(node: TipTapNode): { text: string; bold: boolean } {
let text = '';
let hasBold = false;
if (node.content) {
for (const child of node.content) {
if (child.type === 'text') {
text += child.text ?? '';
if ((child.marks ?? []).some((m) => m.type === 'bold')) hasBold = true;
} else if (child.type === 'hardBreak') {
text += '\n';
} else {
text += extractText(child);
}
}
}
return { text, bold: hasBold };
}
// ─── Field name generation ────────────────────────────────────────────────────
let fieldCounter = 0;
function nextFieldName(prefix: string): string {
return `${prefix}_${fieldCounter++}`;
}
// ─── Serializer State ─────────────────────────────────────────────────────────
interface SerializerState {
fields: SchemaField[];
y: number;
pageIndex: number;
}
function ensurePageSpace(state: SerializerState, needed: number): void {
if (state.y + needed > PAGE_BREAK_THRESHOLD) {
state.pageIndex++;
state.y = MARGIN_TOP_MM;
}
}
// ─── Node Processors ──────────────────────────────────────────────────────────
function processParagraph(node: TipTapNode, state: SerializerState): void {
const { text, bold } = extractParagraphContent(node);
const lineCount = Math.max(1, (text.match(/\n/g) ?? []).length + 1);
const height = PARAGRAPH_HEIGHT * lineCount;
ensurePageSpace(state, height);
const field: SchemaField = {
name: nextFieldName('para'),
type: 'text',
position: { x: MARGIN_X_MM, y: state.y },
width: PAGE_WIDTH_MM,
height,
fontSize: PARAGRAPH_FONT_SIZE,
fontName: bold ? 'Helvetica-Bold' : 'Helvetica',
};
state.fields.push(field);
state.y += height + 1;
}
function processHeading(node: TipTapNode, state: SerializerState): void {
const level = (node.attrs?.level as number) ?? 1;
let height: number;
let fontSize: number;
if (level === 1) {
height = H1_HEIGHT;
fontSize = H1_FONT_SIZE;
} else if (level === 2) {
height = H2_HEIGHT;
fontSize = H2_FONT_SIZE;
} else {
height = H3_HEIGHT;
fontSize = H3_FONT_SIZE;
}
ensurePageSpace(state, height);
const field: SchemaField = {
name: nextFieldName(`h${level}`),
type: 'text',
position: { x: MARGIN_X_MM, y: state.y },
width: PAGE_WIDTH_MM,
height,
fontSize,
fontName: 'Helvetica-Bold',
};
state.fields.push(field);
state.y += height + 2;
}
function processBulletList(node: TipTapNode, state: SerializerState): void {
if (!node.content) return;
for (const item of node.content) {
if (item.type !== 'listItem') continue;
const height = LIST_ITEM_HEIGHT;
ensurePageSpace(state, height);
state.fields.push({
name: nextFieldName('bullet'),
type: 'text',
position: { x: MARGIN_X_MM + 5, y: state.y },
width: PAGE_WIDTH_MM - 5,
height,
fontSize: LIST_FONT_SIZE,
fontName: 'Helvetica',
});
state.y += height + 0.5;
}
state.y += 2;
}
function processOrderedList(node: TipTapNode, state: SerializerState): void {
if (!node.content) return;
// Use a counter that increments to build ordered list prefixes
let listIndex = (node.attrs?.start as number) ?? 1;
for (const item of node.content) {
if (item.type !== 'listItem') continue;
const height = LIST_ITEM_HEIGHT;
ensurePageSpace(state, height);
// listIndex is used via the field name prefix to distinguish items
state.fields.push({
name: nextFieldName(`ol_${listIndex}`),
type: 'text',
position: { x: MARGIN_X_MM + 5, y: state.y },
width: PAGE_WIDTH_MM - 5,
height,
fontSize: LIST_FONT_SIZE,
fontName: 'Helvetica',
});
state.y += height + 0.5;
listIndex++;
}
state.y += 2;
}
function processTable(node: TipTapNode, state: SerializerState): void {
if (!node.content) return;
const rows = node.content.filter((r) => r.type === 'tableRow');
if (rows.length === 0) return;
const firstRow = rows[0];
const colCount = firstRow?.content?.length ?? 1;
const colWidth = PAGE_WIDTH_MM / colCount;
for (const row of rows) {
if (!row.content) continue;
const rowHeight = TABLE_ROW_HEIGHT;
ensurePageSpace(state, rowHeight);
row.content.forEach((cell, colIdx) => {
const isHeader = cell.type === 'tableHeader';
state.fields.push({
name: nextFieldName(isHeader ? 'th' : 'td'),
type: 'text',
position: {
x: MARGIN_X_MM + colIdx * colWidth,
y: state.y,
},
width: colWidth - 0.5,
height: rowHeight,
fontSize: TABLE_FONT_SIZE,
fontName: isHeader ? 'Helvetica-Bold' : 'Helvetica',
});
});
state.y += rowHeight + 0.5;
}
state.y += 3;
}
// ─── Top-level Node Dispatch ──────────────────────────────────────────────────
function processNode(node: TipTapNode, state: SerializerState): void {
switch (node.type) {
case 'paragraph':
processParagraph(node, state);
break;
case 'heading':
processHeading(node, state);
break;
case 'bulletList':
processBulletList(node, state);
break;
case 'orderedList':
processOrderedList(node, state);
break;
case 'table':
processTable(node, state);
break;
case 'image':
state.y += 20;
break;
case 'hardBreak':
state.y += 3;
break;
default:
break;
}
}
// ─── Main Serializer ──────────────────────────────────────────────────────────
/**
* Converts a TipTap JSON document to a @pdfme Template.
* Variables like {{client.name}} are left as-is in text content.
* Call buildContentInputsFromDoc to get inputs with actual values.
*/
export function tipTapToPdfmeTemplate(doc: TipTapNode): Template {
fieldCounter = 0;
const state: SerializerState = {
fields: [],
y: MARGIN_TOP_MM,
pageIndex: 0,
};
const children = doc.type === 'doc' ? (doc.content ?? []) : [doc];
for (const node of children) {
processNode(node, state);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const basePdf = 'BLANK_PDF' as any;
// pdfme's Schema type has a string index signature we satisfy via the
// [key: string]: unknown on SchemaField
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schemas = [state.fields] as any;
return { basePdf, schemas } as Template;
}
// ─── Input Builder ────────────────────────────────────────────────────────────
/**
* Given a @pdfme Template and a flat keyvalue data record,
* builds empty input records keyed by field name.
* Use buildContentInputsFromDoc for content-populated inputs.
*/
export function buildTemplateInputs(
template: Template,
data: Record<string, string>,
): Record<string, string>[] {
return template.schemas.map((pageSchema) => {
const record: Record<string, string> = {};
for (const field of pageSchema as SchemaField[]) {
record[field.name] = data[field.name] ?? '';
}
return record;
});
}
/**
* Replaces all {{variable.key}} tokens in a string with values from the data map.
* Unmatched tokens are left as-is.
*/
export function substituteVariables(text: string, data: Record<string, string>): string {
return text.replace(/\{\{([^}]+)\}\}/g, (_match, key: string) => {
return data[key.trim()] ?? _match;
});
}
/**
* Public alias for internal buildContentInputs.
* Builds pdfme input records by extracting text content from the TipTap doc
* and mapping it to generated field names (in schema order).
* Use after calling tipTapToPdfmeTemplate on the same (already-substituted) doc.
*/
export function buildContentInputsFromDoc(
doc: TipTapNode,
template: Template,
): Record<string, string>[] {
return buildContentInputs(doc, template);
}
/**
* Full pipeline: validate substitute variables convert to pdfme template + inputs.
* Returns { template, inputs, errors }.
*/
export function tiptapDocumentToTemplateWithData(
doc: TipTapNode,
data: Record<string, string> = {},
): {
template: Template | null;
inputs: Record<string, string>[];
errors: string[];
} {
const errors = validateTipTapDocument(doc);
if (errors.length > 0) {
return { template: null, inputs: [], errors };
}
const substitutedDoc = substituteInDoc(doc, data);
const template = tipTapToPdfmeTemplate(substitutedDoc);
const inputs = buildContentInputs(substitutedDoc, template);
return { template, inputs, errors: [] };
}
// ─── Internals ────────────────────────────────────────────────────────────────
/**
* Deeply substitutes variables in all text nodes of a TipTap document.
*/
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) };
}
if (node.content) {
return {
...node,
content: node.content.map((child) => substituteInDoc(child, data)),
};
}
return node;
}
/**
* Builds pdfme input records by extracting text content from the TipTap doc
* and mapping it to generated field names (in schema order).
*/
function buildContentInputs(doc: TipTapNode, template: Template): Record<string, string>[] {
const textContents = extractAllTextContents(doc);
const schemas = template.schemas;
return schemas.map((pageSchema, pageIdx) => {
const record: Record<string, string> = {};
const fields = pageSchema as SchemaField[];
fields.forEach((field, fieldIdx) => {
const globalIdx =
schemas.slice(0, pageIdx).reduce((acc, ps) => acc + (ps as SchemaField[]).length, 0) +
fieldIdx;
record[field.name] = textContents[globalIdx] ?? '';
});
return record;
});
}
/**
* Extracts text content for each schema field in document order.
* Order must mirror the order that processNode() creates fields.
*/
function extractAllTextContents(doc: TipTapNode): string[] {
const contents: string[] = [];
const children = doc.type === 'doc' ? (doc.content ?? []) : [doc];
for (const node of children) {
extractNodeContent(node, contents);
}
return contents;
}
function extractNodeContent(node: TipTapNode, out: string[]): void {
switch (node.type) {
case 'paragraph': {
const { text } = extractParagraphContent(node);
out.push(text);
break;
}
case 'heading': {
out.push(extractText(node));
break;
}
case 'bulletList': {
if (node.content) {
for (const item of node.content) {
if (item.type === 'listItem') {
out.push('• ' + extractText(item).replace(/\n/g, ' '));
}
}
}
break;
}
case 'orderedList': {
if (node.content) {
let idx = (node.attrs?.start as number) ?? 1;
for (const item of node.content) {
if (item.type === 'listItem') {
out.push(`${idx}. ` + extractText(item).replace(/\n/g, ' '));
idx++;
}
}
}
break;
}
case 'table': {
if (node.content) {
for (const row of node.content) {
if (row.type !== 'tableRow' || !row.content) continue;
for (const cell of row.content) {
out.push(extractText(cell));
}
}
}
break;
}
case 'image':
out.push('');
break;
case 'hardBreak':
break;
default:
break;
}
}