20260920173439
This commit is contained in:
1 parent
f4bfc1ddfc
commit
6f03d92425
174 files changed
+23490
-157
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user