20260920173439

This commit is contained in:
oneao committed 2026-09-20 17:34:40 +08:00
1 parent f4bfc1ddfc
commit 6f03d92425
174 files changed
+23490 -157

No files matched your search

@@ -0,0 +1,33 @@
{
"name": "@report/renderer",
"version": "0.1.0",
"description": "消费 LayoutDocument 的 HTML / SVG / PDF 渲染器与浏览器打印",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@report/core": "workspace:*",
"@report/layout": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,250 @@
/**
* HTML Renderer(对应设计文档 §4.4、§3.3)。
*
* 消费 LayoutDocument,产出可直接打印的 HTML 文档字符串。
* 每页一个 <section class="rd-page">,绝对定位的节点用内联样式表达,
* 便于浏览器打印时按页分页。
*/
import type { Diagnostic } from "@report/core"
import type {
LayoutDocument,
LayoutNode,
LayoutPage,
TableContent,
} from "@report/layout"
import type { RenderOptions, TextRenderResult } from "./types.js"
import { styleToCss, toUnit, unitSuffix } from "./style.js"
/** HTML 渲染器。 */
export interface HtmlRenderer {
render(document: LayoutDocument, options?: RenderOptions): TextRenderResult
}
/** 创建 HTML 渲染器。 */
export function createHtmlRenderer(): HtmlRenderer {
return {
render(document, options = {}) {
return renderHtml(document, options)
},
}
}
/**
* 把布局文档渲染为 HTML。
*
* 产出一个完整的 HTML 文档(含 <head> 打印样式),页面用
* `page-break-after` 实现浏览器打印分页。
*/
export function renderHtml(document: LayoutDocument, options: RenderOptions = {}): TextRenderResult {
const diagnostics: Diagnostic[] = []
const unit = unitSuffix(options)
const pages = document.pages
.map((page) => renderHtmlPage(page, options, diagnostics))
.join("\n")
const content =
`<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8"/>\n` +
`<style>\n${printCss(unit)}\n</style>\n</head>\n<body>\n` +
`${pages}\n</body>\n</html>`
return {
target: "html",
pageCount: document.pageCount,
diagnostics,
content,
}
}
/** 打印样式表。 */
function printCss(unit: string): string {
return [
"* { box-sizing: border-box; margin: 0; padding: 0; }",
"body { background: #f5f5f5; }",
".rd-page {",
" position: relative;",
" background: white;",
" margin: 0 auto;",
" box-shadow: 0 1px 3px rgba(0,0,0,0.2);",
" page-break-after: always;",
"}",
".rd-node { position: absolute; overflow: hidden; }",
".rd-text { display: flex; }",
".rd-table { position: absolute; }",
".rd-table table { border-collapse: collapse; width: 100%; }",
".rd-table th, .rd-table td {",
" padding: 0;",
" overflow: hidden;",
" white-space: nowrap;",
"}",
"@media print {",
" body { background: white; }",
" .rd-page { box-shadow: none; margin: 0; }",
"}",
"",
].join("\n")
}
/** 渲染单页为 <section class="rd-page">。 */
function renderHtmlPage(
page: LayoutPage,
options: RenderOptions,
diagnostics: Diagnostic[],
): string {
const width = toUnit(page.width, options)
const height = toUnit(page.height, options)
const unit = unitSuffix(options)
const nodes = page.nodes
.map((node) => renderHtmlNode(node, options, diagnostics))
.join("\n")
return (
`<section class="rd-page" style="width:${width}${unit};height:${height}${unit}">\n` +
`${nodes}\n` +
`</section>`
)
}
/** 渲染单个布局节点为 HTML 元素。 */
function renderHtmlNode(
node: LayoutNode,
options: RenderOptions,
diagnostics: Diagnostic[],
): string {
const x = toUnit(node.frame.x, options)
const y = toUnit(node.frame.y, options)
const width = toUnit(node.frame.width, options)
const height = toUnit(node.frame.height, options)
const unit = unitSuffix(options)
const pos = `position:absolute;left:${x}${unit};top:${y}${unit};width:${width}${unit};height:${height}${unit}`
const id = escapeAttr(node.id)
switch (node.type) {
case "text": {
const content = node.content
if (content?.kind !== "text") return ""
const css = styleToCss(node.style)
const text = escapeHtml(content.text).replace(/\n/g, "<br/>")
return (
`<div class="rd-node rd-text" id="${id}" style="${pos};${css}">` +
`<span>${text}</span></div>`
)
}
case "rect": {
const css = styleToCss(node.style)
return `<div class="rd-node" id="${id}" style="${pos};${css}"></div>`
}
case "line": {
const content = node.content
if (content?.kind !== "line") return ""
const color = node.style.color ?? "#000000"
const thickness = toUnit(content.thickness, options)
const isVertical = content.direction === "vertical"
const lineCss =
isVertical
? `border-left:${thickness}${unit} solid ${color}`
: `border-top:${thickness}${unit} solid ${color}`
return `<div class="rd-node" id="${id}" style="${pos};${lineCss}"></div>`
}
case "image": {
const content = node.content
if (content?.kind !== "image") return ""
if (!content.src) {
diagnostics.push({
severity: "warning",
code: "render.image-missing",
message: `图片元素 "${node.id}" 缺少 src`,
elementId: node.id,
})
return ""
}
const fit = content.fit === "fill" ? "100% 100%" : content.fit
return (
`<div class="rd-node" id="${id}" style="${pos}">` +
`<img src="${escapeAttr(content.src)}" style="width:100%;height:100%;object-fit:${fit}"/>` +
`</div>`
)
}
case "table": {
const content = node.content
if (content?.kind !== "table") return ""
return renderHtmlTable(node, content, options)
}
default: {
diagnostics.push({
severity: "warning",
code: "render.unknown-node",
message: `不支持的节点类型 "${node.type}",已跳过`,
elementId: node.id,
})
return ""
}
}
}
/** 渲染表格节点为 HTML 表格。 */
function renderHtmlTable(
node: LayoutNode,
content: TableContent,
options: RenderOptions,
): string {
const x = toUnit(node.frame.x, options)
const y = toUnit(node.frame.y, options)
const width = toUnit(node.frame.width, options)
const height = toUnit(node.frame.height, options)
const unit = unitSuffix(options)
const id = escapeAttr(node.id)
const colgroup = content.columns
.map((col) => `<col style="width:${toUnit(col.width, options)}${unit}"/>`)
.join("")
const headerRow = content.showHeader
? `<tr>${content.columns
.map((col) => {
const align = `text-align:${col.align}`
return `<th style="${align}">${escapeHtml(col.header ?? "")}</th>`
})
.join("")}</tr>`
: ""
const bodyRows = content.rows
.map((row) => {
const cells = content.columns
.map((col) => {
const cell = row.cells.find((c) => c.columnId === col.id)
const align = `text-align:${col.align}`
return `<td style="${align}">${escapeHtml(cell?.text ?? "")}</td>`
})
.join("")
return `<tr>${cells}</tr>`
})
.join("")
const borderCss = content.showGrid ? "border-collapse:collapse;" : ""
const cellBorder = content.showGrid ? "border:1px solid #000;" : ""
return (
`<div class="rd-node rd-table" id="${id}" ` +
`style="position:absolute;left:${x}${unit};top:${y}${unit};width:${width}${unit};height:${height}${unit}">` +
`<table style="width:100%;${borderCss}">` +
`<colgroup>${colgroup}</colgroup>` +
(content.showHeader ? `<thead>${headerRow}</thead>` : "") +
`<tbody>${bodyRows}</tbody>` +
`</table></div>`
)
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
function escapeAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
@@ -0,0 +1,29 @@
/**
* @report/renderer 公共入口(对应设计文档 §4.4、§3.3)。
*
* Renderer 只消费 LayoutDocument,把分页结果绘制成 HTML / SVG / PDF。
* 所有 Renderer 共享同一份布局结果,保证分页结构一致(§15)。
*/
export type {
RenderTarget,
RenderResult,
TextRenderResult,
BinaryRenderResult,
RenderOptions,
Renderer,
} from "./types.js"
// 样式与坐标共享工具
export { styleToCss, styleToSvgTextAttrs, toUnit, unitSuffix } from "./style.js"
// SVG Renderer
export type { SvgRenderer } from "./svg.js"
export { createSvgRenderer, renderSvg } from "./svg.js"
// HTML Renderer
export type { HtmlRenderer } from "./html.js"
export { createHtmlRenderer, renderHtml } from "./html.js"
// PDF Renderer 契约
export type { PdfBackend, PdfRenderer } from "./pdf.js"
export { createPdfRenderer } from "./pdf.js"
@@ -0,0 +1,78 @@
/**
* PDF Renderer 契约(对应设计文档 §4.4、§3.3)。
*
* PDF 的具体后端可运行在浏览器或 Node/服务端,但都遵循统一的
* Renderer 接口。本包只提供契约与一个"占位"实现,真实 PDF 生成
* 由阶段 5 结合部署环境确定后端(§16)。
*
* 设计要点:
* - PDF 是二进制输出,结果携带 Uint8Array。
* - 后端缺失或未配置时返回诊断而非抛异常(§13)。
*/
import type { Diagnostic } from "@report/core"
import type { LayoutDocument } from "@report/layout"
import type { BinaryRenderResult, RenderOptions } from "./types.js"
/**
* PDF 后端契约。
*
* 宿主实现此接口,把 LayoutDocument 转成 PDF 字节。
* 典型后端:浏览器端 pdf-lib / jsPDF,Node 端 Puppeteer / pdfkit。
*/
export interface PdfBackend {
/** 后端名称,用于诊断与日志。 */
readonly name: string
/** 把布局文档渲染为 PDF 字节。 */
render(document: LayoutDocument, options?: RenderOptions): Uint8Array
}
/** PDF 渲染器。 */
export interface PdfRenderer {
render(document: LayoutDocument, options?: RenderOptions): BinaryRenderResult
}
/** 创建 PDF 渲染器。 */
export function createPdfRenderer(backend?: PdfBackend): PdfRenderer {
return {
render(document, options = {}) {
const diagnostics: Diagnostic[] = []
if (!backend) {
diagnostics.push({
severity: "warning",
code: "render.pdf-backend-missing",
message:
"未配置 PDF 后端;请注入 PdfBackend(如 pdf-lib / jsPDF / Puppeteer)。" +
"当前返回空字节。",
})
return {
target: "pdf",
pageCount: document.pageCount,
diagnostics,
bytes: new Uint8Array(0),
}
}
try {
const bytes = backend.render(document, options)
return {
target: "pdf",
pageCount: document.pageCount,
diagnostics,
bytes,
}
} catch (error) {
diagnostics.push({
severity: "error",
code: "render.pdf-backend-error",
message: `PDF 后端 "${backend.name}" 渲染失败:${(error as Error)?.message ?? String(error)}`,
})
return {
target: "pdf",
pageCount: document.pageCount,
diagnostics,
bytes: new Uint8Array(0),
}
}
},
}
}
@@ -0,0 +1,61 @@
/**
* 样式与坐标转换的共享工具(HTML / SVG 共用)。
*
* 布局结果内部统一使用 mm;SVG/HTML 输出按需换算为 px。
* 样式统一转成 CSS 声明,保证各 Renderer 视觉一致(§15)。
*/
import type { ResolvedStyle } from "@report/core"
import { mmToPx } from "@report/layout"
import type { RenderOptions } from "./types.js"
/** 把 mm 值按目标单位换算为数值(SVG/HTML 里直接写数字)。 */
export function toUnit(value: number, options: RenderOptions): number {
return options.unit === "px" ? mmToPx(value) : value
}
/** 单位后缀。 */
export function unitSuffix(options: RenderOptions): string {
return options.unit === "px" ? "px" : "mm"
}
/**
* 把已解析样式转成 CSS 声明字符串。
*
* 字号在文档中是 pt,HTML/SVG 里也以 pt 表达,保证与布局测量一致。
* 边框只输出四边统一值;逐边覆盖的边框由 Renderer 按需处理。
*/
export function styleToCss(style: ResolvedStyle): string {
const parts: string[] = []
parts.push(`font-family:${style.fontFamily}`)
parts.push(`font-size:${style.fontSize}pt`)
parts.push(`font-weight:${style.fontWeight}`)
parts.push(`font-style:${style.fontStyle}`)
parts.push(`line-height:${style.lineHeight}`)
parts.push(`color:${style.color}`)
if (style.background && style.background !== "transparent") {
parts.push(`background:${style.background}`)
}
parts.push(`text-align:${style.align}`)
parts.push(`text-decoration:${style.textDecoration}`)
if (style.border) {
const width = style.border.width ?? 0.1
const color = style.border.color ?? "#000000"
const lineStyle = style.border.style ?? "solid"
parts.push(`border:${width}mm ${lineStyle} ${color}`)
}
return parts.join(";")
}
/** 把已解析样式转成 SVG 文本属性(fill / font-size 等)。 */
export function styleToSvgTextAttrs(style: ResolvedStyle): Record<string, string> {
const attrs: Record<string, string> = {
fill: style.color,
"font-family": style.fontFamily,
"font-size": `${style.fontSize}pt`,
"font-weight": String(style.fontWeight),
"font-style": style.fontStyle,
}
if (style.textDecoration === "underline") attrs["text-decoration"] = "underline"
if (style.textDecoration === "line-through") attrs["text-decoration"] = "line-through"
return attrs
}
@@ -0,0 +1,293 @@
/**
* SVG Renderer(对应设计文档 §4.4、§3.3)。
*
* 消费 LayoutDocument,产出可直接打印的 SVG 文档字符串。
* SVG 的 viewBox 以 mm 为单位标注,便于与打印尺寸精确对应。
*/
import type { Diagnostic } from "@report/core"
import type {
LayoutDocument,
LayoutNode,
LayoutPage,
TableContent,
} from "@report/layout"
import type { RenderOptions, TextRenderResult } from "./types.js"
import { styleToSvgTextAttrs } from "./style.js"
/** SVG 渲染器。 */
export interface SvgRenderer {
render(document: LayoutDocument, options?: RenderOptions): TextRenderResult
}
/** 创建 SVG 渲染器。 */
export function createSvgRenderer(): SvgRenderer {
return {
render(document, options = {}) {
return renderSvg(document, options)
},
}
}
/**
* 把布局文档渲染为 SVG。
*
* 每个页面产出一个 <svg>,包含在单个 <g class="rd-pages"> 下,
* 页面之间用空行分隔,便于宿主逐页切分或整体打印。
*/
export function renderSvg(document: LayoutDocument, options: RenderOptions = {}): TextRenderResult {
const diagnostics: Diagnostic[] = []
const pages = document.pages.map((page) => renderSvgPage(page, options, diagnostics))
const content = pages.join("\n\n")
return {
target: "svg",
pageCount: document.pageCount,
diagnostics,
content,
}
}
/** 渲染单页为 <svg> 元素。 */
function renderSvgPage(
page: LayoutPage,
options: RenderOptions,
diagnostics: Diagnostic[],
): string {
const width = page.width
const height = page.height
const nodes = page.nodes.map((node) => renderSvgNode(node, options, diagnostics)).join("\n")
return (
`<svg xmlns="http://www.w3.org/2000/svg" ` +
`width="${width}mm" height="${height}mm" viewBox="0 0 ${width} ${height}">\n` +
`<rect x="0" y="0" width="${width}" height="${height}" fill="white"/>\n` +
`${nodes}\n` +
`</svg>`
)
}
/** 渲染单个布局节点为 SVG 元素。 */
function renderSvgNode(
node: LayoutNode,
options: RenderOptions,
diagnostics: Diagnostic[],
): string {
const { x, y, width, height } = node.frame
const id = escapeAttr(node.id)
switch (node.type) {
case "text": {
const content = node.content
if (content?.kind !== "text") return ""
const attrs = styleToSvgTextAttrs(node.style)
const lines = content.lines
// 逐行输出 <text>,行高按 lineHeight(mm)累加
const lineHeightMm = content.lineHeight
const anchor = node.style.align === "center" ? "middle" : node.style.align === "right" ? "end" : "start"
const textX = anchor === "start" ? x : anchor === "middle" ? x + width / 2 : x + width
const rows = lines
.map((line, i) => {
const ly = y + lineHeightMm * (i + 1)
return (
`<text id="${id}" x="${textX}" y="${ly}" ` +
`text-anchor="${anchor}" ${attrsToStr(attrs)}>${escapeText(line)}</text>`
)
})
.join("\n")
return rows
}
case "rect": {
const fill = node.style.background && node.style.background !== "transparent"
? node.style.background
: "none"
const stroke = node.style.border ? (node.style.border.color ?? "#000000") : "none"
const strokeWidth = node.style.border ? (node.style.border.width ?? 0.1) : 0
return (
`<rect id="${id}" x="${x}" y="${y}" width="${width}" height="${height}" ` +
`fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`
)
}
case "line": {
const content = node.content
if (content?.kind !== "line") return ""
const color = node.style.color ?? "#000000"
const thickness = content.thickness
const direction = content.direction
if (direction === "vertical") {
const cx = x + width / 2
return (
`<line id="${id}" x1="${cx}" y1="${y}" x2="${cx}" y2="${y + height}" ` +
`stroke="${color}" stroke-width="${thickness}"/>`
)
}
const cy = y + height / 2
return (
`<line id="${id}" x1="${x}" y1="${cy}" x2="${x + width}" y2="${cy}" ` +
`stroke="${color}" stroke-width="${thickness}"/>`
)
}
case "image": {
const content = node.content
if (content?.kind !== "image") return ""
// SVG 图片:若无 src 则记录诊断并跳过
if (!content.src) {
diagnostics.push({
severity: "warning",
code: "render.image-missing",
message: `图片元素 "${node.id}" 缺少 src`,
elementId: node.id,
})
return ""
}
const preserve = fitToPreserve(content.fit)
return (
`<image id="${id}" x="${x}" y="${y}" width="${width}" height="${height}" ` +
`href="${escapeAttr(content.src)}" preserveAspectRatio="${preserve}"/>`
)
}
case "table": {
const content = node.content
if (content?.kind !== "table") return ""
return renderSvgTable(node, content, options, diagnostics)
}
default: {
// 未知节点类型:不抛异常,记录诊断并跳过(§13)
diagnostics.push({
severity: "warning",
code: "render.unknown-node",
message: `不支持的节点类型 "${node.type}",已跳过`,
elementId: node.id,
})
return ""
}
}
}
/** 渲染表格节点(含表头、行、单元格网格)。 */
function renderSvgTable(
node: LayoutNode,
content: TableContent,
options: RenderOptions,
diagnostics: Diagnostic[],
): string {
const { x, y, width } = node.frame
const parts: string[] = []
const headerHeight = content.headerHeight
const rowHeight = content.rowHeight
// 表格外框
if (content.showGrid) {
parts.push(
`<rect x="${x}" y="${y}" width="${width}" height="${node.frame.height}" ` +
`fill="none" stroke="#000000" stroke-width="0.1"/>`,
)
}
// 列分隔线
if (content.showGrid) {
let cx = x
for (const col of content.columns) {
cx += col.width
parts.push(
`<line x1="${cx}" y1="${y}" x2="${cx}" y2="${y + node.frame.height}" ` +
`stroke="#000000" stroke-width="0.1"/>`,
)
}
}
// 表头
if (content.showHeader) {
const rowY = y
for (const col of content.columns) {
if (col.header === undefined) continue
const colX = x + col.x
const attrs = styleToSvgTextAttrs({ ...defaultCellStyle(), align: col.align })
const anchor = col.align === "center" ? "middle" : col.align === "right" ? "end" : "start"
const textX = anchor === "start" ? colX : anchor === "middle" ? colX + col.width / 2 : colX + col.width
parts.push(
`<text x="${textX}" y="${rowY + headerHeight / 2}" text-anchor="${anchor}" ` +
`${attrsToStr(attrs)}>${escapeText(col.header)}</text>`,
)
}
if (content.showGrid) {
parts.push(
`<line x1="${x}" y1="${y + headerHeight}" x2="${x + width}" y2="${y + headerHeight}" ` +
`stroke="#000000" stroke-width="0.1"/>`,
)
}
}
// 数据行
let rowCursor = y + (content.showHeader ? headerHeight : 0)
for (const row of content.rows) {
const rowH = row.height
for (const cell of row.cells) {
const col = content.columns.find((c) => c.id === cell.columnId)
if (!col) continue
const colX = x + col.x
const align = col.align
const anchor = align === "center" ? "middle" : align === "right" ? "end" : "start"
const textX = anchor === "start" ? colX : anchor === "middle" ? colX + col.width / 2 : colX + col.width
const attrs = styleToSvgTextAttrs({ ...defaultCellStyle(), align })
parts.push(
`<text x="${textX}" y="${rowCursor + rowH / 2}" text-anchor="${anchor}" ` +
`${attrsToStr(attrs)}>${escapeText(cell.text)}</text>`,
)
}
if (content.showGrid) {
parts.push(
`<line x1="${x}" y1="${rowCursor + rowH}" x2="${x + width}" y2="${rowCursor + rowH}" ` +
`stroke="#000000" stroke-width="0.1"/>`,
)
}
rowCursor += rowH
}
return parts.join("\n")
}
function defaultCellStyle(): import("@report/core").ResolvedStyle {
return {
fontFamily: "sans-serif",
fontSize: 10,
fontWeight: "normal",
fontStyle: "normal",
lineHeight: 1.2,
color: "#000000",
background: "transparent",
padding: { top: 0, right: 0, bottom: 0, left: 0 },
align: "left",
verticalAlign: "top",
textDecoration: "none",
}
}
function fitToPreserve(fit: "contain" | "cover" | "fill" | "none"): string {
switch (fit) {
case "contain": return "xMidYMid meet"
case "cover": return "xMidYMid slice"
case "none": return "none"
default: return "none"
}
}
function attrsToStr(attrs: Record<string, string>): string {
return Object.entries(attrs)
.map(([k, v]) => `${k}="${escapeAttr(v)}"`)
.join(" ")
}
function escapeText(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
function escapeAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
@@ -0,0 +1,64 @@
/**
* Renderer 统一契约(对应设计文档 §4.4、§3.3)。
*
* Renderer 只消费 LayoutDocument,绝不重新计算分页。
* 所有 Renderer 针对同一份 LayoutDocument 产出结构一致的输出,
* 以保证 HTML / SVG / PDF 的分页结果一致(§15)。
*/
import type { LayoutDocument } from "@report/layout"
/** 渲染目标格式。 */
export type RenderTarget = "html" | "svg" | "pdf"
/**
* 渲染器输出。
*
* 各 Renderer 的具体产物不同(HTML 字符串、SVG 字符串、PDF 字节),
* 但都遵循这一统一契约,便于宿主在多个目标间切换。
*/
export interface RenderResult {
/** 目标格式。 */
target: RenderTarget
/** 分页结果快照(供宿主校验分页一致性)。 */
pageCount: number
/** 渲染过程中的诊断(缺失资源、不支持的节点类型等)。 */
diagnostics: import("@report/core").Diagnostic[]
}
/** HTML / SVG 这类文本型输出的结果。 */
export interface TextRenderResult extends RenderResult {
/** 产出的文本(HTML 或 SVG 文档字符串)。 */
content: string
}
/** 二进制型输出(PDF)的结果。 */
export interface BinaryRenderResult extends RenderResult {
/** 产出的字节(PDF 等)。 */
bytes: Uint8Array
}
/** 渲染选项。 */
export interface RenderOptions {
/**
* 目标单位。默认 "mm"。
*
* 布局结果内部统一为 mm;SVG 输出通常按 mm 标注 viewBox 便于打印,
* HTML 输出可按 mm 或 px。px 换算走 1in = 96px。
*/
unit?: "mm" | "px"
/** 是否内联样式(默认 true,便于打印与独立保存)。 */
inlineStyle?: boolean
}
/**
* Renderer 契约。
*
* 每个目标格式实现一个 Renderer。PDF 的具体后端可在浏览器或
* Node/服务端实现,但都必须实现本契约(§4.4)。
*/
export interface Renderer<TResult extends RenderResult = RenderResult> {
/** 目标格式。 */
readonly target: RenderTarget
/** 把布局文档渲染为目标格式。 */
render(document: LayoutDocument, options?: RenderOptions): TResult
}
@@ -0,0 +1,220 @@
/**
* Renderer 一致性测试(对应设计文档 §15 的
* "HTML、SVG、PDF Renderer 的公共布局结果一致性测试")。
*
* 核心断言:同一份 LayoutDocument 经 HTML / SVG 渲染后,
* 分页结构一致(页数相同、节点都能找到)。
*/
import { describe, expect, it } from "vitest"
import { createDocument, createTableElement, type ReportDocument } from "@report/core"
import { createEstimateMeasurer, createPassthroughFontResolver } from "@report/layout"
import { layoutDocument } from "@report/layout"
import { renderHtml, renderSvg, createPdfRenderer } from "../src/index.js"
import type { RenderContext } from "@report/layout"
const measurer = createEstimateMeasurer()
const fontResolver = createPassthroughFontResolver()
async function layout(doc: ReportDocument, context: RenderContext) {
return layoutDocument(doc, context, { textMeasurer: measurer, fontResolver })
}
/** 构造一份含页眉、文本、明细表、页脚的报表文档。 */
function sampleDoc(): ReportDocument {
const doc = createDocument({ name: "渲染测试" })
doc.datasets = { sales: { id: "sales" } }
const header = doc.sections.find((s) => s.kind === "page-header")!
header.children.push({
id: "h1",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
props: { text: "销售单" },
})
const detail = doc.sections.find((s) => s.kind === "detail")!
detail.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "sales", [
{ id: "c1", width: 90, header: "商品", binding: { field: "name" } },
{ id: "c2", width: 90, header: "金额", binding: { field: "amount" }, align: "right" },
]),
)
const footer = doc.sections.find((s) => s.kind === "page-footer")!
footer.children.push({
id: "f1",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 6 },
binding: { expression: "'第 ' + page.pageNumber + ' 页'" },
})
return doc
}
const ROWS = [
{ name: "苹果", amount: 100 },
{ name: "香蕉", amount: 200 },
{ name: "橙子", amount: 300 },
]
describe("HTML / SVG 分页一致性", () => {
it("同一文档渲染为 HTML 与 SVG 时页数一致", async () => {
const doc = sampleDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const html = renderHtml(result)
const svg = renderSvg(result)
expect(html.target).toBe("html")
expect(svg.target).toBe("svg")
expect(html.pageCount).toBe(result.pageCount)
expect(svg.pageCount).toBe(result.pageCount)
expect(html.pageCount).toBe(svg.pageCount)
})
it("HTML 输出包含与页数一致的 .rd-page 容器", async () => {
const doc = sampleDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const html = renderHtml(result)
const pageCount = (html.content.match(/class="rd-page"/g) ?? []).length
expect(pageCount).toBe(result.pageCount)
})
it("SVG 输出包含与页数一致的 <svg> 元素", async () => {
const doc = sampleDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const svg = renderSvg(result)
const svgCount = (svg.content.match(/<svg /g) ?? []).length
expect(svgCount).toBe(result.pageCount)
})
it("文本节点在 HTML 与 SVG 中都被渲染", async () => {
const doc = sampleDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const html = renderHtml(result)
const svg = renderSvg(result)
// 页眉文本 "销售单" 应出现在两种输出中
expect(html.content).toContain("销售单")
expect(svg.content).toContain("销售单")
})
it("明细表数据在 HTML 与 SVG 中都被渲染", async () => {
const doc = sampleDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const html = renderHtml(result)
const svg = renderSvg(result)
expect(html.content).toContain("苹果")
expect(html.content).toContain("橙子")
expect(svg.content).toContain("苹果")
expect(svg.content).toContain("橙子")
})
it("页脚页码占位符在渲染前已被替换", async () => {
const doc = sampleDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const html = renderHtml(result)
// 页脚应有 "第 1 页" 而非原始占位符
expect(html.content).toContain("第 1 页")
expect(html.content).not.toContain("{pageNumber}")
})
})
describe("多页渲染", () => {
it("多页文档在 HTML 与 SVG 中页数一致", async () => {
const doc = sampleDoc()
const manyRows = Array.from({ length: 200 }, (_, i) => ({
name: `商品${i}`,
amount: i,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
expect(result.pageCount).toBeGreaterThan(1)
const html = renderHtml(result)
const svg = renderSvg(result)
const htmlPages = (html.content.match(/class="rd-page"/g) ?? []).length
const svgPages = (svg.content.match(/<svg /g) ?? []).length
expect(htmlPages).toBe(result.pageCount)
expect(svgPages).toBe(result.pageCount)
})
})
describe("SVG 输出结构", () => {
it("SVG 使用 mm 标注宽度和 viewBox", async () => {
const doc = sampleDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const svg = renderSvg(result)
const page = result.pages[0]!
// width 与 height 用 mm,viewBox 用无单位数值
expect(svg.content).toContain(`width="${page.width}mm"`)
expect(svg.content).toContain(`viewBox="0 0 ${page.width} ${page.height}"`)
})
it("矩形节点渲染为 <rect>", async () => {
const doc = createDocument({ name: "矩形测试" })
const detail = doc.sections.find((s) => s.kind === "detail")!
detail.children.push({
id: "r1",
type: "rect",
frame: { x: 5, y: 5, width: 50, height: 20 },
})
const result = await layout(doc, { data: {}, parameters: {} })
const svg = renderSvg(result)
expect(svg.content).toContain("<rect")
expect(svg.content).toContain('id="r1"')
})
})
describe("PDF 后端契约", () => {
it("未配置后端时返回诊断而非抛异常", () => {
const result = createPdfRenderer().render({
pages: [],
diagnostics: [],
pageCount: 0,
})
expect(result.target).toBe("pdf")
expect(result.bytes).toBeInstanceOf(Uint8Array)
expect(result.bytes.length).toBe(0)
expect(result.diagnostics.some((d) => d.code === "render.pdf-backend-missing")).toBe(true)
})
it("后端抛错时返回错误诊断", () => {
const backend = {
name: "mock",
render: () => {
throw new Error("boom")
},
}
const result = createPdfRenderer(backend).render({
pages: [],
diagnostics: [],
pageCount: 0,
})
expect(result.diagnostics.some((d) => d.code === "render.pdf-backend-error")).toBe(true)
})
it("后端正常时返回字节", () => {
const backend = {
name: "mock",
render: () => new Uint8Array([1, 2, 3]),
}
const result = createPdfRenderer(backend).render({
pages: [],
diagnostics: [],
pageCount: 0,
})
expect(result.bytes.length).toBe(3)
expect(result.diagnostics).toHaveLength(0)
})
})
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "*.config.ts"]
}
@@ -0,0 +1,11 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
treeshake: true,
target: "es2022",
})