/** * 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; } export interface TipTapNode { type: string; content?: TipTapNode[]; text?: string; marks?: TipTapMark[]; attrs?: Record; } // @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: 'A-23' }, { key: 'berth.mooring_number', label: 'Berth Number', example: 'A-23' }, { 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(); 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 key→value data record, * builds empty input records keyed by field name. * Use buildContentInputsFromDoc for content-populated inputs. */ export function buildTemplateInputs( template: Template, data: Record, ): Record[] { return template.schemas.map((pageSchema) => { const record: Record = {}; 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 { 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[] { 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 = {}, ): { template: Template | null; inputs: Record[]; 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, ): 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[] { const textContents = extractAllTextContents(doc); const schemas = template.schemas; return schemas.map((pageSchema, pageIdx) => { const record: Record = {}; 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; } }