Files
qhmes/yy-admin-master/YY.Admin.Services/Service/Print/NativePrintRenderService.cs

1148 lines
58 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Globalization;
using QRCoder;
namespace YY.Admin.Services.Service.Print;
/// <summary>
/// 将后端「原生打印模板」JSON 渲染为 HTML再通过调用方传入的 WebView2 实例导出 PDF base64。
/// 支持元素类型text/title/subtitle/date/pageNo/qrcode/barcode/image/freeTable/table/detailTable。
/// </summary>
public static class NativePrintRenderService
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
// ─── 入口 ──────────────────────────────────────────────────────────────
/// <summary>
/// 将模板 JSON + 数据对象渲染为完整的可打印 HTML 页面(自包含,无外部依赖)。
/// 屏幕样式与后端前端预览保持一致:深灰底(#525659+ 白纸居中 + 页间分割线。
/// </summary>
public static string RenderToHtml(string templateJson, JsonObject data)
{
var schema = JsonNode.Parse(templateJson) ?? throw new ArgumentException("无效模板 JSON");
var page = schema["page"] ?? throw new ArgumentException("模板缺少 page 配置");
var widthMm = page["width"]?.GetValue<double>() ?? 210;
var heightMm = page["height"]?.GetValue<double>() ?? 297;
var margin = page["margin"]?.AsArray();
var mt = margin?[0]?.GetValue<double>() ?? 0;
var mr = margin?[1]?.GetValue<double>() ?? 0;
var mb = margin?[2]?.GetValue<double>() ?? 0;
var ml = margin?[3]?.GetValue<double>() ?? 0;
var pageMarginCss = (mt == 0 && mr == 0 && mb == 0 && ml == 0) ? "0mm"
: $"{mt.ToString("0.###", CultureInfo.InvariantCulture)}mm {mr.ToString("0.###", CultureInfo.InvariantCulture)}mm {mb.ToString("0.###", CultureInfo.InvariantCulture)}mm {ml.ToString("0.###", CultureInfo.InvariantCulture)}mm";
var elements = schema["elements"]?.AsArray() ?? [];
// ─ 提取 reportHeader 配置 ─
var reportHeaderEl = elements.OfType<JsonObject>()
.FirstOrDefault(el => ReadAsString(el["type"]) == "reportHeader");
var reportHeaderId = ReadAsString(reportHeaderEl?["id"]) ?? string.Empty;
var headerVisible = reportHeaderEl == null ||
!string.Equals(ReadAsString(reportHeaderEl["visible"]), "false", StringComparison.OrdinalIgnoreCase);
var repeatHeaderByPage = headerVisible && reportHeaderEl != null &&
string.Equals(ReadAsString(reportHeaderEl["printRepeated"]), "true", StringComparison.OrdinalIgnoreCase);
var headerBandHeight = repeatHeaderByPage
? Math.Max(0d, reportHeaderEl!["h"]?.GetValue<double>() ?? 0d) : 0d;
var pageCount = ResolvePrintPageCount(elements, data, heightMm, mt, mb, headerBandHeight, repeatHeaderByPage);
var totalHeightMm = Math.Max(heightMm, heightMm * pageCount);
var wStr = widthMm.ToString("0.###", CultureInfo.InvariantCulture);
var hStr = heightMm.ToString("0.###", CultureInfo.InvariantCulture);
var thStr = totalHeightMm.ToString("0.###", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("<!doctype html>\n<html>\n<head><meta charset=\"utf-8\"/>\n<style>\n");
sb.Append($" @page {{ size: {wStr}mm {hStr}mm; margin: {pageMarginCss}; }}\n");
sb.Append(" html, body { margin: 0; padding: 0; overflow: visible; }\n");
sb.Append(" * { -webkit-print-color-adjust: exact; print-color-adjust: exact;\n");
sb.Append(" font-family: \"Microsoft YaHei\", \"SimHei\", Arial, sans-serif; }\n");
sb.Append(" .el { position: absolute; overflow: hidden; }\n");
sb.Append(" @media screen {\n");
sb.Append(" html { background: #525659; }\n");
sb.Append(" body { margin: 0; padding: 28px 20px 48px; background: #525659; min-height: 100vh; box-sizing: border-box; }\n");
sb.Append(" .qhmes-native-print-root {\n");
sb.Append(" margin: 0 auto; position: relative; background: #fff; border-radius: 1px;\n");
sb.Append(" box-shadow: 0 1px 2px rgba(0,0,0,0.14), 0 4px 12px rgba(0,0,0,0.1), 0 12px 28px rgba(0,0,0,0.08);\n");
sb.Append(" }\n");
sb.Append(" .qhmes-native-screen-page-sep {\n");
sb.Append(" position: absolute; left: 0; width: 100%; height: 14px; margin-top: -7px;\n");
sb.Append(" background: #525659; pointer-events: none; z-index: 10000;\n");
sb.Append(" box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);\n");
sb.Append(" }\n");
sb.Append(" .qhmes-native-table-chunk table { border-collapse: collapse; }\n");
sb.Append(" .qhmes-native-table-chunk thead { display: table-header-group; }\n");
sb.Append(" .qhmes-native-table-chunk tbody tr { break-inside: avoid; }\n");
sb.Append(" }\n");
sb.Append(" @media print {\n");
sb.Append(" html, body { background: transparent !important; padding: 0 !important; }\n");
sb.Append(" .qhmes-native-print-root { box-shadow: none !important; transform: none !important; margin-bottom: 0 !important; }\n");
sb.Append(" .qhmes-native-screen-page-sep { display: none !important; }\n");
sb.Append(" }\n");
sb.Append("</style>\n");
sb.Append("<script>\n");
sb.Append("(function(){\n");
sb.Append(" function fitPage(){\n");
sb.Append(" var p=document.querySelector('.qhmes-native-print-root');\n");
sb.Append(" if(!p)return;\n");
sb.Append(" var vw=window.innerWidth||document.documentElement.clientWidth||1;\n");
sb.Append(" var vh=window.innerHeight||document.documentElement.clientHeight||1;\n");
sb.Append(" var pw=p.offsetWidth||1;\n");
sb.Append(" var ph=p.offsetHeight||1;\n");
sb.Append(" var maxW=Math.max(1,vw-40);\n");
sb.Append(" var maxH=Math.max(1,vh-56);\n");
sb.Append(" var s=Math.min(maxW/pw,maxH/ph,1);\n");
sb.Append(" p.style.transform='scale('+s+')';\n");
sb.Append(" p.style.transformOrigin='top center';\n");
sb.Append(" p.style.marginBottom=((1-s)*ph)+'px';\n");
sb.Append(" }\n");
sb.Append(" window.addEventListener('load',fitPage);\n");
sb.Append(" window.addEventListener('resize',fitPage);\n");
sb.Append(" setTimeout(fitPage,0);\n");
sb.Append(" setTimeout(fitPage,150);\n");
sb.Append(" setTimeout(fitPage,400);\n");
sb.Append("})();\n");
sb.Append("</script>\n");
sb.Append("</head>\n<body>\n");
sb.Append($"<div class=\"qhmes-native-print-root\" style=\"width:{wStr}mm;min-height:{thStr}mm;height:auto;overflow:visible;box-sizing:border-box;\">\n");
// 按 zIndex 升序渲染(低 zIndex 先画,高 zIndex 覆盖在上层)
var sortedElements = elements.OfType<JsonNode>()
.OrderBy(el => el?["zIndex"]?.GetValue<int>() ?? 0)
.ToList();
foreach (var el in sortedElements)
{
if (el == null) continue;
var elHtml = RenderElement(el, data, widthMm, heightMm, pageCount, mt, mb, elements,
repeatHeaderByPage, headerBandHeight, reportHeaderId, headerVisible);
if (!string.IsNullOrEmpty(elHtml))
sb.Append(elHtml);
}
for (var i = 1; i < pageCount; i++)
{
var sepTop = (i * heightMm).ToString("0.###", CultureInfo.InvariantCulture);
sb.Append($"<div style=\"position:absolute;left:0;top:{sepTop}mm;width:{wStr}mm;height:0;page-break-before:always;\"></div>\n");
sb.Append($"<div class=\"qhmes-native-screen-page-sep\" aria-hidden=\"true\" style=\"top:{sepTop}mm;\"></div>\n");
}
sb.Append("</div>\n</body></html>");
return sb.ToString();
}
// ─── 元素渲染分发 ──────────────────────────────────────────────────────
private static string RenderElement(
JsonNode el, JsonObject data,
double pageWidthMm, double pageHeightMm, int totalPages,
double marginTopMm, double marginBottomMm, JsonArray allElements,
bool repeatHeaderByPage = false, double headerBandHeight = 0d,
string reportHeaderId = "", bool headerVisible = true)
{
// visible=false 的元素跳过(与前端一致)
if (string.Equals(ReadAsString(el["visible"]), "false", StringComparison.OrdinalIgnoreCase))
return string.Empty;
var type = ReadAsString(el["type"], "text");
var x = el["x"]?.GetValue<double>() ?? 0;
var y = el["y"]?.GetValue<double>() ?? 0;
var w = el["w"]?.GetValue<double>() ?? 20;
var h = el["h"]?.GetValue<double>() ?? 8;
var zIndex = el["zIndex"]?.GetValue<int>() ?? 0;
var rotate = el["rotate"]?.GetValue<double>() ?? 0;
var style = el["style"];
var fs = style?["fontSize"]?.GetValue<double>() ?? 12;
var fw = ReadAsString(style?["fontWeight"], "400");
var color = ReadAsString(style?["color"], "#111111");
var align = ReadAsString(style?["textAlign"], "left");
var lh = style?["lineHeight"]?.GetValue<double>() ?? 1.4;
var bg = ReadAsString(style?["backgroundColor"]);
var bgCss = bg != null ? $"background:{bg};" : string.Empty;
var rotCss = rotate != 0 ? $"transform:rotate({rotate}deg);transform-origin:top left;" : string.Empty;
// ─ reportHeader/reportFooter 位置覆盖 ─
var isReportHeader = type == "reportHeader";
var isReportFooter = type == "reportFooter";
double renderX = (isReportHeader || isReportFooter) ? 0 : x;
double renderW = (isReportHeader || isReportFooter) ? pageWidthMm : w;
double renderY = isReportHeader ? 0
: (isReportFooter && string.Equals(ReadAsString(el["printAtPageBottom"]), "true", StringComparison.OrdinalIgnoreCase))
? Math.Max(0, pageHeightMm - h)
: y;
// ─ reportHeader band 判断 ─
var isInHeaderBand = IsElementInHeaderBand(type, reportHeaderId,
ReadAsString(el["bandId"]) ?? string.Empty,
ReadAsString(el["region"]) ?? string.Empty,
headerBandHeight, y, h);
if (isInHeaderBand && !headerVisible) return string.Empty;
// 需要在每页重复:元素在报表头区域 且 报表头开启了 printRepeated
var bandRepeat = isInHeaderBand && repeatHeaderByPage && totalPages > 1;
var posStyle = $"left:{renderX.ToString("0.###", CultureInfo.InvariantCulture)}mm;" +
$"top:{renderY.ToString("0.###", CultureInfo.InvariantCulture)}mm;" +
$"width:{renderW.ToString("0.###", CultureInfo.InvariantCulture)}mm;" +
$"height:{h.ToString("0.###", CultureInfo.InvariantCulture)}mm;" +
$"z-index:{zIndex};{bgCss}{rotCss}";
return type switch
{
"title" or "subtitle" or "text" or "date" or "pageNo" or "reportHeader" or "reportFooter"
=> RenderText(el, data, posStyle, fs, fw, color, align, lh, renderY, pageHeightMm, totalPages, bandRepeat),
"qrcode" => RenderQrCode(el, data, posStyle, renderW, h, bandRepeat, renderY, pageHeightMm, totalPages),
"barcode" => RenderBarcode(el, data, posStyle, renderW, h, bandRepeat, renderY, pageHeightMm, totalPages),
"image" => RenderImage(el, data, posStyle, bandRepeat, renderY, pageHeightMm, totalPages),
"freeTable" => RenderFreeTable(el, data, posStyle, renderW, h, renderY,
totalPages, pageHeightMm, bandRepeat),
"table" or "detailTable" => RenderDetailTable(el, data, posStyle, y, pageHeightMm,
marginTopMm, marginBottomMm, allElements, repeatHeaderByPage, headerBandHeight),
_ => string.Empty
};
}
/// <summary>
/// 判断元素是否属于报表头区域(与前端 isElementInHeaderRegion 一致)。
/// </summary>
private static bool IsElementInHeaderBand(
string? type, string reportHeaderId,
string bandId, string region,
double headerBandHeight, double y, double h)
{
if (type == "reportHeader") return true;
if (type == "reportFooter") return false;
if (!string.IsNullOrEmpty(reportHeaderId) && bandId == reportHeaderId) return true;
if (region == "header") return true;
if (region is "body" or "footer") return false;
if (headerBandHeight <= 0) return false;
// 位置回退判断:元素顶部在 band 内且底部不超出 band 0.2mm
return y < headerBandHeight && (y + h) <= headerBandHeight + 0.2;
}
// ─── Text ──────────────────────────────────────────────────────────────
private static string RenderText(JsonNode el, JsonObject data, string posStyle,
double fs, string fw, string color, string align, double lh,
double designY, double pageHeightMm, int totalPages, bool bandRepeat = false)
{
var type = ReadAsString(el["type"], "text");
var bindField = ReadAsString(el["bindField"]);
string text;
if (type == "date")
{
var val = bindField != null ? ResolveField(data, bindField)?.ToString() : null;
var fmt = ReadAsString(el["format"], "yyyy-MM-dd");
text = val != null && DateTime.TryParse(val, out var dt)
? dt.ToString(ConvertDateFormat(fmt))
: DateTime.Now.ToString(ConvertDateFormat(fmt));
}
else if (bindField != null)
{
text = ResolveField(data, bindField)?.ToString() ?? ReadAsString(el["text"], string.Empty);
}
else
{
text = ReadAsString(el["text"], string.Empty);
}
var textCss = $"font-size:{fs}px;font-weight:{fw};color:{color};text-align:{align};line-height:{lh};white-space:pre-wrap;overflow:hidden;word-break:break-all;";
// 元素自身 printRepeated 或 pageNo 类型,或报表头区域强制重复
var repeat = bandRepeat || type == "pageNo"
|| string.Equals(ReadAsString(el["printRepeated"], "false"), "true", StringComparison.OrdinalIgnoreCase);
if (!repeat || totalPages <= 1)
{
text = ReplaceTemplatePlaceholders(text, data, 1, totalPages);
return $"<div class=\"el\" style=\"{posStyle}{textCss}\">{EscapeHtml(text)}</div>\n";
}
var baseStyle = RemoveCssProperty(posStyle, "top");
var sb = new StringBuilder();
for (var p = 1; p <= totalPages; p++)
{
var top = designY + (p - 1) * pageHeightMm;
var textPerPage = ReplaceTemplatePlaceholders(text, data, p, totalPages);
sb.Append($"<div class=\"el\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseStyle}{textCss}\">{EscapeHtml(textPerPage)}</div>\n");
}
return sb.ToString();
}
// ─── QR Code ────────────────────────────────────────────────────────────
private static string RenderQrCode(JsonNode el, JsonObject data, string posStyle, double w, double h,
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
{
var bindField = ReadAsString(el["bindField"]);
var value = ReadAsString(el["value"], string.Empty);
if (bindField != null)
value = ResolveField(data, bindField)?.ToString() ?? value;
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
string inner;
try
{
using var gen = new QRCodeGenerator();
var qrData = gen.CreateQrCode(value, QRCodeGenerator.ECCLevel.M);
using var code = new PngByteQRCode(qrData);
var b64 = Convert.ToBase64String(code.GetGraphic(6));
inner = $"<img src=\"data:image/png;base64,{b64}\" style=\"max-width:{w.ToString("0.###", CultureInfo.InvariantCulture)}mm;max-height:{h.ToString("0.###", CultureInfo.InvariantCulture)}mm;object-fit:contain;\" />";
}
catch { return string.Empty; }
var wrapStyle = "display:flex;align-items:center;justify-content:center;";
if (!bandRepeat || totalPages <= 1)
return $"<div class=\"el\" style=\"{posStyle}{wrapStyle}\">{inner}</div>\n";
var baseNoTop = RemoveCssProperty(posStyle, "top");
var sb = new StringBuilder();
for (var p = 0; p < totalPages; p++)
{
var top = designY + p * pageHeightMm;
sb.Append($"<div class=\"el\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseNoTop}{wrapStyle}\">{inner}</div>\n");
}
return sb.ToString();
}
// ─── Barcode ────────────────────────────────────────────────────────────
private static string RenderBarcode(JsonNode el, JsonObject data, string posStyle, double w, double h,
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
{
var bindField = ReadAsString(el["bindField"]);
var value = ReadAsString(el["value"], string.Empty);
if (bindField != null)
value = ResolveField(data, bindField)?.ToString() ?? value;
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var escapedVal = EscapeHtml(value);
var inner = $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" height=\"75%\">" +
$"<rect width=\"100%\" height=\"100%\" fill=\"white\"/>" +
$"<text x=\"50%\" y=\"50%\" font-size=\"8\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-family=\"monospace\">{escapedVal}</text>" +
$"</svg><span style=\"font-size:9px;font-family:monospace;\">{escapedVal}</span>";
var wrapStyle = "display:flex;flex-direction:column;align-items:center;justify-content:center;";
if (!bandRepeat || totalPages <= 1)
return $"<div class=\"el\" style=\"{posStyle}{wrapStyle}\">{inner}</div>\n";
var baseNoTop = RemoveCssProperty(posStyle, "top");
var sb = new StringBuilder();
for (var p = 0; p < totalPages; p++)
{
var top = designY + p * pageHeightMm;
sb.Append($"<div class=\"el\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseNoTop}{wrapStyle}\">{inner}</div>\n");
}
return sb.ToString();
}
// ─── Image ─────────────────────────────────────────────────────────────
private static string RenderImage(JsonNode el, JsonObject data, string posStyle,
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
{
var bindField = ReadAsString(el["bindField"]);
var src = ReadAsString(el["src"], string.Empty);
if (bindField != null)
src = ResolveField(data, bindField)?.ToString() ?? src;
var fit = ReadAsString(el["fit"], "contain");
var objFit = fit switch { "fill" => "fill", "cover" => "cover", _ => "contain" };
var inner = $"<img src=\"{EscapeHtml(src)}\" style=\"width:100%;height:100%;object-fit:{objFit};\" />";
if (!bandRepeat || totalPages <= 1)
return $"<div class=\"el\" style=\"{posStyle}\">{inner}</div>\n";
var baseNoTop = RemoveCssProperty(posStyle, "top");
var sb = new StringBuilder();
for (var p = 0; p < totalPages; p++)
{
var top = designY + p * pageHeightMm;
sb.Append($"<div class=\"el\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseNoTop}\">{inner}</div>\n");
}
return sb.ToString();
}
// ─── FreeTable ──────────────────────────────────────────────────────────
// printRepeated=true 或 bandRepeat=true 时在每页重复否则仅在第1页显示
private static string RenderFreeTable(JsonNode el, JsonObject data, string posStyle, double wMm, double hMm,
double designY, int totalPages, double pageHeightMm, bool bandRepeat = false)
{
var innerHtml = BuildFreeTableInnerHtml(el, data, wMm, hMm);
var printRepeat = string.Equals(ReadAsString(el["printRepeated"]), "true", StringComparison.OrdinalIgnoreCase);
var repeat = (printRepeat || bandRepeat) && totalPages > 1;
var baseNoTop = RemoveCssProperty(RemoveCssProperty(posStyle, "height"), "top");
if (!repeat)
return $"<div class=\"el\" style=\"top:{designY.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseNoTop}height:auto;overflow:visible;\">{innerHtml}</div>\n";
var sb = new StringBuilder();
for (var p = 0; p < totalPages; p++)
{
var top = designY + p * pageHeightMm;
sb.Append($"<div class=\"el\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseNoTop}height:auto;overflow:visible;\">{innerHtml}</div>\n");
}
return sb.ToString();
}
private static string BuildFreeTableInnerHtml(JsonNode el, JsonObject data, double wMm, double hMm)
{
var rowCount = el["rowCount"]?.GetValue<int>() ?? 1;
var colCount = el["colCount"]?.GetValue<int>() ?? 1;
var cells = el["cells"]?.AsArray() ?? [];
var borderColor = ReadAsString(el["borderColor"], "#222222");
var borderWidth = el["borderWidth"]?.GetValue<int>() ?? 1;
var colWidths = el["colWidths"]?.AsArray()?.Select(n => n?.GetValue<double>() ?? wMm / colCount).ToArray()
?? Enumerable.Repeat(wMm / colCount, colCount).ToArray();
var rowHeights = el["rowHeights"]?.AsArray()?.Select(n => n?.GetValue<double>() ?? hMm / rowCount).ToArray()
?? Enumerable.Repeat(hMm / rowCount, rowCount).ToArray();
var cellMap = new Dictionary<(int row, int col), JsonNode>();
foreach (var c in cells)
if (c != null)
cellMap[(c["row"]?.GetValue<int>() ?? 0, c["col"]?.GetValue<int>() ?? 0)] = c;
var occupied = new HashSet<(int, int)>();
var sb = new StringBuilder();
sb.Append($"<table style=\"border-collapse:collapse;width:{wMm}mm;table-layout:fixed;\">");
sb.Append("<colgroup>");
foreach (var cw in colWidths) sb.Append($"<col style=\"width:{cw}mm;\" />");
sb.Append("</colgroup><tbody>");
for (var r = 0; r < rowCount; r++)
{
var rh = r < rowHeights.Length ? rowHeights[r] : hMm / rowCount;
sb.Append($"<tr style=\"height:{rh}mm;\">");
for (var c = 0; c < colCount; c++)
{
if (occupied.Contains((r, c))) continue;
cellMap.TryGetValue((r, c), out var cell);
var rs = cell?["rowspan"]?.GetValue<int>() ?? 1;
var cs = cell?["colspan"]?.GetValue<int>() ?? 1;
var bindField = ReadAsString(cell?["bindField"]);
var rawText = ReadAsString(cell?["text"], string.Empty);
var cellText = bindField != null
? ResolveField(data, bindField)?.ToString() ?? rawText
: rawText;
var ta = ReadAsString(cell?["align"], "left");
var va = ReadAsString(cell?["verticalAlign"], "middle");
var cFs = cell?["fontSize"]?.GetValue<double>() ?? 12;
var cColor = ReadAsString(cell?["color"], "#111111");
var cBg = ReadAsString(cell?["backgroundColor"], "#ffffff");
for (var dr = 0; dr < rs; dr++)
for (var dc = 0; dc < cs; dc++)
if (dr > 0 || dc > 0) occupied.Add((r + dr, c + dc));
var rsAttr = rs > 1 ? $" rowspan=\"{rs}\"" : string.Empty;
var csAttr = cs > 1 ? $" colspan=\"{cs}\"" : string.Empty;
var bdr = $"border:{borderWidth}px solid {borderColor};";
sb.Append($"<td{rsAttr}{csAttr} style=\"{bdr}padding:1mm;text-align:{ta};vertical-align:{va};font-size:{cFs}px;color:{cColor};background:{cBg};word-break:break-all;\">");
sb.Append(EscapeHtml(cellText ?? string.Empty));
sb.Append("</td>");
}
sb.Append("</tr>");
}
sb.Append("</tbody></table>");
return sb.ToString();
}
// ─── Table / DetailTable ───────────────────────────────────────────────
private static string RenderDetailTable(JsonNode el, JsonObject data, string posStyle,
double designY, double pageHeightMm, double marginTopMm, double marginBottomMm,
JsonArray allElements, bool repeatHeaderByPage = false, double headerBandHeight = 0d)
{
var source = ReadAsString(el["source"], "mainTable");
var showHeader = !string.Equals(ReadAsString(el["showHeader"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var rowHeightMm = Math.Max(6d, el["rowHeight"]?.GetValue<double>() ?? 8d);
var headerBg = ReadAsString(el["headerBgColor"], "#f5f5f5");
var headerText = ReadAsString(el["headerTextColor"], "#111111");
var columns = el["columns"]?.AsArray() ?? [];
if (columns.Count == 0) return string.Empty;
var rows = ResolveTableRows(data, source);
var footerMode = (ReadAsString(el["footerTotalMode"], "overall") ?? "overall").ToLowerInvariant();
var showFooter = !string.Equals(ReadAsString(el["footerShowTotal"], "true"), "false", StringComparison.OrdinalIgnoreCase);
// 行合并配置
var mergeColumnKeys = el["mergeColumnKeys"]?.AsArray()
?.Select(n => ReadAsString(n) ?? string.Empty)
.Where(s => !string.IsNullOrEmpty(s))
.ToArray() ?? [];
var strictGrouping = !string.Equals(ReadAsString(el["strictGrouping"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var detailH = el["h"]?.GetValue<double>() ?? 8d;
var repeatFreeConstrains = RepeatingFreeTableConstrainsDetail(allElements, designY, detailH);
List<List<JsonObject>> chunks;
var tableMode = (ReadAsString(el["tableHeightMode"], "autoPage") ?? "autoPage").ToLowerInvariant();
if (tableMode == "fixedrows")
{
var pageSize = Math.Max(1, el["fixedRows"]?.GetValue<int>() ?? 5);
chunks = ChunkRowsFixed(rows, pageSize);
}
else
{
chunks = ComputeAutoPageChunks(el, rows, columns, designY, pageHeightMm,
marginTopMm, marginBottomMm, showFooter, footerMode, rowHeightMm,
repeatFreeConstrains, headerBandHeight);
}
var sb = new StringBuilder();
var baseStyleNoTop = RemoveCssProperty(RemoveCssProperty(posStyle, "height"), "top");
for (var pageIdx = 0; pageIdx < chunks.Count; pageIdx++)
{
// 续页起点:
// - repeatFreeConstrains → 沿用 designY重复自由表格占据了顶部空间
// - 否则 → 顶排到页面上边距 + 报表头带高度(消除空白)
double top;
if (pageIdx == 0 || repeatFreeConstrains)
{
top = designY + pageIdx * pageHeightMm;
}
else
{
top = pageIdx * pageHeightMm + marginTopMm
+ (repeatHeaderByPage ? headerBandHeight : 0d);
}
var isLastChunk = pageIdx == chunks.Count - 1;
var chunkRows = chunks[pageIdx];
// 计算当前 chunk 的行合并 rowSpanMap按 chunk 内相对行号)
var rowSpanMap = BuildRowSpanMap(chunkRows, columns, mergeColumnKeys, strictGrouping);
sb.Append($"<div class=\"el qhmes-native-table-chunk\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseStyleNoTop}height:auto;overflow:visible;\">");
sb.Append("<table style=\"width:100%;border-collapse:collapse;table-layout:fixed;\">");
if (showHeader)
{
var headerRows = BuildTableHeaderRows(el, columns.OfType<JsonObject>().ToList());
var headerHeightMm = Math.Max(6d, el["headerHeight"]?.GetValue<double>() ?? 10d);
var headerRowHeightMm = headerHeightMm / Math.Max(1, headerRows.Count);
sb.Append("<thead>");
foreach (var hrow in headerRows)
{
sb.Append($"<tr style=\"height:{headerRowHeightMm.ToString("0.###", CultureInfo.InvariantCulture)}mm;\">");
foreach (var cell in hrow)
{
var rs = cell.RowSpan > 1 ? $" rowspan=\"{cell.RowSpan}\"" : string.Empty;
var cs = cell.ColSpan > 1 ? $" colspan=\"{cell.ColSpan}\"" : string.Empty;
sb.Append($"<th{rs}{cs} style=\"border:1px solid #222;padding:2mm;text-align:{cell.Align};font-weight:600;background:{headerBg};color:{headerText};width:{cell.WidthPercent.ToString("0.###", CultureInfo.InvariantCulture)}%;\">{EscapeHtml(cell.Title)}</th>");
}
sb.Append("</tr>");
}
sb.Append("</thead>");
}
sb.Append("<tbody>");
for (var rowIdx = 0; rowIdx < chunkRows.Count; rowIdx++)
{
var row = chunkRows[rowIdx];
sb.Append($"<tr style=\"min-height:{rowHeightMm.ToString("0.###", CultureInfo.InvariantCulture)}mm;\">");
foreach (var col in columns)
{
var field = ReadAsString(col?["bindField"]) ?? ReadAsString(col?["field"]);
var spanKey = $"{rowIdx}_{field ?? string.Empty}";
if (rowSpanMap.TryGetValue(spanKey, out var spanVal) && spanVal == 0)
continue; // 被上方行合并,跳过此格
var rowspanAttr = (rowSpanMap.TryGetValue(spanKey, out var rsVal) && rsVal > 1)
? $" rowspan=\"{rsVal}\""
: string.Empty;
var align = ReadAsString(col?["align"], "left");
var contentType = ReadAsString(col?["contentType"], "text");
var autoWrap = !string.Equals(ReadAsString(col?["autoWrap"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var raw = !string.IsNullOrWhiteSpace(field) ? ResolveField(row, field!) : null;
var text = FormatColumnValue(raw, col, contentType);
var html = ResolveTableCellInnerHtml(contentType, text);
var ws = autoWrap ? "normal" : "nowrap";
var wb = autoWrap ? "break-all" : "normal";
var lhv = autoWrap ? "1.3" : $"{rowHeightMm.ToString("0.###", CultureInfo.InvariantCulture)}mm";
sb.Append($"<td{rowspanAttr} style=\"border:1px solid #222;padding:2mm;text-align:{align};white-space:{ws};word-break:{wb};line-height:{lhv};\">{html}</td>");
}
sb.Append("</tr>");
}
sb.Append("</tbody>");
if (showFooter && (footerMode == "page" || isLastChunk))
{
var footerRows = footerMode == "page" ? chunkRows : rows;
sb.Append(BuildFooterHtml(el, footerRows, columns));
}
sb.Append("</table></div>");
}
return sb.ToString();
}
// ─── 行合并 ────────────────────────────────────────────────────────────
/// <summary>
/// 移植自前端 buildRowSpanMap。返回 "{rowIdx}_{field}" → rowspan 的映射;
/// 值为 0 表示该格被上方行合并,渲染时跳过;值 &gt; 1 表示需加 rowspan 属性。
/// </summary>
private static Dictionary<string, int> BuildRowSpanMap(
List<JsonObject> rows, JsonArray columns,
string[] mergeColumnKeys, bool strictGrouping)
{
var map = new Dictionary<string, int>();
if (rows.Count == 0) return map;
var mergeFields = ResolveMergeFields(columns, mergeColumnKeys);
if (mergeFields.Count == 0) return map;
var currentRanges = new List<(int start, int end)> { (0, rows.Count) };
foreach (var field in mergeFields)
{
var (fieldMap, nextRanges) = BuildRangesByField(rows, field, currentRanges);
foreach (var kv in fieldMap) map[kv.Key] = kv.Value;
if (strictGrouping) currentRanges = nextRanges;
}
return map;
}
private static List<string> ResolveMergeFields(JsonArray columns, string[] mergeColumnKeys)
{
var colList = columns.OfType<JsonObject>().ToList();
if (mergeColumnKeys.Length > 0)
{
var byKey = colList.ToDictionary(
c => ReadAsString(c["key"]) ?? string.Empty,
c => ReadAsString(c["bindField"]) ?? ReadAsString(c["field"]) ?? string.Empty);
return mergeColumnKeys
.Select(k => byKey.TryGetValue(k, out var f) ? f : string.Empty)
.Where(f => !string.IsNullOrEmpty(f))
.ToList();
}
return colList
.Where(c => string.Equals(ReadAsString(c["mergeByValue"]), "true", StringComparison.OrdinalIgnoreCase))
.Select(c => ReadAsString(c["bindField"]) ?? ReadAsString(c["field"]) ?? string.Empty)
.Where(f => !string.IsNullOrEmpty(f))
.ToList();
}
private static (Dictionary<string, int> map, List<(int start, int end)> nextRanges)
BuildRangesByField(List<JsonObject> rows, string field, List<(int start, int end)> ranges)
{
var map = new Dictionary<string, int>();
var nextRanges = new List<(int start, int end)>();
foreach (var (rangeStart, rangeEnd) in ranges)
{
var pos = rangeStart;
while (pos < rangeEnd)
{
var value = ResolveField(rows[pos], field)?.ToString();
var end = pos + 1;
while (end < rangeEnd && ResolveField(rows[end], field)?.ToString() == value)
end++;
map[$"{pos}_{field}"] = end - pos;
for (var i = pos + 1; i < end; i++)
map[$"{i}_{field}"] = 0;
nextRanges.Add((pos, end));
pos = end;
}
}
return (map, nextRanges);
}
// ─── 分页算法 ──────────────────────────────────────────────────────────
private static List<List<JsonObject>> ChunkRowsFixed(List<JsonObject> rows, int pageSize)
{
var size = Math.Max(1, pageSize);
if (rows.Count == 0) return new List<List<JsonObject>> { new() };
var result = new List<List<JsonObject>>();
for (var i = 0; i < rows.Count; i += size)
result.Add(rows.Skip(i).Take(size).ToList());
return result;
}
private static List<List<JsonObject>> ComputeAutoPageChunks(
JsonNode el, List<JsonObject> rows, JsonArray columns,
double designY, double pageHeightMm, double marginTopMm, double marginBottomMm,
bool showFooter, string footerMode, double rowHeightMm,
bool repeatFreeConstrains = false, double headerBandMm = 0d)
{
if (rows.Count == 0) return new List<List<JsonObject>> { new() };
var showHeader = !string.Equals(ReadAsString(el["showHeader"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var headerHeightMm = showHeader ? Math.Max(6d, el["headerHeight"]?.GetValue<double>() ?? 10d) : 0d;
var footerMm = showFooter ? Math.Max(rowHeightMm, 6d) : 0d;
var innerH = pageHeightMm - marginTopMm - marginBottomMm;
var colList = columns.OfType<JsonObject>().ToList();
var rowHeights = rows.Select(r => EstimateRowHeightMm(r, colList, rowHeightMm)).ToList();
const double insetMm = 1.4;
const double fitFactor = 1.03;
var chunks = new List<List<JsonObject>>();
var i = 0;
var pageIdx = 0;
while (i < rows.Count)
{
var remaining = rows.Count - i;
// repeatFreeConstrains → 续页可用高度同首页(重复自由表格占据了 designY 空间)
// 否则 → 续页顶排减去报表头带高度headerBandMm
var avail = pageIdx == 0
? innerH - designY - headerHeightMm
: repeatFreeConstrains
? Math.Max(rowHeightMm, innerH - designY - headerHeightMm)
: Math.Max(rowHeightMm, innerH - headerHeightMm - headerBandMm);
var safeAvail = Math.Max(rowHeightMm, avail - insetMm);
var needFooter = showFooter && footerMode == "page";
var maxBodyMm = needFooter ? Math.Max(rowHeightMm, safeAvail - footerMm) : safeAvail;
if (showFooter && footerMode != "page" && remaining <= CalcMaxRows(rowHeights, i, remaining, fitFactor, safeAvail))
{
var bodyMm = rowHeights.Skip(i).Take(remaining).Sum(h => h * fitFactor);
if (bodyMm + footerMm <= safeAvail + 0.02)
{
chunks.Add(rows.Skip(i).Take(remaining).ToList());
break;
}
maxBodyMm = Math.Max(rowHeightMm, safeAvail - footerMm);
}
var take = 0;
var used = 0d;
while (take < remaining && used + rowHeights[i + take] * fitFactor <= maxBodyMm + 0.02)
{
used += rowHeights[i + take] * fitFactor;
take++;
}
if (take == 0) take = 1;
chunks.Add(rows.Skip(i).Take(take).ToList());
i += take;
pageIdx += 1;
if (pageIdx > 5000) break;
}
return chunks.Count > 0 ? chunks : new List<List<JsonObject>> { new() };
}
private static int CalcMaxRows(List<double> heights, int from, int count, double factor, double avail)
{
var used = 0d;
var n = 0;
for (var j = from; j < from + count && j < heights.Count; j++)
{
if (used + heights[j] * factor > avail + 0.02) break;
used += heights[j] * factor;
n++;
}
return n;
}
private static double EstimateRowHeightMm(JsonObject row, List<JsonObject> columns, double baseRowMm)
{
const double padHmm = 4d;
const double mediaExtra = 1.25d;
const double pxPerMm = 96d / 25.4d;
const double mmPerPx = 25.4d / 96d;
var maxH = baseRowMm;
foreach (var col in columns)
{
var contentType = (ReadAsString(col["contentType"], "text") ?? "text").ToLowerInvariant();
var colWMm = Math.Max(1d, col["width"]?.GetValue<double>() ?? 30d);
if (contentType is "qrcode" or "barcode")
{
maxH = Math.Max(maxH, colWMm * 0.93 + padHmm + mediaExtra);
continue;
}
if (contentType == "image")
{
maxH = Math.Max(maxH, colWMm * 0.65 + padHmm);
continue;
}
var autoWrap = !string.Equals(ReadAsString(col["autoWrap"], "true"), "false", StringComparison.OrdinalIgnoreCase);
if (!autoWrap) { maxH = Math.Max(maxH, baseRowMm); continue; }
var field = ReadAsString(col["bindField"]) ?? ReadAsString(col["field"]);
var raw = field != null ? ResolveField(row, field)?.ToString() ?? string.Empty : string.Empty;
if (string.IsNullOrEmpty(raw)) { maxH = Math.Max(maxH, baseRowMm); continue; }
var fs = col["fontSize"]?.GetValue<double>() ?? 12d;
var innerWpx = Math.Max(8d, colWMm * pxPerMm - 4d * pxPerMm);
var lines = EstimateTextWrapLines(raw, innerWpx, fs);
var textHmm = lines * fs * 1.3 * mmPerPx;
maxH = Math.Max(maxH, Math.Max(textHmm + padHmm, baseRowMm));
}
return maxH;
}
private static int EstimateTextWrapLines(string text, double innerWpx, double fontSize)
{
if (string.IsNullOrEmpty(text)) return 1;
var units = 0d;
foreach (var ch in text)
{
var cp = (int)ch;
if (IsFullWidthChar(cp)) units += 1d;
else if (char.IsWhiteSpace(ch)) units += 0.22d;
else units += 0.62d;
}
units = Math.Max(0.01, units);
var unitsPerLine = Math.Max(1.8, innerWpx / Math.Max(1, fontSize) * 0.97);
return Math.Max(1, (int)Math.Ceiling(units / unitsPerLine));
}
private static bool IsFullWidthChar(int cp) =>
(cp >= 0x2E80 && cp <= 0x9FFF) ||
(cp >= 0x3040 && cp <= 0x30FF) ||
(cp >= 0xAC00 && cp <= 0xD7AF) ||
(cp >= 0xF900 && cp <= 0xFAFF) ||
(cp >= 0xFF00 && cp <= 0xFFEF);
// ─── 合计行 ─────────────────────────────────────────────────────────────
private static string BuildFooterHtml(JsonNode el, List<JsonObject> rows, JsonArray columns)
{
var labelColumnKey = ReadAsString(el["footerLabelColumnKey"]) ?? string.Empty;
var labelText = ReadAsString(el["footerLabelText"], "合计") ?? "合计";
var labelCenter = !string.Equals(ReadAsString(el["footerLabelCenter"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var labelAlign = labelCenter ? "center" : "left";
var sb = new StringBuilder("<tfoot><tr>");
var colList = columns.OfType<JsonObject>().ToList();
for (var idx = 0; idx < colList.Count; idx++)
{
var col = colList[idx];
var contentType = (ReadAsString(col["contentType"], "text") ?? "text").ToLowerInvariant();
var isNumeric = contentType is "number" or "amount";
var enableTotal = string.Equals(ReadAsString(col["enableFooterTotal"]), "true", StringComparison.OrdinalIgnoreCase);
var colKey = ReadAsString(col["key"]) ?? string.Empty;
string cellText;
var cellAlign = "left";
if (isNumeric && enableTotal)
{
var field = ReadAsString(col["bindField"]) ?? ReadAsString(col["field"]);
var total = 0d;
if (field != null)
foreach (var row in rows)
{
var raw = ResolveField(row, field)?.ToString();
if (raw != null && double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
total += d;
}
cellText = FormatColumnValue(total, col, contentType);
cellAlign = ReadAsString(col["align"], "right") ?? "right";
}
else if ((!string.IsNullOrEmpty(labelColumnKey) && colKey == labelColumnKey)
|| (string.IsNullOrEmpty(labelColumnKey) && idx == 0))
{
cellText = labelText;
cellAlign = labelAlign;
}
else
{
cellText = string.Empty;
}
sb.Append($"<td style=\"border:1px solid #222;padding:2mm;font-weight:600;text-align:{cellAlign};background:#fafafa;\">{EscapeHtml(cellText)}</td>");
}
sb.Append("</tr></tfoot>");
return sb.ToString();
}
// ─── 页数计算 ───────────────────────────────────────────────────────────
private static int ResolvePrintPageCount(
JsonArray elements, JsonObject data,
double pageHeightMm, double marginTopMm, double marginBottomMm,
double headerBandHeight = 0d, bool repeatHeaderByPage = false)
{
var headerBandMm = repeatHeaderByPage ? headerBandHeight : 0d;
var pages = 1;
foreach (var el in elements.OfType<JsonObject>())
{
var type = ReadAsString(el["type"], string.Empty);
if (type is not ("table" or "detailTable")) continue;
var source = ReadAsString(el["source"], "mainTable");
var rows = ResolveTableRows(data, source);
var columns = el["columns"]?.AsArray() ?? new JsonArray();
var designY = el["y"]?.GetValue<double>() ?? 0d;
var rowHeightMm = Math.Max(6d, el["rowHeight"]?.GetValue<double>() ?? 8d);
var footerMode = (ReadAsString(el["footerTotalMode"], "overall") ?? "overall").ToLowerInvariant();
var showFooter = !string.Equals(ReadAsString(el["footerShowTotal"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var tableMode = (ReadAsString(el["tableHeightMode"], "autoPage") ?? "autoPage").ToLowerInvariant();
int tablePages;
if (tableMode == "fixedrows")
{
var pageSize = Math.Max(1, el["fixedRows"]?.GetValue<int>() ?? 5);
tablePages = Math.Max(1, (int)Math.Ceiling(rows.Count / (double)pageSize));
}
else
{
var repeatFreeConstrains = RepeatingFreeTableConstrainsDetail(elements, designY, el["h"]?.GetValue<double>() ?? 8d);
var chunks = ComputeAutoPageChunks(el, rows, columns, designY, pageHeightMm,
marginTopMm, marginBottomMm, showFooter, footerMode, rowHeightMm,
repeatFreeConstrains, headerBandMm);
tablePages = Math.Max(1, chunks.Count);
}
pages = Math.Max(pages, tablePages);
}
return Math.Max(1, pages);
}
// ─── 自由表格约束判断 ──────────────────────────────────────────────────
private static bool RepeatingFreeTableConstrainsDetail(JsonArray elements, double detailY, double detailH)
{
var dBottom = detailY + Math.Max(0.01, detailH);
foreach (var el in elements.OfType<JsonObject>())
{
if (!string.Equals(ReadAsString(el["type"]), "freeTable", StringComparison.OrdinalIgnoreCase)) continue;
if (string.Equals(ReadAsString(el["visible"]), "false", StringComparison.OrdinalIgnoreCase)) continue;
if (!string.Equals(ReadAsString(el["printRepeated"]), "true", StringComparison.OrdinalIgnoreCase)) continue;
var fy = el["y"]?.GetValue<double>() ?? 0d;
if (fy >= dBottom - 0.02) continue;
return true;
}
return false;
}
// ─── 表格行数据 & 单元格渲染 ───────────────────────────────────────────
private static List<JsonObject> ResolveTableRows(JsonObject data, string? source)
{
var key = string.IsNullOrWhiteSpace(source) ? "mainTable" : source.Trim();
var node = ResolveField(data, key) as JsonNode;
if (node is JsonArray arr)
return arr.OfType<JsonObject>().ToList();
return new List<JsonObject>();
}
private static string ResolveTableCellInnerHtml(string? contentType, string value)
{
var t = (contentType ?? "text").Trim().ToLowerInvariant();
if (t == "qrcode")
{
try
{
using var gen = new QRCodeGenerator();
var qrData = gen.CreateQrCode(value, QRCodeGenerator.ECCLevel.M);
using var code = new PngByteQRCode(qrData);
var b64 = Convert.ToBase64String(code.GetGraphic(6));
return $"<img src=\"data:image/png;base64,{b64}\" style=\"display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:contain;\" />";
}
catch { return EscapeHtml(value); }
}
if (t == "barcode")
return $"<div style=\"font-family:monospace;text-align:center;\">{EscapeHtml(value)}</div>";
if (t == "image")
return $"<img src=\"{EscapeHtml(value)}\" style=\"display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:contain;\" />";
return EscapeHtml(value);
}
private static string FormatColumnValue(object? raw, JsonNode? col, string? contentType)
{
if (raw == null) return string.Empty;
var t = (contentType ?? "text").Trim().ToLowerInvariant();
if (t is "number" or "amount")
{
if (double.TryParse(raw.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d)
|| double.TryParse(raw.ToString(), NumberStyles.Any, CultureInfo.CurrentCulture, out d))
{
var decimals = Math.Clamp((int?)col?["decimalPlaces"]?.GetValue<int>() ?? 2, 0, 6);
var formatted = d.ToString("N" + decimals, CultureInfo.CurrentCulture);
if (t == "amount")
{
var amountType = ReadAsString(col?["amountType"], "CNY");
var symbol = amountType switch { "USD" => "$", "EUR" => "EUR ", _ => "¥" };
return symbol + formatted;
}
return formatted;
}
}
return raw.ToString() ?? string.Empty;
}
// ─── 多级表头构建 ───────────────────────────────────────────────────────
private sealed class HeaderCell
{
public int Row { get; set; }
public int Col { get; set; }
public int RowSpan { get; set; } = 1;
public int ColSpan { get; set; } = 1;
public string Title { get; set; } = string.Empty;
public string Align { get; set; } = "center";
public double WidthPercent { get; set; }
}
private static List<List<HeaderCell>> BuildTableHeaderRows(JsonNode tableEl, List<JsonObject> columns)
{
var colCount = Math.Max(1, columns.Count);
var widths = columns.Select(c => c["width"]?.GetValue<double>() ?? 30d).ToList();
var totalWidth = Math.Max(0.001, widths.Sum());
double CalcWP(int start, int span)
{
var end = Math.Min(colCount, start + span);
var sum = 0d;
for (var i = start; i < end; i++) sum += widths[i];
return sum / totalWidth * 100d;
}
var enableMulti = string.Equals(ReadAsString(tableEl["enableMultiHeader"], "false"), "true", StringComparison.OrdinalIgnoreCase);
if (!enableMulti)
{
var single = new List<HeaderCell>();
for (var i = 0; i < colCount; i++)
single.Add(new HeaderCell
{
Row = 0, Col = i,
Title = ReadAsString(columns[i]["title"], string.Empty) ?? string.Empty,
Align = ReadAsString(columns[i]["align"], "center") ?? "center",
WidthPercent = CalcWP(i, 1)
});
return new List<List<HeaderCell>> { single };
}
var cfg = tableEl["headerConfig"];
var rowCount = Math.Max(1, cfg?["rowCount"]?.GetValue<int>() ?? 1);
var cfgColCount = Math.Max(1, cfg?["colCount"]?.GetValue<int>() ?? colCount);
if (cfgColCount != colCount)
return BuildTableHeaderRows(new JsonObject { ["enableMultiHeader"] = false }, columns);
var owner = new HeaderCell?[rowCount, colCount];
var cells = new List<HeaderCell>();
var cfgCells = cfg?["cells"]?.AsArray() ?? new JsonArray();
foreach (var n in cfgCells.OfType<JsonObject>())
{
var r = Math.Max(0, n["row"]?.GetValue<int>() ?? 0);
var c = Math.Max(0, n["col"]?.GetValue<int>() ?? 0);
var rs = Math.Max(1, n["rowspan"]?.GetValue<int>() ?? 1);
var cs = Math.Max(1, n["colspan"]?.GetValue<int>() ?? 1);
if (r >= rowCount || c >= colCount) continue;
var maxR = Math.Min(rowCount, r + rs);
var maxC = Math.Min(colCount, c + cs);
var conflict = false;
for (var rr = r; rr < maxR && !conflict; rr++)
for (var cc = c; cc < maxC; cc++)
if (owner[rr, cc] != null) { conflict = true; break; }
if (conflict) continue;
var cell = new HeaderCell
{
Row = r, Col = c, RowSpan = maxR - r, ColSpan = maxC - c,
Title = ReadAsString(n["title"], string.Empty) ?? string.Empty,
Align = ReadAsString(n["align"], "center") ?? "center",
};
cell.WidthPercent = CalcWP(cell.Col, cell.ColSpan);
cells.Add(cell);
for (var rr = r; rr < maxR; rr++)
for (var cc = c; cc < maxC; cc++)
owner[rr, cc] = cell;
}
for (var r = 0; r < rowCount; r++)
{
for (var c = 0; c < colCount; c++)
{
if (owner[r, c] != null) continue;
var cell = new HeaderCell
{
Row = r, Col = c, RowSpan = 1, ColSpan = 1,
Title = r == rowCount - 1 ? (ReadAsString(columns[c]["title"], string.Empty) ?? string.Empty) : string.Empty,
Align = ReadAsString(columns[c]["align"], "center") ?? "center",
WidthPercent = CalcWP(c, 1)
};
owner[r, c] = cell;
cells.Add(cell);
}
}
var rows = Enumerable.Range(0, rowCount).Select(_ => new List<HeaderCell>()).ToList();
foreach (var cell in cells)
{
if (!ReferenceEquals(owner[cell.Row, cell.Col], cell)) continue;
rows[cell.Row].Add(cell);
}
foreach (var row in rows) row.Sort((a, b) => a.Col.CompareTo(b.Col));
return rows;
}
// ─── 通用工具 ───────────────────────────────────────────────────────────
private static object? ResolveField(JsonObject data, string fieldPath)
{
JsonNode? node = data;
foreach (var key in fieldPath.Split('.'))
{
node = node?[key];
if (node == null) return null;
}
return node switch
{
JsonArray a => a,
JsonObject o => o,
JsonValue v when v.TryGetValue<string>(out var s) => s,
JsonValue v when v.TryGetValue<double>(out var d) => d,
JsonValue v when v.TryGetValue<bool>(out var b) => b,
_ => node?.ToString()
};
}
private static string? ReadAsString(JsonNode? node, string? defaultValue = null)
{
if (node == null) return defaultValue;
if (node is JsonValue v)
{
if (v.TryGetValue<string>(out var s)) return s;
if (v.TryGetValue<double>(out var d)) return d.ToString(CultureInfo.InvariantCulture);
if (v.TryGetValue<int>(out var i)) return i.ToString(CultureInfo.InvariantCulture);
if (v.TryGetValue<long>(out var l)) return l.ToString(CultureInfo.InvariantCulture);
if (v.TryGetValue<bool>(out var b)) return b ? "true" : "false";
}
return node.ToString();
}
private static string EscapeHtml(string? s) =>
(s ?? string.Empty).Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
private static string ConvertDateFormat(string? jsFormat) =>
(jsFormat ?? "yyyy-MM-dd").Replace("YYYY", "yyyy").Replace("DD", "dd");
private static string ReplaceTemplatePlaceholders(string input, JsonObject data, int pageNo, int totalPages)
{
if (string.IsNullOrEmpty(input)) return input;
return Regex.Replace(input, @"\{\{\s*([\w\.]+)\s*\}\}", m =>
{
var key = m.Groups[1].Value;
if (key.Equals("pageNo", StringComparison.OrdinalIgnoreCase)) return pageNo.ToString(CultureInfo.InvariantCulture);
if (key.Equals("totalPages", StringComparison.OrdinalIgnoreCase)) return totalPages.ToString(CultureInfo.InvariantCulture);
return ResolveField(data, key)?.ToString() ?? string.Empty;
});
}
private static string RemoveCssProperty(string style, string propertyName)
{
if (string.IsNullOrWhiteSpace(style)) return string.Empty;
var kept = style.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(p => p.Trim())
.Where(p =>
{
var idx = p.IndexOf(':');
if (idx <= 0) return true;
return !p[..idx].Trim().Equals(propertyName, StringComparison.OrdinalIgnoreCase);
})
.ToList();
return kept.Count == 0 ? string.Empty : string.Join(";", kept) + ";";
}
}