/** * Document Renderer * * High-level document rendering that combines layout calculation with PDF rendering. */ import type { Node, LayoutBox, DocumentNode } from '../core/model/types' import { isDocumentNode, hasChildren, isTextNode, isImageNode, isDividerNode, isBoxNode } from '../core/model/types' import { getPageDimensions, normalizeSpacing } from '../core/model/node' import { calculateLayout } from '../core/layout' import type { Renderer } from './types' import { nodeStyleToTextStyle, nodeStyleToRectStyle } from './types' /** * Options for document rendering */ export interface DocumentRenderOptions { /** Renderer instance to use */ renderer: Renderer } /** * Render a document tree to PDF */ export async function renderDocument(root: Node, options: DocumentRenderOptions): Promise { const { renderer } = options // Validate root is a document node if (!isDocumentNode(root)) { throw new Error('Root node must be a document node') } // Get page dimensions const pageSettings = root.props.pageSettings const { width: pageWidth, height: pageHeight } = getPageDimensions( pageSettings.size, pageSettings.orientation ) const margins = normalizeSpacing(pageSettings.margins ?? [40, 40, 40, 40]) // Begin document renderer.beginDocument({ pageWidth, pageHeight, metadata: root.props.metadata, }) // Calculate layout for each child const contentWidth = pageWidth - margins.left - margins.right const contentHeight = pageHeight - margins.top - margins.bottom let currentPage = 1 let currentY = margins.top let remainingHeight = contentHeight renderer.beginPage(currentPage) // Render children const children = root.children ?? [] for (const child of children) { const layoutBox = calculateLayout(child, contentWidth, remainingHeight) // Check if content fits on current page if (layoutBox.height > remainingHeight && currentY > margins.top) { // Need new page currentPage++ renderer.endPage() renderer.beginPage(currentPage) currentY = margins.top remainingHeight = contentHeight } // Offset the layout box to correct position const offsetBox: LayoutBox = { ...layoutBox, x: margins.left + layoutBox.x, y: currentY + layoutBox.y, } // Render the node await renderNode(child, offsetBox, renderer) // Update position currentY += layoutBox.height remainingHeight -= layoutBox.height } renderer.endPage() renderer.endDocument() return renderer } /** * Render a single node and its children */ async function renderNode(node: Node, box: LayoutBox, renderer: Renderer): Promise { // Render background and border for containers if (hasChildren(node)) { const rectStyle = nodeStyleToRectStyle(node.style) if (rectStyle.backgroundColor || rectStyle.borderColor) { renderer.drawRect(box, rectStyle) } } // Render based on node type if (isTextNode(node)) { const textStyle = nodeStyleToTextStyle(node.style) renderer.drawText(node.props.value, box, textStyle) } else if (isImageNode(node)) { await renderer.drawImage(node.props.src, box) } else if (isDividerNode(node)) { const thickness = node.props?.thickness ?? 1 const color = node.style?.borderColor ?? '#000000' const style = node.props?.style ?? 'solid' if (node.props?.orientation === 'vertical') { renderer.drawLine( { x: box.x + box.width / 2, y: box.y }, { x: box.x + box.width / 2, y: box.y + box.height }, { color, thickness, style } ) } else { renderer.drawLine( { x: box.x, y: box.y + thickness / 2 }, { x: box.x + box.width, y: box.y + thickness / 2 }, { color, thickness, style } ) } } else if (hasChildren(node) && box.children) { // Render children for (let i = 0; i < node.children!.length; i++) { const child = node.children![i] const childBox = box.children[i] await renderNode(child, childBox, renderer) } } } /** * Export document to PDF blob */ export async function exportToBlob(root: Node, renderer: Renderer): Promise { await renderDocument(root, { renderer }) return renderer.toBlob() } /** * Export document to data URL */ export async function exportToDataURL(root: Node, renderer: Renderer): Promise { await renderDocument(root, { renderer }) return renderer.toDataURL() }