2026-05-12 18:29:03 +08:00
|
|
|
|
using System.Text;
|
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
|
|
using System.Globalization;
|
|
|
|
|
|
using QRCoder;
|
2026-05-13 12:35:02 +08:00
|
|
|
|
using ZXing;
|
|
|
|
|
|
using ZXing.Rendering;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
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>
|
2026-05-14 11:25:17 +08:00
|
|
|
|
public static string RenderToHtml(string templateJson, JsonObject data, bool enableScreenAutoFit = true)
|
2026-05-12 18:29:03 +08:00
|
|
|
|
{
|
|
|
|
|
|
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");
|
2026-05-14 11:25:17 +08:00
|
|
|
|
if (enableScreenAutoFit)
|
|
|
|
|
|
{
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-05-13 13:04:31 +08:00
|
|
|
|
// ─ 元素级边框(title/subtitle/text/date/pageNo/reportHeader/reportFooter 均支持)─
|
|
|
|
|
|
var bWidth = Math.Max(0d, style?["borderWidth"]?.GetValue<double>() ?? 0d);
|
|
|
|
|
|
var bColor = ReadAsString(style?["borderColor"], "#222") ?? "#222";
|
|
|
|
|
|
string ElemBorderSide(string hideKey) =>
|
|
|
|
|
|
bWidth > 0 && !ReadBoolDefault(style?[hideKey], false)
|
|
|
|
|
|
? $"{(int)Math.Round(bWidth)}px solid {bColor}" : "none";
|
|
|
|
|
|
var elemBorderCss = bWidth > 0
|
|
|
|
|
|
? $"border-top:{ElemBorderSide("hideBorderTop")};border-right:{ElemBorderSide("hideBorderRight")};border-bottom:{ElemBorderSide("hideBorderBottom")};border-left:{ElemBorderSide("hideBorderLeft")};"
|
|
|
|
|
|
: string.Empty;
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
// ─ 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;" +
|
2026-05-13 13:04:31 +08:00
|
|
|
|
$"z-index:{zIndex};{bgCss}{rotCss}{elemBorderCss}";
|
2026-05-12 18:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
var type = ReadAsString(el["type"], "text");
|
|
|
|
|
|
// 设计器默认 bindField 为空字符串 "",须视为「未绑定」,否则误走数据分支导致标题等只剩空串
|
|
|
|
|
|
var bindFieldRaw = ReadAsString(el["bindField"]);
|
|
|
|
|
|
var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim();
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
// 已绑定数据字段:缺键或解析不到时留空,不回退到画布上的设计占位 text(否则会误显示「采购订单」等)
|
|
|
|
|
|
text = ResolveField(data, bindField)?.ToString() ?? string.Empty;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
var bindFieldRaw = ReadAsString(el["bindField"]);
|
|
|
|
|
|
var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim();
|
|
|
|
|
|
var value = ReadAsString(el["value"], string.Empty);
|
2026-05-12 18:29:03 +08:00
|
|
|
|
if (bindField != null)
|
2026-05-14 10:43:51 +08:00
|
|
|
|
value = ResolveField(data, bindField)?.ToString() ?? string.Empty;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-13 12:35:02 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 使用 ZXing.Net 的 SVG 渲染器生成真实可扫码的 1D 条码,与 web 端 jsbarcode 输出的
|
|
|
|
|
|
/// SVG 行为一致(preserveAspectRatio="xMidYMid meet",外层 CSS 100% 保比例铺满)。
|
|
|
|
|
|
/// 失败时回退到原占位 SVG,避免渲染流程中断。
|
|
|
|
|
|
/// </summary>
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
var bindFieldRaw = ReadAsString(el["bindField"]);
|
|
|
|
|
|
var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim();
|
|
|
|
|
|
var value = ReadAsString(el["value"], string.Empty);
|
2026-05-12 18:29:03 +08:00
|
|
|
|
if (bindField != null)
|
2026-05-14 10:43:51 +08:00
|
|
|
|
value = ResolveField(data, bindField)?.ToString() ?? string.Empty;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
|
|
|
|
|
|
2026-05-13 12:35:02 +08:00
|
|
|
|
// 从元素配置取格式/显示文字开关;元素未设时按 Code128 + 显示文字默认
|
|
|
|
|
|
var format = ParseBarcodeFormat(ReadAsString(el["format"]));
|
|
|
|
|
|
var displayValue = !string.Equals(ReadAsString(el["displayValue"], "true"), "false", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
var textAlign = ReadAsString(el["textAlign"], "center")!;
|
2026-05-13 13:04:31 +08:00
|
|
|
|
var lineWidthPx = Math.Max(1, (int)Math.Round(el["lineWidth"]?.GetValue<double>() ?? 2d));
|
|
|
|
|
|
var barHeightPx = Math.Max(10, (int)Math.Round(el["barHeight"]?.GetValue<double>() ?? 60d));
|
|
|
|
|
|
var barFontSize = Math.Max(8, (int)Math.Round(el["fontSize"]?.GetValue<double>() ?? 14d));
|
2026-05-13 14:06:13 +08:00
|
|
|
|
// 与后端对齐:优先读元素级 fillCell;兼容旧模板的 style.fillCell。
|
|
|
|
|
|
// 均未配置时默认 false(保比例居中)。
|
|
|
|
|
|
var fillCellNode = el["fillCell"] ?? el["style"]?["fillCell"];
|
|
|
|
|
|
var fillCell = string.Equals(ReadAsString(fillCellNode, "false"), "true", StringComparison.OrdinalIgnoreCase);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var inner = BuildBarcodeSvgInner(value, format, displayValue, textAlign, lineWidthPx, barHeightPx, barFontSize, fillCell);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
|
|
|
|
|
var wrapStyle = "display:flex;align-items:center;justify-content:center;overflow:hidden;";
|
2026-05-12 18:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 12:35:02 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 把模板元素上的 format 字符串映射到 ZXing 的 BarcodeFormat 枚举。
|
|
|
|
|
|
/// 覆盖 web 端设计器(jsbarcode)支持的全部码制;对 ZXing 不直接支持的子集
|
|
|
|
|
|
/// (CODE128 子集、MSI 变体、EAN-5/EAN-2、pharmacode)做最接近映射,避免渲染失败。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static BarcodeFormat ParseBarcodeFormat(string? fmt)
|
|
|
|
|
|
{
|
|
|
|
|
|
var key = (fmt ?? string.Empty).Trim().ToUpperInvariant().Replace("-", "_");
|
|
|
|
|
|
return key switch
|
|
|
|
|
|
{
|
|
|
|
|
|
// Code128 系列:ZXing 自动识别 A/B/C 编码集,统一映射到 CODE_128
|
|
|
|
|
|
"" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"CODE128" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"CODE_128" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"CODE128A" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"CODE128B" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"CODE128C" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
|
|
|
|
|
|
// Code 39 / 93
|
|
|
|
|
|
"CODE39" => BarcodeFormat.CODE_39,
|
|
|
|
|
|
"CODE_39" => BarcodeFormat.CODE_39,
|
|
|
|
|
|
"CODE93" => BarcodeFormat.CODE_93,
|
|
|
|
|
|
"CODE_93" => BarcodeFormat.CODE_93,
|
|
|
|
|
|
|
|
|
|
|
|
// EAN / UPC
|
|
|
|
|
|
"EAN13" => BarcodeFormat.EAN_13,
|
|
|
|
|
|
"EAN_13" => BarcodeFormat.EAN_13,
|
|
|
|
|
|
"EAN8" => BarcodeFormat.EAN_8,
|
|
|
|
|
|
"EAN_8" => BarcodeFormat.EAN_8,
|
|
|
|
|
|
// EAN-5 / EAN-2 是 EAN-13 的附加码,ZXing 不直接支持;为避免渲染失败回退为 CODE_128
|
|
|
|
|
|
"EAN5" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"EAN_5" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"EAN2" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"EAN_2" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"UPC" => BarcodeFormat.UPC_A,
|
|
|
|
|
|
"UPCA" => BarcodeFormat.UPC_A,
|
|
|
|
|
|
"UPC_A" => BarcodeFormat.UPC_A,
|
|
|
|
|
|
"UPCE" => BarcodeFormat.UPC_E,
|
|
|
|
|
|
"UPC_E" => BarcodeFormat.UPC_E,
|
|
|
|
|
|
|
|
|
|
|
|
// ITF / ITF-14
|
|
|
|
|
|
"ITF" => BarcodeFormat.ITF,
|
|
|
|
|
|
"ITF14" => BarcodeFormat.ITF,
|
|
|
|
|
|
"ITF_14" => BarcodeFormat.ITF,
|
|
|
|
|
|
|
|
|
|
|
|
// MSI 全部变体:ZXing 不直接支持,回退到 CODE_128(保证至少能扫码识别值)
|
|
|
|
|
|
"MSI" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"MSI10" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"MSI11" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"MSI1010" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
"MSI1110" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
|
|
|
|
|
|
// Pharmacode:ZXing 不支持,回退 CODE_128
|
|
|
|
|
|
"PHARMACODE" => BarcodeFormat.CODE_128,
|
|
|
|
|
|
|
|
|
|
|
|
// Codabar
|
|
|
|
|
|
"CODABAR" => BarcodeFormat.CODABAR,
|
|
|
|
|
|
|
|
|
|
|
|
_ => BarcodeFormat.CODE_128,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-05-13 14:06:13 +08:00
|
|
|
|
/// 把 ZXing 输出的 SVG 转换为"由外层 CSS 控制尺寸 + 可配置铺满策略"的形式:
|
|
|
|
|
|
/// 1) <b>强制清除</b>根 svg 节点上原有的 width / height / viewBox / preserveAspectRatio 属性
|
|
|
|
|
|
/// (不再依赖 ZXing 自身输出格式,无论它用单引号、双引号、无引号都覆写);
|
|
|
|
|
|
/// 2) 用调用方传入的 viewBoxWidth × viewBoxHeight 作为 viewBox,保证容器内
|
|
|
|
|
|
/// 条码比例与 BuildBarcodeSvgInner 计算的画布比例一致;
|
|
|
|
|
|
/// 3) preserveAspectRatio:fillCell=true → "none"(按容器拉伸铺满,矢量缩放无损);
|
|
|
|
|
|
/// fillCell=false → "xMidYMid meet"(保比例居中,与 web jsbarcode 默认一致);
|
|
|
|
|
|
/// 4) 重新挂上 width="100%" height="100%",确保外层 div 100% 铺满。
|
|
|
|
|
|
/// 这里不再"尝试从原 SVG 派生 viewBox",否则 regex 不匹配 ZXing 实际输出(单引号 /
|
|
|
|
|
|
/// 无属性 / 顺序差异)时,viewBox 会被漏掉,导致 SVG 被 CSS 双向拉伸,条码视觉变粗、变高。
|
2026-05-13 12:35:02 +08:00
|
|
|
|
/// </summary>
|
2026-05-13 14:06:13 +08:00
|
|
|
|
private static string? NormalizeBarcodeSvg(string svg, bool fillCell = false,
|
|
|
|
|
|
double viewBoxWidth = 0, double viewBoxHeight = 0)
|
2026-05-13 12:35:02 +08:00
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(svg)) return null;
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var targetAspect = fillCell ? "none" : "xMidYMid meet";
|
2026-05-13 12:35:02 +08:00
|
|
|
|
return Regex.Replace(svg, @"<svg\b([^>]*)>", m =>
|
|
|
|
|
|
{
|
|
|
|
|
|
var attrs = m.Groups[1].Value;
|
|
|
|
|
|
|
2026-05-13 14:06:13 +08:00
|
|
|
|
// 同时兼容单/双引号,彻底清理可能影响最终缩放策略的属性
|
|
|
|
|
|
attrs = Regex.Replace(attrs, @"\s(width|height|viewBox|preserveAspectRatio)\s*=\s*(""[^""]*""|'[^']*')",
|
|
|
|
|
|
string.Empty, RegexOptions.IgnoreCase);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
2026-05-13 14:06:13 +08:00
|
|
|
|
// 用调用方提供的稳定画布比例作为 viewBox;调用方没传时再回退去读原属性
|
|
|
|
|
|
var vbW = viewBoxWidth;
|
|
|
|
|
|
var vbH = viewBoxHeight;
|
|
|
|
|
|
if (vbW <= 0 || vbH <= 0)
|
2026-05-13 12:35:02 +08:00
|
|
|
|
{
|
2026-05-13 14:06:13 +08:00
|
|
|
|
// 兼容回退路径:从原 svg 标签 attrs 中再读一次 width/height(无 viewBox 时)
|
|
|
|
|
|
// 这段在调用方传入了 viewBoxWidth/Height 时不会走,保留兜底防御
|
|
|
|
|
|
var widthMatch = Regex.Match(m.Groups[1].Value, @"\swidth\s*=\s*(""[^""]+""|'[^']+')", RegexOptions.IgnoreCase);
|
|
|
|
|
|
var heightMatch = Regex.Match(m.Groups[1].Value, @"\sheight\s*=\s*(""[^""]+""|'[^']+')", RegexOptions.IgnoreCase);
|
|
|
|
|
|
if (widthMatch.Success && heightMatch.Success)
|
|
|
|
|
|
{
|
|
|
|
|
|
var ws = widthMatch.Groups[1].Value.Trim('"', '\'');
|
|
|
|
|
|
var hs = heightMatch.Groups[1].Value.Trim('"', '\'');
|
|
|
|
|
|
double.TryParse(Regex.Replace(ws, @"[^\d.]", ""), NumberStyles.Any, CultureInfo.InvariantCulture, out vbW);
|
|
|
|
|
|
double.TryParse(Regex.Replace(hs, @"[^\d.]", ""), NumberStyles.Any, CultureInfo.InvariantCulture, out vbH);
|
|
|
|
|
|
}
|
2026-05-13 12:35:02 +08:00
|
|
|
|
}
|
2026-05-13 14:06:13 +08:00
|
|
|
|
// 实在拿不到时按 jsbarcode 风格的兜底比例(避免 SVG 被纯拉伸)
|
|
|
|
|
|
if (vbW <= 0) vbW = 200;
|
|
|
|
|
|
if (vbH <= 0) vbH = 60;
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var vbStr = $"0 0 {vbW.ToString("0.###", CultureInfo.InvariantCulture)} {vbH.ToString("0.###", CultureInfo.InvariantCulture)}";
|
|
|
|
|
|
return $"<svg width=\"100%\" height=\"100%\" viewBox=\"{vbStr}\" preserveAspectRatio=\"{targetAspect}\"{attrs}>";
|
2026-05-13 12:35:02 +08:00
|
|
|
|
}, RegexOptions.IgnoreCase);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>条码生成失败时的占位 SVG,至少把原文字显示出来便于排查。</summary>
|
|
|
|
|
|
private static string BuildFallbackBarcodeSvg(string value)
|
|
|
|
|
|
{
|
|
|
|
|
|
var escapedVal = EscapeHtml(value);
|
|
|
|
|
|
return $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" height=\"100%\" preserveAspectRatio=\"xMidYMid meet\" viewBox=\"0 0 200 60\">" +
|
|
|
|
|
|
$"<rect width=\"200\" height=\"60\" fill=\"white\"/>" +
|
|
|
|
|
|
$"<text x=\"100\" y=\"35\" font-size=\"12\" text-anchor=\"middle\" font-family=\"monospace\">{escapedVal}</text>" +
|
|
|
|
|
|
$"</svg>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 表格单元格用:包装统一的 BuildBarcodeSvgInner,保持与独立 barcode 元素一致的视觉比例。
|
|
|
|
|
|
/// </summary>
|
2026-05-13 13:04:31 +08:00
|
|
|
|
private static string BuildBarcodeCellSvg(string value, string? format = null, bool displayValue = true, string? textAlign = null, int barFontSize = 14)
|
|
|
|
|
|
=> BuildBarcodeSvgInner(value ?? string.Empty, ParseBarcodeFormat(format), displayValue, textAlign, 2, 60, barFontSize);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 调用 ZXing.Net 生成条码 SVG 的统一入口:用与 web 端 jsbarcode 默认参数等价的比例
|
|
|
|
|
|
/// (lineWidth ≈ 2px/module、barHeight ≈ 60px、底部文字区 ≈ 18px)来派生 ZXing 的
|
|
|
|
|
|
/// Width/Height,使生成的 SVG 内部 viewBox 比例稳定(与 web 视觉风格对齐),不会随
|
|
|
|
|
|
/// 元素框形状(90×60 / 50×30 mm)漂移成 1.5:1。外层 div 用 100%+preserveAspectRatio
|
|
|
|
|
|
/// ="xMidYMid meet" 自适应铺满。
|
2026-05-13 14:06:13 +08:00
|
|
|
|
/// textAlign 控制底部文字对齐:center / left / right / justify(两端),
|
|
|
|
|
|
/// 文字通过 InjectBarcodeText 向 SVG 底部注入 <text> 元素实现,与 web 端 jsbarcode 行为对齐。
|
|
|
|
|
|
/// 注意:ZXing.Net 的 SVG 渲染器不生成人类可读文字(PureBarcode=false 在 SvgRenderer 无效),
|
|
|
|
|
|
/// 必须手动注入;同时只将 barHeight 传给 ZXing 渲染条形,避免条形撑满含文字区的总高度。
|
2026-05-13 12:35:02 +08:00
|
|
|
|
/// </summary>
|
2026-05-13 13:04:31 +08:00
|
|
|
|
private static string BuildBarcodeSvgInner(string value, BarcodeFormat format, bool displayValue,
|
2026-05-13 14:06:13 +08:00
|
|
|
|
string? textAlign = null, int lineWidth = 2, int barHeight = 60, int barFontSize = 14, bool fillCell = false)
|
2026-05-13 12:35:02 +08:00
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var moduleCount = EstimateBarcodeModuleCount(value, format);
|
2026-05-13 13:04:31 +08:00
|
|
|
|
var widthPx = Math.Max(120, moduleCount * lineWidth);
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var barOnlyH = Math.Max(10, barHeight); // 只把条高给 ZXing;文字由 InjectBarcodeText 注入
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
|
|
|
|
|
var writer = new BarcodeWriterSvg
|
|
|
|
|
|
{
|
|
|
|
|
|
Format = format,
|
|
|
|
|
|
Options = new ZXing.Common.EncodingOptions
|
|
|
|
|
|
{
|
2026-05-13 14:06:13 +08:00
|
|
|
|
Width = widthPx,
|
|
|
|
|
|
Height = barOnlyH,
|
|
|
|
|
|
Margin = 0,
|
|
|
|
|
|
PureBarcode = true, // ZXing SVG renderer 不支持文字,统一用 PureBarcode=true
|
2026-05-13 12:35:02 +08:00
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var svgStr = writer.Write(value)?.Content ?? string.Empty;
|
|
|
|
|
|
// 关键:把我们提供给 ZXing 的 widthPx × barOnlyH 作为目标 viewBox 强制写入,
|
|
|
|
|
|
// 让桌面端 SVG 与 web jsbarcode 同样进入"viewBox + preserveAspectRatio=meet"渲染
|
|
|
|
|
|
// 路径,避免某些 ZXing 输出顺序/引号风格让 regex 漏掉 viewBox 派生导致条码被 CSS 双向拉伸。
|
|
|
|
|
|
//(注:底部文字区由后续 InjectBarcodeText 再把 viewBox 的高度扩展上去,
|
|
|
|
|
|
// 因此此处只用条形高度 barOnlyH。)
|
|
|
|
|
|
var normalized = NormalizeBarcodeSvg(svgStr, fillCell, widthPx, barOnlyH)
|
|
|
|
|
|
?? BuildFallbackBarcodeSvg(value);
|
|
|
|
|
|
// 裁掉 ZXing 可能保留的左右静区,让条码主体宽度更贴近 web 端 jsbarcode 观感。
|
|
|
|
|
|
normalized = TightenBarcodeHorizontalViewBox(normalized, widthPx);
|
|
|
|
|
|
|
|
|
|
|
|
// 文字注入:在 viewBox 底部添加 <text>,扩展 viewBox 高度以容纳文字区
|
|
|
|
|
|
if (displayValue)
|
|
|
|
|
|
normalized = InjectBarcodeText(normalized, value, barFontSize, textAlign);
|
|
|
|
|
|
|
2026-05-13 12:35:02 +08:00
|
|
|
|
return normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
return BuildFallbackBarcodeSvg(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-05-13 14:06:13 +08:00
|
|
|
|
/// 向 ZXing 条码 SVG 底部注入人类可读文字并扩展 viewBox 高度,与 web 端 jsbarcode displayValue 行为对齐。
|
|
|
|
|
|
/// textAlign 支持 center / left / right / justify(两端对齐,通过 textLength+lengthAdjust 实现)。
|
2026-05-13 12:35:02 +08:00
|
|
|
|
/// </summary>
|
2026-05-13 14:06:13 +08:00
|
|
|
|
private static string InjectBarcodeText(string svg, string text, int fontSize, string? textAlign)
|
2026-05-13 12:35:02 +08:00
|
|
|
|
{
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var (x0, y0, vbWidth, vbHeight) = ParseViewBoxOrDefault(svg);
|
|
|
|
|
|
var safeFs = Math.Max(8, fontSize);
|
|
|
|
|
|
const double textMargin = 2d; // 对齐 jsbarcode 默认 textMargin
|
|
|
|
|
|
var footerH = safeFs + textMargin + 2d; // 预留文字区 + 底部余量,避免裁切
|
|
|
|
|
|
var newH = vbHeight + footerH;
|
|
|
|
|
|
var wStr = vbWidth.ToString("0.###", CultureInfo.InvariantCulture);
|
|
|
|
|
|
var hStr = newH.ToString("0.###", CultureInfo.InvariantCulture);
|
|
|
|
|
|
|
|
|
|
|
|
// 扩展 viewBox 高度(保留原始 x0/y0)
|
|
|
|
|
|
svg = Regex.Replace(svg, @"viewBox\s*=\s*""([^""]+)""", m =>
|
|
|
|
|
|
{
|
|
|
|
|
|
return $"viewBox=\"{x0.ToString("0.###", CultureInfo.InvariantCulture)} {y0.ToString("0.###", CultureInfo.InvariantCulture)} {wStr} {hStr}\"";
|
|
|
|
|
|
}, RegexOptions.IgnoreCase);
|
|
|
|
|
|
|
|
|
|
|
|
// 计算文字 y 基线(条形底边 + textMargin + fontSize),与 jsbarcode 版式一致
|
|
|
|
|
|
var yStr = (y0 + vbHeight + textMargin + safeFs).ToString("0.###", CultureInfo.InvariantCulture);
|
|
|
|
|
|
// 关键:文字水平对齐基于“条码墨迹真实范围”而非整块 viewBox。
|
|
|
|
|
|
// 否则当 SVG 内部存在左右静区/内缩时,文字会看起来和条码不对齐。
|
|
|
|
|
|
var (inkLeft, inkRight) = ResolveBarcodeInkBounds(svg, vbWidth);
|
|
|
|
|
|
var inkWidth = Math.Max(1e-6, inkRight - inkLeft);
|
|
|
|
|
|
var leftStr = inkLeft.ToString("0.###", CultureInfo.InvariantCulture);
|
|
|
|
|
|
var rightStr = inkRight.ToString("0.###", CultureInfo.InvariantCulture);
|
|
|
|
|
|
var widthStr = inkWidth.ToString("0.###", CultureInfo.InvariantCulture);
|
|
|
|
|
|
var midX = (inkLeft + inkWidth / 2d).ToString("0.###", CultureInfo.InvariantCulture);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
var align = (textAlign ?? "center").Trim().ToLowerInvariant();
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var esc = EscapeHtml(text);
|
|
|
|
|
|
|
|
|
|
|
|
var textElem = align switch
|
|
|
|
|
|
{
|
|
|
|
|
|
"left" => $"<text x=\"{leftStr}\" y=\"{yStr}\" text-anchor=\"start\" font-size=\"{safeFs}\" font-family=\"monospace\" fill=\"#000000\">{esc}</text>",
|
|
|
|
|
|
"right" => $"<text x=\"{rightStr}\" y=\"{yStr}\" text-anchor=\"end\" font-size=\"{safeFs}\" font-family=\"monospace\" fill=\"#000000\">{esc}</text>",
|
|
|
|
|
|
"justify" => $"<text x=\"{leftStr}\" y=\"{yStr}\" text-anchor=\"start\" textLength=\"{widthStr}\" lengthAdjust=\"spacing\" font-size=\"{safeFs}\" font-family=\"monospace\" fill=\"#000000\">{esc}</text>",
|
|
|
|
|
|
_ => $"<text x=\"{midX}\" y=\"{yStr}\" text-anchor=\"middle\" font-size=\"{safeFs}\" font-family=\"monospace\" fill=\"#000000\">{esc}</text>",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 插入 </svg> 闭合标签之前
|
|
|
|
|
|
return Regex.Replace(svg, @"</svg>", $"{textElem}</svg>", RegexOptions.IgnoreCase);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 收紧条码水平 viewBox,裁掉左右静区;仅调整 x/width,不改 y/height。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static string TightenBarcodeHorizontalViewBox(string svg, double fallbackWidth)
|
|
|
|
|
|
{
|
|
|
|
|
|
var (x0, y0, vbW, vbH) = ParseViewBoxOrDefault(svg, fallbackWidth);
|
|
|
|
|
|
var (inkLeft, inkRight) = ResolveBarcodeInkBounds(svg, vbW);
|
|
|
|
|
|
var inkW = inkRight - inkLeft;
|
|
|
|
|
|
if (inkW <= 1e-6) return svg;
|
|
|
|
|
|
|
|
|
|
|
|
var newX = x0 + inkLeft;
|
|
|
|
|
|
var newViewBox = $"viewBox=\"{newX.ToString("0.###", CultureInfo.InvariantCulture)} {y0.ToString("0.###", CultureInfo.InvariantCulture)} {inkW.ToString("0.###", CultureInfo.InvariantCulture)} {vbH.ToString("0.###", CultureInfo.InvariantCulture)}\"";
|
|
|
|
|
|
return Regex.Replace(svg, @"viewBox\s*=\s*""[^""]+""", newViewBox, RegexOptions.IgnoreCase);
|
|
|
|
|
|
}
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
2026-05-13 14:06:13 +08:00
|
|
|
|
private static (double x0, double y0, double w, double h) ParseViewBoxOrDefault(string svg, double fallbackWidth = 200, double fallbackHeight = 60)
|
|
|
|
|
|
{
|
|
|
|
|
|
var m = Regex.Match(svg ?? string.Empty, @"viewBox\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
|
|
|
|
|
|
if (m.Success)
|
2026-05-13 12:35:02 +08:00
|
|
|
|
{
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var parts = Regex.Split(m.Groups[1].Value.Trim(), @"[\s,]+");
|
2026-05-13 12:35:02 +08:00
|
|
|
|
if (parts.Length >= 4
|
2026-05-13 14:06:13 +08:00
|
|
|
|
&& double.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var x0)
|
|
|
|
|
|
&& double.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var y0)
|
|
|
|
|
|
&& double.TryParse(parts[2], NumberStyles.Any, CultureInfo.InvariantCulture, out var w)
|
|
|
|
|
|
&& double.TryParse(parts[3], NumberStyles.Any, CultureInfo.InvariantCulture, out var h)
|
|
|
|
|
|
&& w > 0 && h > 0)
|
2026-05-13 12:35:02 +08:00
|
|
|
|
{
|
2026-05-13 14:06:13 +08:00
|
|
|
|
return (x0, y0, w, h);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-13 14:06:13 +08:00
|
|
|
|
return (0d, 0d, Math.Max(1d, fallbackWidth), Math.Max(1d, fallbackHeight));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 从 SVG 中提取条码“黑色墨迹”的左右边界。
|
|
|
|
|
|
/// 优先扫描 <rect>,忽略白色/透明填充;失败时回退到整块 viewBox。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static (double left, double right) ResolveBarcodeInkBounds(string svg, double fallbackWidth)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(svg))
|
|
|
|
|
|
return (0d, Math.Max(1d, fallbackWidth));
|
|
|
|
|
|
|
|
|
|
|
|
var rects = Regex.Matches(svg, @"<rect\b[^>]*>", RegexOptions.IgnoreCase);
|
|
|
|
|
|
double minX = double.PositiveInfinity;
|
|
|
|
|
|
double maxX = double.NegativeInfinity;
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
2026-05-13 14:06:13 +08:00
|
|
|
|
foreach (Match m in rects)
|
2026-05-13 12:35:02 +08:00
|
|
|
|
{
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var tag = m.Value;
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
2026-05-13 14:06:13 +08:00
|
|
|
|
// 跳过明显的白底/透明背景 rect
|
|
|
|
|
|
var fill = ReadSvgAttr(tag, "fill")?.Trim().ToLowerInvariant();
|
|
|
|
|
|
if (fill is "none" or "transparent" or "#fff" or "#ffffff" or "white")
|
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
|
|
var xs = ReadSvgAttr(tag, "x");
|
|
|
|
|
|
var ws = ReadSvgAttr(tag, "width");
|
|
|
|
|
|
if (!TryParseSvgNumber(xs, out var x) || !TryParseSvgNumber(ws, out var w) || w <= 0)
|
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
|
|
if (x < minX) minX = x;
|
|
|
|
|
|
if (x + w > maxX) maxX = x + w;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (double.IsInfinity(minX) || double.IsInfinity(maxX) || maxX <= minX)
|
|
|
|
|
|
return (0d, Math.Max(1d, fallbackWidth));
|
|
|
|
|
|
|
|
|
|
|
|
return (minX, maxX);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string? ReadSvgAttr(string tag, string attr)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(tag) || string.IsNullOrWhiteSpace(attr))
|
|
|
|
|
|
return null;
|
|
|
|
|
|
var mm = Regex.Match(tag, $@"\b{Regex.Escape(attr)}\s*=\s*(""([^""]*)""|'([^']*)')", RegexOptions.IgnoreCase);
|
|
|
|
|
|
if (!mm.Success) return null;
|
|
|
|
|
|
return !string.IsNullOrEmpty(mm.Groups[2].Value) ? mm.Groups[2].Value : mm.Groups[3].Value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static bool TryParseSvgNumber(string? raw, out double value)
|
|
|
|
|
|
{
|
|
|
|
|
|
value = 0;
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(raw)) return false;
|
|
|
|
|
|
var s = raw.Trim();
|
|
|
|
|
|
// 支持 "123", "123.45", "123px";百分比不参与墨迹边界计算
|
|
|
|
|
|
if (s.EndsWith("%", StringComparison.Ordinal)) return false;
|
|
|
|
|
|
s = Regex.Replace(s, @"[^\d\.\-+eE]", "");
|
|
|
|
|
|
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 估算 1D 条码所需的模块数(即最窄条单位个数),用于给 ZXing 提供合适的 Width,
|
|
|
|
|
|
/// 确保 SVG viewBox 比例稳定。各码制按字符 × 每字符模块数 + 起止/校验粗略估算。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static int EstimateBarcodeModuleCount(string value, BarcodeFormat format)
|
|
|
|
|
|
{
|
|
|
|
|
|
var n = Math.Max(1, value?.Length ?? 1);
|
|
|
|
|
|
return format switch
|
|
|
|
|
|
{
|
|
|
|
|
|
BarcodeFormat.CODE_128 => n * 11 + 35,
|
|
|
|
|
|
BarcodeFormat.CODE_39 => (n + 2) * 13,
|
|
|
|
|
|
BarcodeFormat.CODE_93 => (n + 4) * 9,
|
|
|
|
|
|
BarcodeFormat.EAN_13 => 95,
|
|
|
|
|
|
BarcodeFormat.UPC_A => 95,
|
|
|
|
|
|
BarcodeFormat.EAN_8 => 67,
|
|
|
|
|
|
BarcodeFormat.UPC_E => 51,
|
|
|
|
|
|
BarcodeFormat.ITF => (n / 2 + 1) * 9 + 7,
|
|
|
|
|
|
BarcodeFormat.CODABAR => (n + 2) * 10,
|
|
|
|
|
|
_ => n * 11 + 35,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
// ─── Image ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private static string RenderImage(JsonNode el, JsonObject data, string posStyle,
|
|
|
|
|
|
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
|
|
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
var bindFieldRaw = ReadAsString(el["bindField"]);
|
|
|
|
|
|
var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim();
|
|
|
|
|
|
var src = ReadAsString(el["src"], string.Empty);
|
2026-05-12 18:29:03 +08:00
|
|
|
|
if (bindField != null)
|
2026-05-14 10:43:51 +08:00
|
|
|
|
src = ResolveField(data, bindField)?.ToString() ?? string.Empty;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-05-12 18:49:12 +08:00
|
|
|
|
var rowCount = Math.Max(1, el["rowCount"]?.GetValue<int>() ?? 1);
|
|
|
|
|
|
var colCount = Math.Max(1, el["colCount"]?.GetValue<int>() ?? 1);
|
2026-05-12 18:29:03 +08:00
|
|
|
|
var cells = el["cells"]?.AsArray() ?? [];
|
2026-05-12 18:49:12 +08:00
|
|
|
|
var borderColor = ReadAsString(el["borderColor"], "#222222") ?? "#222222";
|
|
|
|
|
|
var borderWidth = Math.Max(1, el["borderWidth"]?.GetValue<int>() ?? 1);
|
|
|
|
|
|
|
|
|
|
|
|
var colWidths = ResolveFreeTableColWidths(el, colCount, wMm);
|
|
|
|
|
|
var rowHeights = ResolveFreeTableRowHeights(el, rowCount, hMm);
|
|
|
|
|
|
var anchors = NormalizeFreeTableAnchors(rowCount, colCount, cells);
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-group anchors by row for O(n) iteration
|
|
|
|
|
|
var anchorsByRow = anchors
|
|
|
|
|
|
.GroupBy(a => a.Row)
|
|
|
|
|
|
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Col).ToList());
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
var sb = new StringBuilder();
|
2026-05-12 18:49:12 +08:00
|
|
|
|
sb.Append($"<table style=\"width:{wMm.ToString("0.###", CultureInfo.InvariantCulture)}mm;border-collapse:collapse;border-spacing:0;table-layout:fixed;box-sizing:border-box;\">");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
sb.Append("<colgroup>");
|
2026-05-12 18:49:12 +08:00
|
|
|
|
foreach (var cw in colWidths)
|
|
|
|
|
|
sb.Append($"<col style=\"width:{cw.ToString("0.###", CultureInfo.InvariantCulture)}mm;box-sizing:border-box;\" />");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
sb.Append("</colgroup><tbody>");
|
|
|
|
|
|
|
|
|
|
|
|
for (var r = 0; r < rowCount; r++)
|
|
|
|
|
|
{
|
|
|
|
|
|
var rh = r < rowHeights.Length ? rowHeights[r] : hMm / rowCount;
|
2026-05-12 18:49:12 +08:00
|
|
|
|
sb.Append($"<tr style=\"height:{rh.ToString("0.###", CultureInfo.InvariantCulture)}mm;box-sizing:border-box;\">");
|
|
|
|
|
|
|
|
|
|
|
|
if (!anchorsByRow.TryGetValue(r, out var rowAnchors))
|
|
|
|
|
|
rowAnchors = new List<FreeTableAnchorCell>();
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var cell in rowAnchors)
|
2026-05-12 18:29:03 +08:00
|
|
|
|
{
|
2026-05-12 18:49:12 +08:00
|
|
|
|
var rs = cell.Rowspan;
|
|
|
|
|
|
var cs = cell.Colspan;
|
|
|
|
|
|
|
|
|
|
|
|
// 边框计算(完整 outer/inner/hideBorder 体系)
|
|
|
|
|
|
var sides = ResolveFreeTableCellBorderSides(el, anchors, cell, rowCount, colCount);
|
|
|
|
|
|
var lineKeys = ResolveFreeTableCellLineStyleKeys(el, cell.Row, cell.Col, rs, cs, rowCount, colCount);
|
|
|
|
|
|
var borderCss = BorderSidesToCssFragment(sides, borderWidth, borderColor, lineKeys);
|
|
|
|
|
|
|
2026-05-13 13:04:31 +08:00
|
|
|
|
// 跨列/跨行高度(用于自适应字号和内边距)
|
2026-05-12 18:49:12 +08:00
|
|
|
|
var spanW = 0d;
|
|
|
|
|
|
for (var ci = cell.Col; ci < cell.Col + cs && ci < colWidths.Length; ci++)
|
|
|
|
|
|
spanW += colWidths[ci];
|
2026-05-13 13:04:31 +08:00
|
|
|
|
var spanH = 0d;
|
|
|
|
|
|
for (var ri = cell.Row; ri < cell.Row + rs && ri < rowHeights.Length; ri++)
|
|
|
|
|
|
spanH += rowHeights[ri];
|
|
|
|
|
|
|
|
|
|
|
|
// 随行高自适应内边距(行越密 padding 越小,防止把行撑高导致底部被裁切)
|
|
|
|
|
|
const double pxPerMm96 = 96d / 25.4d;
|
|
|
|
|
|
var vPadMm = Math.Max(0.15d, Math.Min(0.8d, spanH * 0.08d));
|
|
|
|
|
|
var hPadMm = Math.Max(0.3d, Math.Min(1.2d, vPadMm * 1.6d));
|
|
|
|
|
|
|
|
|
|
|
|
// 随行高自动收缩字号(与前端 renderFreeTable fitFontSize 逻辑一致)
|
|
|
|
|
|
var innerHmm = Math.Max(0.1d, spanH - vPadMm * 2d);
|
|
|
|
|
|
var innerHpx = innerHmm * pxPerMm96;
|
|
|
|
|
|
var fitFontSize = Math.Max(1d, Math.Min(cell.FontSize, Math.Floor(innerHpx * 0.82d)));
|
2026-05-12 18:49:12 +08:00
|
|
|
|
|
|
|
|
|
|
// 内容渲染(支持所有 contentType)
|
|
|
|
|
|
var innerHtml = RenderFreeTableCellContent(cell, data);
|
|
|
|
|
|
|
|
|
|
|
|
// 排版样式
|
|
|
|
|
|
var nowrap = !cell.AutoWrap;
|
|
|
|
|
|
var ws = nowrap ? "nowrap" : "normal";
|
|
|
|
|
|
var wb = nowrap ? "normal" : "break-all";
|
|
|
|
|
|
var ow = nowrap ? "normal" : "anywhere";
|
2026-05-13 13:04:31 +08:00
|
|
|
|
var lh = nowrap ? $"{innerHmm.ToString("0.###", CultureInfo.InvariantCulture)}mm" : "1.15";
|
2026-05-12 18:49:12 +08:00
|
|
|
|
var widthCss = $"width:{spanW.ToString("0.###", CultureInfo.InvariantCulture)}mm;";
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
var rsAttr = rs > 1 ? $" rowspan=\"{rs}\"" : string.Empty;
|
|
|
|
|
|
var csAttr = cs > 1 ? $" colspan=\"{cs}\"" : string.Empty;
|
2026-05-12 18:49:12 +08:00
|
|
|
|
|
2026-05-13 13:04:31 +08:00
|
|
|
|
sb.Append($"<td{rsAttr}{csAttr} style=\"box-sizing:border-box;{borderCss}{widthCss}padding:{vPadMm.ToString("0.###", CultureInfo.InvariantCulture)}mm {hPadMm.ToString("0.###", CultureInfo.InvariantCulture)}mm;text-align:{cell.Align};vertical-align:{cell.VerticalAlign};font-size:{fitFontSize.ToString("0.###", CultureInfo.InvariantCulture)}px;color:{cell.Color};background:{cell.BackgroundColor};white-space:{ws};word-break:{wb};overflow-wrap:{ow};line-height:{lh};\">");
|
2026-05-12 18:49:12 +08:00
|
|
|
|
sb.Append(innerHtml);
|
2026-05-12 18:29:03 +08:00
|
|
|
|
sb.Append("</td>");
|
|
|
|
|
|
}
|
|
|
|
|
|
sb.Append("</tr>");
|
|
|
|
|
|
}
|
|
|
|
|
|
sb.Append("</tbody></table>");
|
|
|
|
|
|
return sb.ToString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 18:49:12 +08:00
|
|
|
|
// ─── 自由表格:锚点单元格 ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private sealed record FreeTableAnchorCell
|
|
|
|
|
|
{
|
|
|
|
|
|
public int Row { get; init; }
|
|
|
|
|
|
public int Col { get; init; }
|
|
|
|
|
|
public int Rowspan { get; init; } = 1;
|
|
|
|
|
|
public int Colspan { get; init; } = 1;
|
|
|
|
|
|
public string Text { get; init; } = string.Empty;
|
|
|
|
|
|
public string? BindField { get; init; }
|
|
|
|
|
|
public string ContentType { get; init; } = "text";
|
|
|
|
|
|
public string Align { get; init; } = "left";
|
|
|
|
|
|
public string VerticalAlign { get; init; } = "middle";
|
|
|
|
|
|
public double FontSize { get; init; } = 12d;
|
|
|
|
|
|
public string Color { get; init; } = "#111111";
|
|
|
|
|
|
public string BackgroundColor{ get; init; } = "#ffffff";
|
|
|
|
|
|
public bool HideBorderTop { get; init; }
|
|
|
|
|
|
public bool HideBorderRight { get; init; }
|
|
|
|
|
|
public bool HideBorderBottom { get; init; }
|
|
|
|
|
|
public bool HideBorderLeft { get; init; }
|
2026-05-13 13:04:31 +08:00
|
|
|
|
public bool? FillCell { get; init; } // null → true for media content
|
|
|
|
|
|
public double ContentScale { get; init; } = 100d;
|
|
|
|
|
|
public string? ImageFit { get; init; }
|
|
|
|
|
|
public int DecimalPlaces { get; init; } = 2;
|
|
|
|
|
|
public string? AmountType { get; init; }
|
|
|
|
|
|
public bool AutoWrap { get; init; } = true;
|
|
|
|
|
|
// 条码专属
|
|
|
|
|
|
public string? BarcodeFormat { get; init; }
|
|
|
|
|
|
public bool DisplayBarcodeValue { get; init; } = true;
|
|
|
|
|
|
public int BarcodeFontSize { get; init; } = 14;
|
2026-05-12 18:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static FreeTableAnchorCell ParseFreeTableCell(JsonObject c)
|
|
|
|
|
|
{
|
|
|
|
|
|
var ct = (ReadAsString(c["contentType"]) ?? "text").Trim().ToLowerInvariant();
|
|
|
|
|
|
if (ct is not ("text" or "image" or "qrcode" or "barcode" or "number" or "amount"))
|
|
|
|
|
|
ct = "text";
|
|
|
|
|
|
var bf = ReadAsString(c["bindField"])?.Trim();
|
|
|
|
|
|
var imgFit = ReadAsString(c["imageFit"]);
|
|
|
|
|
|
if (imgFit is not ("fill" or "contain" or "cover")) imgFit = null;
|
|
|
|
|
|
var amtType = ReadAsString(c["amountType"]);
|
|
|
|
|
|
if (amtType is not ("CNY" or "USD" or "EUR")) amtType = null;
|
|
|
|
|
|
return new FreeTableAnchorCell
|
|
|
|
|
|
{
|
|
|
|
|
|
Row = Math.Max(0, c["row"]?.GetValue<int>() ?? 0),
|
|
|
|
|
|
Col = Math.Max(0, c["col"]?.GetValue<int>() ?? 0),
|
|
|
|
|
|
Rowspan = Math.Max(1, c["rowspan"]?.GetValue<int>() ?? 1),
|
|
|
|
|
|
Colspan = Math.Max(1, c["colspan"]?.GetValue<int>() ?? 1),
|
|
|
|
|
|
Text = ReadAsString(c["text"]) ?? string.Empty,
|
|
|
|
|
|
BindField = string.IsNullOrWhiteSpace(bf) ? null : bf,
|
|
|
|
|
|
ContentType = ct,
|
|
|
|
|
|
Align = ReadAsString(c["align"], "left") ?? "left",
|
|
|
|
|
|
VerticalAlign = ReadAsString(c["verticalAlign"], "middle") ?? "middle",
|
|
|
|
|
|
FontSize = Math.Max(8d, c["fontSize"]?.GetValue<double>() ?? 12d),
|
|
|
|
|
|
Color = ReadAsString(c["color"], "#111111") ?? "#111111",
|
|
|
|
|
|
BackgroundColor = ReadAsString(c["backgroundColor"], "#ffffff") ?? "#ffffff",
|
|
|
|
|
|
HideBorderTop = ReadBoolDefault(c["hideBorderTop"], false),
|
|
|
|
|
|
HideBorderRight = ReadBoolDefault(c["hideBorderRight"], false),
|
|
|
|
|
|
HideBorderBottom = ReadBoolDefault(c["hideBorderBottom"], false),
|
|
|
|
|
|
HideBorderLeft = ReadBoolDefault(c["hideBorderLeft"], false),
|
|
|
|
|
|
FillCell = c["fillCell"] != null ? (bool?)ReadBoolDefault(c["fillCell"], true) : null,
|
|
|
|
|
|
ContentScale = Math.Max(10d, Math.Min(100d, c["contentScale"]?.GetValue<double>() ?? 100d)),
|
|
|
|
|
|
ImageFit = imgFit,
|
|
|
|
|
|
DecimalPlaces = Math.Clamp(c["decimalPlaces"]?.GetValue<int>() ?? 2, 0, 6),
|
|
|
|
|
|
AmountType = amtType,
|
2026-05-13 13:04:31 +08:00
|
|
|
|
AutoWrap = !string.Equals(ReadAsString(c["autoWrap"]), "false", StringComparison.OrdinalIgnoreCase),
|
|
|
|
|
|
BarcodeFormat = ReadAsString(c["barcodeFormat"]),
|
|
|
|
|
|
DisplayBarcodeValue = !string.Equals(ReadAsString(c["displayValue"]), "false", StringComparison.OrdinalIgnoreCase),
|
|
|
|
|
|
BarcodeFontSize = Math.Max(8, (int)Math.Round(c["barcodeFontSize"]?.GetValue<double>() ?? 14d)),
|
2026-05-12 18:49:12 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static FreeTableAnchorCell DefaultFreeTableCell(int row, int col) => new()
|
|
|
|
|
|
{
|
|
|
|
|
|
Row = row, Col = col
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 移植自前端 normalizeFreeTableAnchors:去除重叠、填补空格,返回排序后的锚点列表。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static List<FreeTableAnchorCell> NormalizeFreeTableAnchors(int rowCount, int colCount, JsonArray cells)
|
|
|
|
|
|
{
|
|
|
|
|
|
var occ = new bool[rowCount, colCount];
|
|
|
|
|
|
var anchors = new List<FreeTableAnchorCell>();
|
|
|
|
|
|
|
|
|
|
|
|
// 解析 + 排序
|
|
|
|
|
|
var parsed = cells.OfType<JsonObject>()
|
|
|
|
|
|
.Select(ParseFreeTableCell)
|
|
|
|
|
|
.OrderBy(c => c.Row).ThenBy(c => c.Col)
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var c in parsed)
|
|
|
|
|
|
{
|
|
|
|
|
|
var rs = Math.Min(c.Rowspan, rowCount - c.Row);
|
|
|
|
|
|
var cs = Math.Min(c.Colspan, colCount - c.Col);
|
|
|
|
|
|
if (rs < 1 || cs < 1 || c.Row >= rowCount || c.Col >= colCount) continue;
|
|
|
|
|
|
|
|
|
|
|
|
var overlap = false;
|
|
|
|
|
|
for (var dr = 0; dr < rs && !overlap; dr++)
|
|
|
|
|
|
for (var dc = 0; dc < cs && !overlap; dc++)
|
|
|
|
|
|
if (occ[c.Row + dr, c.Col + dc]) overlap = true;
|
|
|
|
|
|
if (overlap) continue;
|
|
|
|
|
|
|
|
|
|
|
|
for (var dr = 0; dr < rs; dr++)
|
|
|
|
|
|
for (var dc = 0; dc < cs; dc++)
|
|
|
|
|
|
occ[c.Row + dr, c.Col + dc] = true;
|
|
|
|
|
|
|
|
|
|
|
|
anchors.Add(c with { Rowspan = rs, Colspan = cs });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 填补未覆盖格
|
|
|
|
|
|
for (var r = 0; r < rowCount; r++)
|
|
|
|
|
|
for (var c = 0; c < colCount; c++)
|
|
|
|
|
|
if (!occ[r, c])
|
|
|
|
|
|
{
|
|
|
|
|
|
occ[r, c] = true;
|
|
|
|
|
|
anchors.Add(DefaultFreeTableCell(r, c));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
anchors.Sort((a, b) => a.Row != b.Row ? a.Row.CompareTo(b.Row) : a.Col.CompareTo(b.Col));
|
|
|
|
|
|
return anchors;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 找到"拥有" (r, c) 位置的锚点格(考虑合并跨度)。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static FreeTableAnchorCell GetFreeTableOwnerAt(List<FreeTableAnchorCell> anchors, int r, int c)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var cell in anchors)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (r >= cell.Row && r < cell.Row + Math.Max(1, cell.Rowspan) &&
|
|
|
|
|
|
c >= cell.Col && c < cell.Col + Math.Max(1, cell.Colspan))
|
|
|
|
|
|
return cell;
|
|
|
|
|
|
}
|
|
|
|
|
|
return DefaultFreeTableCell(r, c);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 自由表格:列宽/行高轨道 ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private static double[] ResolveFreeTableColWidths(JsonNode el, int colCount, double wMm)
|
|
|
|
|
|
{
|
|
|
|
|
|
var raw = el["colWidths"]?.AsArray();
|
|
|
|
|
|
if (raw == null || raw.Count != colCount)
|
|
|
|
|
|
return EvenSplitTracks(wMm, colCount);
|
|
|
|
|
|
var tracks = raw.Select(n => Math.Max(4d, n?.GetValue<double>() ?? 4d)).ToArray();
|
|
|
|
|
|
return ClampTrackSumToTotal(tracks, wMm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static double[] ResolveFreeTableRowHeights(JsonNode el, int rowCount, double hMm)
|
|
|
|
|
|
{
|
|
|
|
|
|
var raw = el["rowHeights"]?.AsArray();
|
|
|
|
|
|
if (raw == null || raw.Count != rowCount)
|
|
|
|
|
|
return EvenSplitTracks(hMm, rowCount);
|
|
|
|
|
|
var tracks = raw.Select(n => Math.Max(4d, n?.GetValue<double>() ?? 4d)).ToArray();
|
|
|
|
|
|
return ClampTrackSumToTotal(tracks, hMm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static double[] EvenSplitTracks(double total, int count)
|
|
|
|
|
|
{
|
|
|
|
|
|
var n = Math.Max(1, count);
|
|
|
|
|
|
var t = Math.Max(0.01, total);
|
|
|
|
|
|
var baseVal = Ft2(t / n);
|
|
|
|
|
|
var arr = Enumerable.Repeat(baseVal, n).ToArray();
|
|
|
|
|
|
var sum = arr.Sum();
|
|
|
|
|
|
arr[n - 1] = Ft2(arr[n - 1] + (t - sum));
|
|
|
|
|
|
return arr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static double[] ClampTrackSumToTotal(double[] tracks, double total, double minMm = 4d)
|
|
|
|
|
|
{
|
|
|
|
|
|
var n = tracks.Length;
|
|
|
|
|
|
if (n == 0) return Array.Empty<double>();
|
|
|
|
|
|
var t = Math.Max(0.01, total);
|
|
|
|
|
|
var next = tracks.Select(x => Ft2(Math.Max(minMm, x))).ToArray();
|
|
|
|
|
|
var sum = next.Sum();
|
|
|
|
|
|
if (Math.Abs(sum - t) < 0.02) return next;
|
|
|
|
|
|
var scale = t / sum;
|
|
|
|
|
|
next = next.Select(x => Ft2(x * scale)).ToArray();
|
|
|
|
|
|
sum = next.Sum();
|
|
|
|
|
|
next[n - 1] = Ft2(next[n - 1] + (t - sum));
|
|
|
|
|
|
return next;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// round to 2 decimal places(与前端 round2 一致)
|
|
|
|
|
|
private static double Ft2(double x) => Math.Round(x * 100d) / 100d;
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 自由表格:边框体系 ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 移植自前端 resolveFreeTableCellBorderSides:计算单元格四边是否画线。
|
|
|
|
|
|
/// 同时考虑 outerBorder / innerBorder 开关、单元格 hideBorder* 及相邻格共享边。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static (bool top, bool right, bool bottom, bool left) ResolveFreeTableCellBorderSides(
|
|
|
|
|
|
JsonNode el, List<FreeTableAnchorCell> anchors,
|
|
|
|
|
|
FreeTableAnchorCell cell, int rowCount, int colCount)
|
|
|
|
|
|
{
|
|
|
|
|
|
var ob = el["outerBorder"];
|
|
|
|
|
|
var ib = el["innerBorder"];
|
|
|
|
|
|
|
|
|
|
|
|
// outer 四边缺省均为 true
|
|
|
|
|
|
var outerTop = ReadBoolDefault(ob?["top"], true);
|
|
|
|
|
|
var outerRight = ReadBoolDefault(ob?["right"], true);
|
|
|
|
|
|
var outerBottom = ReadBoolDefault(ob?["bottom"], true);
|
|
|
|
|
|
var outerLeft = ReadBoolDefault(ob?["left"], true);
|
|
|
|
|
|
// inner 缺省横纵均显示
|
|
|
|
|
|
var innerH = ReadBoolDefault(ib?["horizontal"], true);
|
|
|
|
|
|
var innerV = ReadBoolDefault(ib?["vertical"], true);
|
|
|
|
|
|
|
|
|
|
|
|
int r = cell.Row, c = cell.Col, rs = cell.Rowspan, cs = cell.Colspan;
|
|
|
|
|
|
int rEnd = r + rs - 1, cEnd = c + cs - 1;
|
|
|
|
|
|
|
|
|
|
|
|
var top = r == 0 ? outerTop : innerH;
|
|
|
|
|
|
var right = cEnd == colCount - 1 ? outerRight : innerV;
|
|
|
|
|
|
var bottom = rEnd == rowCount - 1 ? outerBottom : innerH;
|
|
|
|
|
|
var left = c == 0 ? outerLeft : innerV;
|
|
|
|
|
|
|
|
|
|
|
|
// 单元格级别隐藏
|
|
|
|
|
|
if (cell.HideBorderTop) top = false;
|
|
|
|
|
|
if (cell.HideBorderRight) right = false;
|
|
|
|
|
|
if (cell.HideBorderBottom) bottom = false;
|
|
|
|
|
|
if (cell.HideBorderLeft) left = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 共享边:相邻格若声明隐藏其接触边,则本格也不画
|
|
|
|
|
|
if (top && r > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
for (var cc = c; cc <= cEnd; cc++)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (GetFreeTableOwnerAt(anchors, r - 1, cc).HideBorderBottom) { top = false; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (bottom && rEnd < rowCount - 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
var belowRow = r + rs;
|
|
|
|
|
|
for (var cc = c; cc <= cEnd; cc++)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (GetFreeTableOwnerAt(anchors, belowRow, cc).HideBorderTop) { bottom = false; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (left && c > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
for (var rr = r; rr <= rEnd; rr++)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (GetFreeTableOwnerAt(anchors, rr, c - 1).HideBorderRight) { left = false; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (right && cEnd < colCount - 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
var rightCol = c + cs;
|
|
|
|
|
|
for (var rr = r; rr <= rEnd; rr++)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (GetFreeTableOwnerAt(anchors, rr, rightCol).HideBorderLeft) { right = false; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (top, right, bottom, left);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 移植自前端 resolveFreeTableCellLineStyleKeys:按位置判断各边线型(外框/横/竖)。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static (string top, string right, string bottom, string left) ResolveFreeTableCellLineStyleKeys(
|
|
|
|
|
|
JsonNode el, int anchorRow, int anchorCol, int rs, int cs, int rowCount, int colCount)
|
|
|
|
|
|
{
|
|
|
|
|
|
int rEnd = anchorRow + rs - 1, cEnd = anchorCol + cs - 1;
|
|
|
|
|
|
var outerStyle = ReadAsString(el["outerBorderLineStyle"], "solid") ?? "solid";
|
|
|
|
|
|
var hStyle = ReadAsString(el["innerBorderHorizontalLineStyle"], "solid") ?? "solid";
|
|
|
|
|
|
var vStyle = ReadAsString(el["innerBorderVerticalLineStyle"], "solid") ?? "solid";
|
|
|
|
|
|
return (
|
|
|
|
|
|
top: anchorRow == 0 ? outerStyle : hStyle,
|
|
|
|
|
|
right: cEnd == colCount - 1 ? outerStyle : vStyle,
|
|
|
|
|
|
bottom: rEnd == rowCount - 1 ? outerStyle : hStyle,
|
|
|
|
|
|
left: anchorCol == 0 ? outerStyle : vStyle
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string BorderSidesToCssFragment(
|
|
|
|
|
|
(bool top, bool right, bool bottom, bool left) sides,
|
|
|
|
|
|
int bw, string color,
|
|
|
|
|
|
(string top, string right, string bottom, string left) lineStyles)
|
|
|
|
|
|
{
|
|
|
|
|
|
var t = sides.top ? $"{bw}px {LineStyleToCss(lineStyles.top)} {color}" : "none";
|
|
|
|
|
|
var r = sides.right ? $"{bw}px {LineStyleToCss(lineStyles.right)} {color}" : "none";
|
|
|
|
|
|
var b = sides.bottom ? $"{bw}px {LineStyleToCss(lineStyles.bottom)} {color}" : "none";
|
|
|
|
|
|
var l = sides.left ? $"{bw}px {LineStyleToCss(lineStyles.left)} {color}" : "none";
|
|
|
|
|
|
return $"border-top:{t};border-right:{r};border-bottom:{b};border-left:{l};";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string LineStyleToCss(string key) => key switch
|
|
|
|
|
|
{
|
|
|
|
|
|
"dashed" => "dashed",
|
|
|
|
|
|
"dotted" => "dotted",
|
|
|
|
|
|
"dash_dot" => "dashed",
|
|
|
|
|
|
"double_dash_dot" => "double",
|
|
|
|
|
|
_ => "solid"
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 自由表格:单元格内容渲染(支持所有 contentType)────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private static string RenderFreeTableCellContent(FreeTableAnchorCell cell, JsonObject data)
|
|
|
|
|
|
{
|
|
|
|
|
|
var ct = (cell.ContentType ?? "text").ToLowerInvariant();
|
|
|
|
|
|
|
|
|
|
|
|
// 解析原始值
|
|
|
|
|
|
string rawValue;
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(cell.BindField))
|
2026-05-14 10:43:51 +08:00
|
|
|
|
rawValue = ResolveField(data, cell.BindField!)?.ToString() ?? string.Empty;
|
2026-05-12 18:49:12 +08:00
|
|
|
|
else
|
|
|
|
|
|
rawValue = cell.Text;
|
|
|
|
|
|
|
|
|
|
|
|
// 数值格式化
|
|
|
|
|
|
var displayValue = ct is "number" or "amount"
|
|
|
|
|
|
? FormatFreeTableCellValue(rawValue, cell)
|
|
|
|
|
|
: rawValue;
|
|
|
|
|
|
|
|
|
|
|
|
// image/qrcode/barcode 使用原始值(URL、扫描数据);文本类使用 displayValue
|
|
|
|
|
|
var innerArg = ct is "image" or "qrcode" or "barcode" ? rawValue : displayValue;
|
|
|
|
|
|
|
|
|
|
|
|
var fillCell = cell.FillCell != false; // null → true
|
|
|
|
|
|
var scale = Math.Max(10d, Math.Min(100d, cell.ContentScale > 0 ? cell.ContentScale : 100d));
|
|
|
|
|
|
|
|
|
|
|
|
if (ct == "image")
|
|
|
|
|
|
{
|
|
|
|
|
|
var fit = cell.ImageFit ?? "contain";
|
|
|
|
|
|
var ws = fillCell ? "100%" : $"{scale.ToString("0", CultureInfo.InvariantCulture)}%";
|
|
|
|
|
|
return $"<img src=\"{EscapeHtml(innerArg)}\" style=\"display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:{fit};width:{ws};height:{ws};\" />";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (ct == "qrcode")
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(innerArg)) return string.Empty;
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
using var gen = new QRCodeGenerator();
|
|
|
|
|
|
var qrData = gen.CreateQrCode(innerArg, QRCodeGenerator.ECCLevel.M);
|
|
|
|
|
|
using var code = new PngByteQRCode(qrData);
|
|
|
|
|
|
var b64 = Convert.ToBase64String(code.GetGraphic(6));
|
|
|
|
|
|
var ws = fillCell ? "100%" : $"{scale.ToString("0", CultureInfo.InvariantCulture)}%";
|
|
|
|
|
|
return $"<img src=\"data:image/png;base64,{b64}\" style=\"display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:contain;width:{ws};height:{ws};\" />";
|
|
|
|
|
|
}
|
|
|
|
|
|
catch { return EscapeHtml(innerArg); }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (ct == "barcode")
|
|
|
|
|
|
{
|
2026-05-13 13:04:31 +08:00
|
|
|
|
var ws = fillCell ? "100%" : $"{scale.ToString("0", CultureInfo.InvariantCulture)}%";
|
|
|
|
|
|
var hs = fillCell ? "100%" : $"{Math.Max(20d, scale * 0.6d).ToString("0", CultureInfo.InvariantCulture)}%";
|
|
|
|
|
|
var svg = BuildBarcodeCellSvg(innerArg, cell.BarcodeFormat, cell.DisplayBarcodeValue, null, cell.BarcodeFontSize);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:{ws};height:{hs};margin:0 auto;overflow:hidden;\">{svg}</div>";
|
2026-05-12 18:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
return EscapeHtml(displayValue);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string FormatFreeTableCellValue(string raw, FreeTableAnchorCell cell)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) &&
|
|
|
|
|
|
!double.TryParse(raw, NumberStyles.Any, CultureInfo.CurrentCulture, out d))
|
|
|
|
|
|
return raw;
|
|
|
|
|
|
var decimals = Math.Clamp(cell.DecimalPlaces, 0, 6);
|
|
|
|
|
|
var formatted = d.ToString("N" + decimals, CultureInfo.CurrentCulture);
|
|
|
|
|
|
if (string.Equals(cell.ContentType, "amount", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
|
{
|
|
|
|
|
|
var symbol = cell.AmountType switch { "USD" => "$", "EUR" => "EUR ", _ => "¥" };
|
|
|
|
|
|
return symbol + formatted;
|
|
|
|
|
|
}
|
|
|
|
|
|
return formatted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static bool ReadBoolDefault(JsonNode? node, bool defaultValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (node == null) return defaultValue;
|
|
|
|
|
|
if (node is JsonValue v)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (v.TryGetValue<bool>(out var b)) return b;
|
|
|
|
|
|
var s = v.ToString();
|
|
|
|
|
|
if (string.Equals(s, "false", StringComparison.OrdinalIgnoreCase)) return false;
|
|
|
|
|
|
if (string.Equals(s, "true", StringComparison.OrdinalIgnoreCase)) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return defaultValue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
// ─── 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);
|
2026-05-12 18:49:12 +08:00
|
|
|
|
var headerBg = ReadAsString(el["headerBgColor"], "#f5f5f5");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
var headerText = ReadAsString(el["headerTextColor"], "#111111");
|
|
|
|
|
|
var columns = el["columns"]?.AsArray() ?? [];
|
|
|
|
|
|
if (columns.Count == 0) return string.Empty;
|
|
|
|
|
|
|
2026-05-12 18:49:12 +08:00
|
|
|
|
// 元素级字体基准
|
|
|
|
|
|
var bodyFontSizeBase = el["bodyFontSize"]?.GetValue<double>() ?? 12d;
|
|
|
|
|
|
var headerFontSizeBase = el["headerFontSize"]?.GetValue<double>() ?? 12d;
|
|
|
|
|
|
|
|
|
|
|
|
var columnsList = columns.OfType<JsonObject>().ToList();
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-05-12 18:49:12 +08:00
|
|
|
|
var detailH = el["h"]?.GetValue<double>() ?? 8d;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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++)
|
|
|
|
|
|
{
|
2026-05-12 18:49:12 +08:00
|
|
|
|
// 续页起点
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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];
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-05-12 18:49:12 +08:00
|
|
|
|
var headerRows = BuildTableHeaderRows(el, columnsList);
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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;\">");
|
2026-05-12 18:49:12 +08:00
|
|
|
|
foreach (var hcell in hrow)
|
2026-05-12 18:29:03 +08:00
|
|
|
|
{
|
2026-05-12 18:49:12 +08:00
|
|
|
|
var colNode = (hcell.Col >= 0 && hcell.Col < columnsList.Count)
|
|
|
|
|
|
? (JsonNode?)columnsList[hcell.Col] : null;
|
|
|
|
|
|
var hFontFamily = ReadAsString(colNode?["fontFamily"]) ?? "inherit";
|
|
|
|
|
|
var hFontColor = ReadAsString(colNode?["fontColor"]) ?? headerText;
|
|
|
|
|
|
var hAutoWrap = !string.Equals(ReadAsString(colNode?["autoWrap"]), "false", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
// 合并跨列宽度之和,用于字号自适应
|
|
|
|
|
|
var spanWidthMm = 0d;
|
|
|
|
|
|
for (var ci = hcell.Col; ci < hcell.Col + hcell.ColSpan && ci < columnsList.Count; ci++)
|
|
|
|
|
|
spanWidthMm += columnsList[ci]["width"]?.GetValue<double>() ?? 30d;
|
|
|
|
|
|
var cellHeightMm = headerRowHeightMm * hcell.RowSpan;
|
|
|
|
|
|
var hFontSize = ResolvePrintAutoFontSize(colNode, hcell.Title,
|
|
|
|
|
|
Math.Max(1d, spanWidthMm), cellHeightMm, headerFontSizeBase);
|
|
|
|
|
|
var hWs = hAutoWrap ? "normal" : "nowrap";
|
|
|
|
|
|
var hWb = hAutoWrap ? "break-all" : "normal";
|
|
|
|
|
|
var hOw = hAutoWrap ? "anywhere" : "normal";
|
|
|
|
|
|
var hLh = hAutoWrap ? "1.3" : $"{cellHeightMm.ToString("0.###", CultureInfo.InvariantCulture)}mm";
|
|
|
|
|
|
|
|
|
|
|
|
var rs = hcell.RowSpan > 1 ? $" rowspan=\"{hcell.RowSpan}\"" : string.Empty;
|
|
|
|
|
|
var cs = hcell.ColSpan > 1 ? $" colspan=\"{hcell.ColSpan}\"" : string.Empty;
|
|
|
|
|
|
sb.Append($"<th{rs}{cs} style=\"border:1px solid #222;padding:2mm;text-align:{hcell.Align};font-weight:600;background:{headerBg};color:{hFontColor};font-family:{hFontFamily};font-size:{hFontSize}px;white-space:{hWs};word-break:{hWb};overflow-wrap:{hOw};line-height:{hLh};width:{hcell.WidthPercent.ToString("0.###", CultureInfo.InvariantCulture)}%;\">{EscapeHtml(hcell.Title)}</th>");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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)
|
2026-05-12 18:49:12 +08:00
|
|
|
|
continue;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-13 13:04:31 +08:00
|
|
|
|
var html = ResolveTableCellInnerHtml(contentType, text, col);
|
2026-05-12 18:49:12 +08:00
|
|
|
|
|
|
|
|
|
|
// 字体:fontFamily / fontColor / fontSize(含 autoFitFont 自适应)
|
|
|
|
|
|
var fontFamily = ReadAsString(col?["fontFamily"], "inherit") ?? "inherit";
|
|
|
|
|
|
var fontColor = ReadAsString(col?["fontColor"], "#111111") ?? "#111111";
|
|
|
|
|
|
var colWidthMm = Math.Max(1d, col?["width"]?.GetValue<double>() ?? 30d);
|
|
|
|
|
|
var useCustomFs = string.Equals(ReadAsString(col?["useCustomFontSize"]), "true", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
var bodyBaseSize = useCustomFs
|
|
|
|
|
|
? (col?["fontSize"]?.GetValue<double>() ?? bodyFontSizeBase)
|
|
|
|
|
|
: bodyFontSizeBase;
|
|
|
|
|
|
var fontSize = ResolvePrintAutoFontSize(col, text, colWidthMm, rowHeightMm, bodyBaseSize);
|
|
|
|
|
|
|
|
|
|
|
|
var ws = autoWrap ? "normal" : "nowrap";
|
|
|
|
|
|
var wb = autoWrap ? "break-all" : "normal";
|
|
|
|
|
|
var ow = autoWrap ? "anywhere" : "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};font-family:{fontFamily};font-size:{fontSize}px;color:{fontColor};white-space:{ws};word-break:{wb};overflow-wrap:{ow};line-height:{lhv};\">{html}</td>");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 行合并 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-05-12 18:55:12 +08:00
|
|
|
|
// 用 GroupBy 兜底,避免重复 key 抛异常;按 mergeColumnKeys 给定顺序输出对应 bindField/field
|
|
|
|
|
|
var byKey = colList
|
|
|
|
|
|
.GroupBy(c => ReadAsString(c["key"]) ?? string.Empty)
|
|
|
|
|
|
.ToDictionary(
|
|
|
|
|
|
g => g.Key,
|
|
|
|
|
|
g => ReadAsString(g.First()["bindField"]) ?? ReadAsString(g.First()["field"]) ?? string.Empty);
|
2026-05-12 18:29:03 +08:00
|
|
|
|
return mergeColumnKeys
|
|
|
|
|
|
.Select(k => byKey.TryGetValue(k, out var f) ? f : string.Empty)
|
|
|
|
|
|
.Where(f => !string.IsNullOrEmpty(f))
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
}
|
2026-05-12 18:55:12 +08:00
|
|
|
|
// 兼容老的列级 mergeByValue 开关
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
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>();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 13:04:31 +08:00
|
|
|
|
private static string ResolveTableCellInnerHtml(string? contentType, string value, JsonNode? col = null)
|
2026-05-12 18:29:03 +08:00
|
|
|
|
{
|
|
|
|
|
|
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")
|
2026-05-13 12:35:02 +08:00
|
|
|
|
{
|
2026-05-13 14:06:13 +08:00
|
|
|
|
var fmt = ReadAsString(col?["barcodeFormat"]);
|
|
|
|
|
|
var dv = !string.Equals(ReadAsString(col?["displayValue"]), "false", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
var bFontSize = Math.Max(8, (int)Math.Round(col?["barcodeFontSize"]?.GetValue<double>() ?? 14d));
|
2026-05-13 13:04:31 +08:00
|
|
|
|
var svg = BuildBarcodeCellSvg(value, fmt, dv, null, bFontSize);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:100%;height:100%;overflow:hidden;\">{svg}</div>";
|
|
|
|
|
|
}
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 18:49:12 +08:00
|
|
|
|
// ─── autoFitFont 字号自适应(移植自前端 resolvePrintAutoFontSize)────
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 当列开启 autoFitFont 时,根据列宽/行高/文本长度动态缩减字号,最小 8px。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static double ResolvePrintAutoFontSize(
|
|
|
|
|
|
JsonNode? col, string text, double colWidthMm, double rowHeightMm, double baseSize)
|
|
|
|
|
|
{
|
|
|
|
|
|
var @base = Math.Max(8d, baseSize);
|
|
|
|
|
|
if (!string.Equals(ReadAsString(col?["autoFitFont"]), "true", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
|
return @base;
|
|
|
|
|
|
const double pxPerMm = 3.7795275591d;
|
|
|
|
|
|
var widthPx = Math.Max(1d, colWidthMm * pxPerMm);
|
|
|
|
|
|
var heightPx = Math.Max(1d, rowHeightMm * pxPerMm);
|
|
|
|
|
|
var textLen = Math.Max(1, text.Length);
|
|
|
|
|
|
var byWidth = widthPx / Math.Max(1d, textLen * 0.62d);
|
|
|
|
|
|
var noWrap = string.Equals(ReadAsString(col?["autoWrap"]), "false", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
var byHeight = noWrap ? heightPx * 0.55d : heightPx * 0.36d;
|
|
|
|
|
|
return Math.Max(8d, Math.Round(Math.Min(@base, Math.Min(byWidth, byHeight))));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
// ─── 多级表头构建 ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-05-12 18:49:12 +08:00
|
|
|
|
JsonArray a => a,
|
|
|
|
|
|
JsonObject o => o,
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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,
|
2026-05-12 18:49:12 +08:00
|
|
|
|
_ => node?.ToString()
|
2026-05-12 18:29:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) + ";";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|