20260920173439
This commit is contained in:
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
Reference in new issue
Block a user