1148 lines
58 KiB
C#
1148 lines
58 KiB
C#
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 表示该格被上方行合并,渲染时跳过;值 > 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("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
||
|
||
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) + ";";
|
||
}
|
||
}
|