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

1980 lines
102 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Globalization;
using QRCoder;
using ZXing;
using ZXing.Rendering;
namespace YY.Admin.Services.Service.Print;
/// <summary>
/// 将后端「原生打印模板」JSON 渲染为 HTML再通过调用方传入的 WebView2 实例导出 PDF base64。
/// 支持元素类型text/title/subtitle/date/pageNo/qrcode/barcode/image/freeTable/table/detailTable。
/// </summary>
public static class NativePrintRenderService
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
// ─── 入口 ──────────────────────────────────────────────────────────────
/// <summary>
/// 将模板 JSON + 数据对象渲染为完整的可打印 HTML 页面(自包含,无外部依赖)。
/// 屏幕样式与后端前端预览保持一致:深灰底(#525659+ 白纸居中 + 页间分割线。
/// </summary>
public static string RenderToHtml(string templateJson, JsonObject data, bool enableScreenAutoFit = true)
{
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");
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");
}
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;
// ─ 元素级边框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;
// ─ reportHeader/reportFooter 位置覆盖 ─
var isReportHeader = type == "reportHeader";
var isReportFooter = type == "reportFooter";
double renderX = (isReportHeader || isReportFooter) ? 0 : x;
double renderW = (isReportHeader || isReportFooter) ? pageWidthMm : w;
double renderY = isReportHeader ? 0
: (isReportFooter && string.Equals(ReadAsString(el["printAtPageBottom"]), "true", StringComparison.OrdinalIgnoreCase))
? Math.Max(0, pageHeightMm - h)
: y;
// ─ reportHeader band 判断 ─
var isInHeaderBand = IsElementInHeaderBand(type, reportHeaderId,
ReadAsString(el["bandId"]) ?? string.Empty,
ReadAsString(el["region"]) ?? string.Empty,
headerBandHeight, y, h);
if (isInHeaderBand && !headerVisible) return string.Empty;
// 需要在每页重复:元素在报表头区域 且 报表头开启了 printRepeated
var bandRepeat = isInHeaderBand && repeatHeaderByPage && totalPages > 1;
var posStyle = $"left:{renderX.ToString("0.###", CultureInfo.InvariantCulture)}mm;" +
$"top:{renderY.ToString("0.###", CultureInfo.InvariantCulture)}mm;" +
$"width:{renderW.ToString("0.###", CultureInfo.InvariantCulture)}mm;" +
$"height:{h.ToString("0.###", CultureInfo.InvariantCulture)}mm;" +
$"z-index:{zIndex};{bgCss}{rotCss}{elemBorderCss}";
return type switch
{
"title" or "subtitle" or "text" or "date" or "pageNo" or "reportHeader" or "reportFooter"
=> RenderText(el, data, posStyle, fs, fw, color, align, lh, renderY, pageHeightMm, totalPages, bandRepeat),
"qrcode" => RenderQrCode(el, data, posStyle, renderW, h, bandRepeat, renderY, pageHeightMm, totalPages),
"barcode" => RenderBarcode(el, data, posStyle, renderW, h, bandRepeat, renderY, pageHeightMm, totalPages),
"image" => RenderImage(el, data, posStyle, bandRepeat, renderY, pageHeightMm, totalPages),
"freeTable" => RenderFreeTable(el, data, posStyle, renderW, h, renderY,
totalPages, pageHeightMm, bandRepeat),
"table" or "detailTable" => RenderDetailTable(el, data, posStyle, y, pageHeightMm,
marginTopMm, marginBottomMm, allElements, repeatHeaderByPage, headerBandHeight),
_ => string.Empty
};
}
/// <summary>
/// 判断元素是否属于报表头区域(与前端 isElementInHeaderRegion 一致)。
/// </summary>
private static bool IsElementInHeaderBand(
string? type, string reportHeaderId,
string bandId, string region,
double headerBandHeight, double y, double h)
{
if (type == "reportHeader") return true;
if (type == "reportFooter") return false;
if (!string.IsNullOrEmpty(reportHeaderId) && bandId == reportHeaderId) return true;
if (region == "header") return true;
if (region is "body" or "footer") return false;
if (headerBandHeight <= 0) return false;
// 位置回退判断:元素顶部在 band 内且底部不超出 band 0.2mm
return y < headerBandHeight && (y + h) <= headerBandHeight + 0.2;
}
// ─── Text ──────────────────────────────────────────────────────────────
private static string RenderText(JsonNode el, JsonObject data, string posStyle,
double fs, string fw, string color, string align, double lh,
double designY, double pageHeightMm, int totalPages, bool bandRepeat = false)
{
var type = ReadAsString(el["type"], "text");
// 设计器默认 bindField 为空字符串 "",须视为「未绑定」,否则误走数据分支导致标题等只剩空串
var bindFieldRaw = ReadAsString(el["bindField"]);
var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim();
string text;
if (type == "date")
{
var val = bindField != null ? ResolveField(data, bindField)?.ToString() : null;
var fmt = ReadAsString(el["format"], "yyyy-MM-dd");
text = val != null && DateTime.TryParse(val, out var dt)
? dt.ToString(ConvertDateFormat(fmt))
: DateTime.Now.ToString(ConvertDateFormat(fmt));
}
else if (bindField != null)
{
// 已绑定数据字段:缺键或解析不到时留空,不回退到画布上的设计占位 text否则会误显示「采购订单」等
text = ResolveField(data, bindField)?.ToString() ?? string.Empty;
}
else
{
text = ReadAsString(el["text"], string.Empty);
}
var textCss = $"font-size:{fs}px;font-weight:{fw};color:{color};text-align:{align};line-height:{lh};white-space:pre-wrap;overflow:hidden;word-break:break-all;";
// 元素自身 printRepeated 或 pageNo 类型,或报表头区域强制重复
var repeat = bandRepeat || type == "pageNo"
|| string.Equals(ReadAsString(el["printRepeated"], "false"), "true", StringComparison.OrdinalIgnoreCase);
if (!repeat || totalPages <= 1)
{
text = ReplaceTemplatePlaceholders(text, data, 1, totalPages);
return $"<div class=\"el\" style=\"{posStyle}{textCss}\">{EscapeHtml(text)}</div>\n";
}
var baseStyle = RemoveCssProperty(posStyle, "top");
var sb = new StringBuilder();
for (var p = 1; p <= totalPages; p++)
{
var top = designY + (p - 1) * pageHeightMm;
var textPerPage = ReplaceTemplatePlaceholders(text, data, p, totalPages);
sb.Append($"<div class=\"el\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseStyle}{textCss}\">{EscapeHtml(textPerPage)}</div>\n");
}
return sb.ToString();
}
// ─── QR Code ────────────────────────────────────────────────────────────
private static string RenderQrCode(JsonNode el, JsonObject data, string posStyle, double w, double h,
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
{
var bindFieldRaw = ReadAsString(el["bindField"]);
var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim();
var value = ReadAsString(el["value"], string.Empty);
if (bindField != null)
value = ResolveField(data, bindField)?.ToString() ?? string.Empty;
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 ────────────────────────────────────────────────────────────
/// <summary>
/// 使用 ZXing.Net 的 SVG 渲染器生成真实可扫码的 1D 条码,与 web 端 jsbarcode 输出的
/// SVG 行为一致preserveAspectRatio="xMidYMid meet",外层 CSS 100% 保比例铺满)。
/// 失败时回退到原占位 SVG避免渲染流程中断。
/// </summary>
private static string RenderBarcode(JsonNode el, JsonObject data, string posStyle, double w, double h,
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
{
var bindFieldRaw = ReadAsString(el["bindField"]);
var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim();
var value = ReadAsString(el["value"], string.Empty);
if (bindField != null)
value = ResolveField(data, bindField)?.ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
// 从元素配置取格式/显示文字开关;元素未设时按 Code128 + 显示文字默认
var format = ParseBarcodeFormat(ReadAsString(el["format"]));
var displayValue = !string.Equals(ReadAsString(el["displayValue"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var textAlign = ReadAsString(el["textAlign"], "center")!;
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));
// 与后端对齐:优先读元素级 fillCell兼容旧模板的 style.fillCell。
// 均未配置时默认 false保比例居中
var fillCellNode = el["fillCell"] ?? el["style"]?["fillCell"];
var fillCell = string.Equals(ReadAsString(fillCellNode, "false"), "true", StringComparison.OrdinalIgnoreCase);
var inner = BuildBarcodeSvgInner(value, format, displayValue, textAlign, lineWidthPx, barHeightPx, barFontSize, fillCell);
var wrapStyle = "display:flex;align-items:center;justify-content:center;overflow:hidden;";
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();
}
/// <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,
// PharmacodeZXing 不支持,回退 CODE_128
"PHARMACODE" => BarcodeFormat.CODE_128,
// Codabar
"CODABAR" => BarcodeFormat.CODABAR,
_ => BarcodeFormat.CODE_128,
};
}
/// <summary>
/// 把 ZXing 输出的 SVG 转换为"由外层 CSS 控制尺寸 + 可配置铺满策略"的形式:
/// 1) <b>强制清除</b>根 svg 节点上原有的 width / height / viewBox / preserveAspectRatio 属性
/// (不再依赖 ZXing 自身输出格式,无论它用单引号、双引号、无引号都覆写);
/// 2) 用调用方传入的 viewBoxWidth × viewBoxHeight 作为 viewBox保证容器内
/// 条码比例与 BuildBarcodeSvgInner 计算的画布比例一致;
/// 3) preserveAspectRatiofillCell=true → "none"(按容器拉伸铺满,矢量缩放无损);
/// fillCell=false → "xMidYMid meet"(保比例居中,与 web jsbarcode 默认一致);
/// 4) 重新挂上 width="100%" height="100%",确保外层 div 100% 铺满。
/// 这里不再"尝试从原 SVG 派生 viewBox",否则 regex 不匹配 ZXing 实际输出(单引号 /
/// 无属性 / 顺序差异viewBox 会被漏掉,导致 SVG 被 CSS 双向拉伸,条码视觉变粗、变高。
/// </summary>
private static string? NormalizeBarcodeSvg(string svg, bool fillCell = false,
double viewBoxWidth = 0, double viewBoxHeight = 0)
{
if (string.IsNullOrWhiteSpace(svg)) return null;
var targetAspect = fillCell ? "none" : "xMidYMid meet";
return Regex.Replace(svg, @"<svg\b([^>]*)>", m =>
{
var attrs = m.Groups[1].Value;
// 同时兼容单/双引号,彻底清理可能影响最终缩放策略的属性
attrs = Regex.Replace(attrs, @"\s(width|height|viewBox|preserveAspectRatio)\s*=\s*(""[^""]*""|'[^']*')",
string.Empty, RegexOptions.IgnoreCase);
// 用调用方提供的稳定画布比例作为 viewBox调用方没传时再回退去读原属性
var vbW = viewBoxWidth;
var vbH = viewBoxHeight;
if (vbW <= 0 || vbH <= 0)
{
// 兼容回退路径:从原 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);
}
}
// 实在拿不到时按 jsbarcode 风格的兜底比例(避免 SVG 被纯拉伸)
if (vbW <= 0) vbW = 200;
if (vbH <= 0) vbH = 60;
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}>";
}, 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>
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);
/// <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" 自适应铺满。
/// textAlign 控制底部文字对齐center / left / right / justify两端
/// 文字通过 InjectBarcodeText 向 SVG 底部注入 &lt;text&gt; 元素实现,与 web 端 jsbarcode 行为对齐。
/// 注意ZXing.Net 的 SVG 渲染器不生成人类可读文字PureBarcode=false 在 SvgRenderer 无效),
/// 必须手动注入;同时只将 barHeight 传给 ZXing 渲染条形,避免条形撑满含文字区的总高度。
/// </summary>
private static string BuildBarcodeSvgInner(string value, BarcodeFormat format, bool displayValue,
string? textAlign = null, int lineWidth = 2, int barHeight = 60, int barFontSize = 14, bool fillCell = false)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
try
{
var moduleCount = EstimateBarcodeModuleCount(value, format);
var widthPx = Math.Max(120, moduleCount * lineWidth);
var barOnlyH = Math.Max(10, barHeight); // 只把条高给 ZXing文字由 InjectBarcodeText 注入
var writer = new BarcodeWriterSvg
{
Format = format,
Options = new ZXing.Common.EncodingOptions
{
Width = widthPx,
Height = barOnlyH,
Margin = 0,
PureBarcode = true, // ZXing SVG renderer 不支持文字,统一用 PureBarcode=true
},
};
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);
return normalized;
}
catch
{
return BuildFallbackBarcodeSvg(value);
}
}
/// <summary>
/// 向 ZXing 条码 SVG 底部注入人类可读文字并扩展 viewBox 高度,与 web 端 jsbarcode displayValue 行为对齐。
/// textAlign 支持 center / left / right / justify两端对齐通过 textLength+lengthAdjust 实现)。
/// </summary>
private static string InjectBarcodeText(string svg, string text, int fontSize, string? textAlign)
{
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);
var align = (textAlign ?? "center").Trim().ToLowerInvariant();
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);
}
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)
{
var parts = Regex.Split(m.Groups[1].Value.Trim(), @"[\s,]+");
if (parts.Length >= 4
&& 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)
{
return (x0, y0, w, h);
}
}
return (0d, 0d, Math.Max(1d, fallbackWidth), Math.Max(1d, fallbackHeight));
}
/// <summary>
/// 从 SVG 中提取条码“黑色墨迹”的左右边界。
/// 优先扫描 &lt;rect&gt;,忽略白色/透明填充;失败时回退到整块 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;
foreach (Match m in rects)
{
var tag = m.Value;
// 跳过明显的白底/透明背景 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);
}
/// <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,
};
}
// ─── Image ─────────────────────────────────────────────────────────────
private static string RenderImage(JsonNode el, JsonObject data, string posStyle,
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
{
var bindFieldRaw = ReadAsString(el["bindField"]);
var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim();
var src = ReadAsString(el["src"], string.Empty);
if (bindField != null)
src = ResolveField(data, bindField)?.ToString() ?? string.Empty;
var fit = ReadAsString(el["fit"], "contain");
var objFit = fit switch { "fill" => "fill", "cover" => "cover", _ => "contain" };
var inner = $"<img src=\"{EscapeHtml(src)}\" style=\"width:100%;height:100%;object-fit:{objFit};\" />";
if (!bandRepeat || totalPages <= 1)
return $"<div class=\"el\" style=\"{posStyle}\">{inner}</div>\n";
var baseNoTop = RemoveCssProperty(posStyle, "top");
var sb = new StringBuilder();
for (var p = 0; p < totalPages; p++)
{
var top = designY + p * pageHeightMm;
sb.Append($"<div class=\"el\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseNoTop}\">{inner}</div>\n");
}
return sb.ToString();
}
// ─── FreeTable ──────────────────────────────────────────────────────────
// printRepeated=true 或 bandRepeat=true 时在每页重复否则仅在第1页显示
private static string RenderFreeTable(JsonNode el, JsonObject data, string posStyle, double wMm, double hMm,
double designY, int totalPages, double pageHeightMm, bool bandRepeat = false)
{
var innerHtml = BuildFreeTableInnerHtml(el, data, wMm, hMm);
var printRepeat = string.Equals(ReadAsString(el["printRepeated"]), "true", StringComparison.OrdinalIgnoreCase);
var repeat = (printRepeat || bandRepeat) && totalPages > 1;
var baseNoTop = RemoveCssProperty(RemoveCssProperty(posStyle, "height"), "top");
if (!repeat)
return $"<div class=\"el\" style=\"top:{designY.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseNoTop}height:auto;overflow:visible;\">{innerHtml}</div>\n";
var sb = new StringBuilder();
for (var p = 0; p < totalPages; p++)
{
var top = designY + p * pageHeightMm;
sb.Append($"<div class=\"el\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseNoTop}height:auto;overflow:visible;\">{innerHtml}</div>\n");
}
return sb.ToString();
}
private static string BuildFreeTableInnerHtml(JsonNode el, JsonObject data, double wMm, double hMm)
{
var rowCount = Math.Max(1, el["rowCount"]?.GetValue<int>() ?? 1);
var colCount = Math.Max(1, el["colCount"]?.GetValue<int>() ?? 1);
var cells = el["cells"]?.AsArray() ?? [];
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());
var sb = new StringBuilder();
sb.Append($"<table style=\"width:{wMm.ToString("0.###", CultureInfo.InvariantCulture)}mm;border-collapse:collapse;border-spacing:0;table-layout:fixed;box-sizing:border-box;\">");
sb.Append("<colgroup>");
foreach (var cw in colWidths)
sb.Append($"<col style=\"width:{cw.ToString("0.###", CultureInfo.InvariantCulture)}mm;box-sizing:border-box;\" />");
sb.Append("</colgroup><tbody>");
for (var r = 0; r < rowCount; r++)
{
var rh = r < rowHeights.Length ? rowHeights[r] : hMm / rowCount;
sb.Append($"<tr style=\"height:{rh.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)
{
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);
// 跨列/跨行高度(用于自适应字号和内边距)
var spanW = 0d;
for (var ci = cell.Col; ci < cell.Col + cs && ci < colWidths.Length; ci++)
spanW += colWidths[ci];
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)));
// 内容渲染(支持所有 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";
var lh = nowrap ? $"{innerHmm.ToString("0.###", CultureInfo.InvariantCulture)}mm" : "1.15";
var widthCss = $"width:{spanW.ToString("0.###", CultureInfo.InvariantCulture)}mm;";
var rsAttr = rs > 1 ? $" rowspan=\"{rs}\"" : string.Empty;
var csAttr = cs > 1 ? $" colspan=\"{cs}\"" : string.Empty;
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};\">");
sb.Append(innerHtml);
sb.Append("</td>");
}
sb.Append("</tr>");
}
sb.Append("</tbody></table>");
return sb.ToString();
}
// ─── 自由表格:锚点单元格 ─────────────────────────────────────────────
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; }
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;
}
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,
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)),
};
}
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))
rawValue = ResolveField(data, cell.BindField!)?.ToString() ?? string.Empty;
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")
{
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);
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:{ws};height:{hs};margin:0 auto;overflow:hidden;\">{svg}</div>";
}
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;
}
// ─── Table / DetailTable ───────────────────────────────────────────────
private static string RenderDetailTable(JsonNode el, JsonObject data, string posStyle,
double designY, double pageHeightMm, double marginTopMm, double marginBottomMm,
JsonArray allElements, bool repeatHeaderByPage = false, double headerBandHeight = 0d)
{
var source = ReadAsString(el["source"], "mainTable");
var showHeader = !string.Equals(ReadAsString(el["showHeader"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var rowHeightMm = Math.Max(6d, el["rowHeight"]?.GetValue<double>() ?? 8d);
var headerBg = ReadAsString(el["headerBgColor"], "#f5f5f5");
var headerText = ReadAsString(el["headerTextColor"], "#111111");
var columns = el["columns"]?.AsArray() ?? [];
if (columns.Count == 0) return string.Empty;
// 元素级字体基准
var bodyFontSizeBase = el["bodyFontSize"]?.GetValue<double>() ?? 12d;
var headerFontSizeBase = el["headerFontSize"]?.GetValue<double>() ?? 12d;
var columnsList = columns.OfType<JsonObject>().ToList();
var rows = ResolveTableRows(data, source);
var footerMode = (ReadAsString(el["footerTotalMode"], "overall") ?? "overall").ToLowerInvariant();
var showFooter = !string.Equals(ReadAsString(el["footerShowTotal"], "true"), "false", StringComparison.OrdinalIgnoreCase);
// 行合并配置
var mergeColumnKeys = el["mergeColumnKeys"]?.AsArray()
?.Select(n => ReadAsString(n) ?? string.Empty)
.Where(s => !string.IsNullOrEmpty(s))
.ToArray() ?? [];
var strictGrouping = !string.Equals(ReadAsString(el["strictGrouping"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var detailH = el["h"]?.GetValue<double>() ?? 8d;
var repeatFreeConstrains = RepeatingFreeTableConstrainsDetail(allElements, designY, detailH);
List<List<JsonObject>> chunks;
var tableMode = (ReadAsString(el["tableHeightMode"], "autoPage") ?? "autoPage").ToLowerInvariant();
if (tableMode == "fixedrows")
{
var pageSize = Math.Max(1, el["fixedRows"]?.GetValue<int>() ?? 5);
chunks = ChunkRowsFixed(rows, pageSize);
}
else
{
chunks = ComputeAutoPageChunks(el, rows, columns, designY, pageHeightMm,
marginTopMm, marginBottomMm, showFooter, footerMode, rowHeightMm,
repeatFreeConstrains, headerBandHeight);
}
var sb = new StringBuilder();
var baseStyleNoTop = RemoveCssProperty(RemoveCssProperty(posStyle, "height"), "top");
for (var pageIdx = 0; pageIdx < chunks.Count; pageIdx++)
{
// 续页起点
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)
{
var headerRows = BuildTableHeaderRows(el, columnsList);
var headerHeightMm = Math.Max(6d, el["headerHeight"]?.GetValue<double>() ?? 10d);
var headerRowHeightMm = headerHeightMm / Math.Max(1, headerRows.Count);
sb.Append("<thead>");
foreach (var hrow in headerRows)
{
sb.Append($"<tr style=\"height:{headerRowHeightMm.ToString("0.###", CultureInfo.InvariantCulture)}mm;\">");
foreach (var hcell in hrow)
{
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>");
}
sb.Append("</tr>");
}
sb.Append("</thead>");
}
sb.Append("<tbody>");
for (var rowIdx = 0; rowIdx < chunkRows.Count; rowIdx++)
{
var row = chunkRows[rowIdx];
sb.Append($"<tr style=\"min-height:{rowHeightMm.ToString("0.###", CultureInfo.InvariantCulture)}mm;\">");
foreach (var col in columns)
{
var field = ReadAsString(col?["bindField"]) ?? ReadAsString(col?["field"]);
var spanKey = $"{rowIdx}_{field ?? string.Empty}";
if (rowSpanMap.TryGetValue(spanKey, out var spanVal) && spanVal == 0)
continue;
var rowspanAttr = (rowSpanMap.TryGetValue(spanKey, out var rsVal) && rsVal > 1)
? $" rowspan=\"{rsVal}\""
: string.Empty;
var align = ReadAsString(col?["align"], "left");
var contentType = ReadAsString(col?["contentType"], "text");
var autoWrap = !string.Equals(ReadAsString(col?["autoWrap"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var raw = !string.IsNullOrWhiteSpace(field) ? ResolveField(row, field!) : null;
var text = FormatColumnValue(raw, col, contentType);
var html = ResolveTableCellInnerHtml(contentType, text, col);
// 字体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>");
}
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)
{
// 用 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);
return mergeColumnKeys
.Select(k => byKey.TryGetValue(k, out var f) ? f : string.Empty)
.Where(f => !string.IsNullOrEmpty(f))
.ToList();
}
// 兼容老的列级 mergeByValue 开关
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>();
}
private static string ResolveTableCellInnerHtml(string? contentType, string value, JsonNode? col = null)
{
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")
{
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));
var svg = BuildBarcodeCellSvg(value, fmt, dv, null, bFontSize);
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:100%;height:100%;overflow:hidden;\">{svg}</div>";
}
if (t == "image")
return $"<img src=\"{EscapeHtml(value)}\" style=\"display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:contain;\" />";
return EscapeHtml(value);
}
private static string FormatColumnValue(object? raw, JsonNode? col, string? contentType)
{
if (raw == null) return string.Empty;
var t = (contentType ?? "text").Trim().ToLowerInvariant();
if (t is "number" or "amount")
{
if (double.TryParse(raw.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d)
|| double.TryParse(raw.ToString(), NumberStyles.Any, CultureInfo.CurrentCulture, out d))
{
var decimals = Math.Clamp((int?)col?["decimalPlaces"]?.GetValue<int>() ?? 2, 0, 6);
var formatted = d.ToString("N" + decimals, CultureInfo.CurrentCulture);
if (t == "amount")
{
var amountType = ReadAsString(col?["amountType"], "CNY");
var symbol = amountType switch { "USD" => "$", "EUR" => "EUR ", _ => "¥" };
return symbol + formatted;
}
return formatted;
}
}
return raw.ToString() ?? string.Empty;
}
// ─── 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))));
}
// ─── 多级表头构建 ───────────────────────────────────────────────────────
private sealed class HeaderCell
{
public int Row { get; set; }
public int Col { get; set; }
public int RowSpan { get; set; } = 1;
public int ColSpan { get; set; } = 1;
public string Title { get; set; } = string.Empty;
public string Align { get; set; } = "center";
public double WidthPercent { get; set; }
}
private static List<List<HeaderCell>> BuildTableHeaderRows(JsonNode tableEl, List<JsonObject> columns)
{
var colCount = Math.Max(1, columns.Count);
var widths = columns.Select(c => c["width"]?.GetValue<double>() ?? 30d).ToList();
var totalWidth = Math.Max(0.001, widths.Sum());
double CalcWP(int start, int span)
{
var end = Math.Min(colCount, start + span);
var sum = 0d;
for (var i = start; i < end; i++) sum += widths[i];
return sum / totalWidth * 100d;
}
var enableMulti = string.Equals(ReadAsString(tableEl["enableMultiHeader"], "false"), "true", StringComparison.OrdinalIgnoreCase);
if (!enableMulti)
{
var single = new List<HeaderCell>();
for (var i = 0; i < colCount; i++)
single.Add(new HeaderCell
{
Row = 0, Col = i,
Title = ReadAsString(columns[i]["title"], string.Empty) ?? string.Empty,
Align = ReadAsString(columns[i]["align"], "center") ?? "center",
WidthPercent = CalcWP(i, 1)
});
return new List<List<HeaderCell>> { single };
}
var cfg = tableEl["headerConfig"];
var rowCount = Math.Max(1, cfg?["rowCount"]?.GetValue<int>() ?? 1);
var cfgColCount = Math.Max(1, cfg?["colCount"]?.GetValue<int>() ?? colCount);
if (cfgColCount != colCount)
return BuildTableHeaderRows(new JsonObject { ["enableMultiHeader"] = false }, columns);
var owner = new HeaderCell?[rowCount, colCount];
var cells = new List<HeaderCell>();
var cfgCells = cfg?["cells"]?.AsArray() ?? new JsonArray();
foreach (var n in cfgCells.OfType<JsonObject>())
{
var r = Math.Max(0, n["row"]?.GetValue<int>() ?? 0);
var c = Math.Max(0, n["col"]?.GetValue<int>() ?? 0);
var rs = Math.Max(1, n["rowspan"]?.GetValue<int>() ?? 1);
var cs = Math.Max(1, n["colspan"]?.GetValue<int>() ?? 1);
if (r >= rowCount || c >= colCount) continue;
var maxR = Math.Min(rowCount, r + rs);
var maxC = Math.Min(colCount, c + cs);
var conflict = false;
for (var rr = r; rr < maxR && !conflict; rr++)
for (var cc = c; cc < maxC; cc++)
if (owner[rr, cc] != null) { conflict = true; break; }
if (conflict) continue;
var cell = new HeaderCell
{
Row = r, Col = c, RowSpan = maxR - r, ColSpan = maxC - c,
Title = ReadAsString(n["title"], string.Empty) ?? string.Empty,
Align = ReadAsString(n["align"], "center") ?? "center",
};
cell.WidthPercent = CalcWP(cell.Col, cell.ColSpan);
cells.Add(cell);
for (var rr = r; rr < maxR; rr++)
for (var cc = c; cc < maxC; cc++)
owner[rr, cc] = cell;
}
for (var r = 0; r < rowCount; r++)
{
for (var c = 0; c < colCount; c++)
{
if (owner[r, c] != null) continue;
var cell = new HeaderCell
{
Row = r, Col = c, RowSpan = 1, ColSpan = 1,
Title = r == rowCount - 1 ? (ReadAsString(columns[c]["title"], string.Empty) ?? string.Empty) : string.Empty,
Align = ReadAsString(columns[c]["align"], "center") ?? "center",
WidthPercent = CalcWP(c, 1)
};
owner[r, c] = cell;
cells.Add(cell);
}
}
var rows = Enumerable.Range(0, rowCount).Select(_ => new List<HeaderCell>()).ToList();
foreach (var cell in cells)
{
if (!ReferenceEquals(owner[cell.Row, cell.Col], cell)) continue;
rows[cell.Row].Add(cell);
}
foreach (var row in rows) row.Sort((a, b) => a.Col.CompareTo(b.Col));
return rows;
}
// ─── 通用工具 ───────────────────────────────────────────────────────────
private static object? ResolveField(JsonObject data, string fieldPath)
{
JsonNode? node = data;
foreach (var key in fieldPath.Split('.'))
{
node = node?[key];
if (node == null) return null;
}
return node switch
{
JsonArray a => a,
JsonObject o => o,
JsonValue v when v.TryGetValue<string>(out var s) => s,
JsonValue v when v.TryGetValue<double>(out var d) => d,
JsonValue v when v.TryGetValue<bool>(out var b) => b,
_ => node?.ToString()
};
}
private static string? ReadAsString(JsonNode? node, string? defaultValue = null)
{
if (node == null) return defaultValue;
if (node is JsonValue v)
{
if (v.TryGetValue<string>(out var s)) return s;
if (v.TryGetValue<double>(out var d)) return d.ToString(CultureInfo.InvariantCulture);
if (v.TryGetValue<int>(out var i)) return i.ToString(CultureInfo.InvariantCulture);
if (v.TryGetValue<long>(out var l)) return l.ToString(CultureInfo.InvariantCulture);
if (v.TryGetValue<bool>(out var b)) return b ? "true" : "false";
}
return node.ToString();
}
private static string EscapeHtml(string? s) =>
(s ?? string.Empty).Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
private static string ConvertDateFormat(string? jsFormat) =>
(jsFormat ?? "yyyy-MM-dd").Replace("YYYY", "yyyy").Replace("DD", "dd");
private static string ReplaceTemplatePlaceholders(string input, JsonObject data, int pageNo, int totalPages)
{
if (string.IsNullOrEmpty(input)) return input;
return Regex.Replace(input, @"\{\{\s*([\w\.]+)\s*\}\}", m =>
{
var key = m.Groups[1].Value;
if (key.Equals("pageNo", StringComparison.OrdinalIgnoreCase)) return pageNo.ToString(CultureInfo.InvariantCulture);
if (key.Equals("totalPages", StringComparison.OrdinalIgnoreCase)) return totalPages.ToString(CultureInfo.InvariantCulture);
return ResolveField(data, key)?.ToString() ?? string.Empty;
});
}
private static string RemoveCssProperty(string style, string propertyName)
{
if (string.IsNullOrWhiteSpace(style)) return string.Empty;
var kept = style.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(p => p.Trim())
.Where(p =>
{
var idx = p.IndexOf(':');
if (idx <= 0) return true;
return !p[..idx].Trim().Equals(propertyName, StringComparison.OrdinalIgnoreCase);
})
.ToList();
return kept.Count == 0 ? string.Empty : string.Join(";", kept) + ";";
}
}