You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
155 lines
4.4 KiB
155 lines
4.4 KiB
/**
|
|
* 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<Renderer> {
|
|
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<void> {
|
|
// 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<Blob> {
|
|
await renderDocument(root, { renderer })
|
|
return renderer.toBlob()
|
|
}
|
|
|
|
/**
|
|
* Export document to data URL
|
|
*/
|
|
export async function exportToDataURL(root: Node, renderer: Renderer): Promise<string> {
|
|
await renderDocument(root, { renderer })
|
|
return renderer.toDataURL()
|
|
}
|