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; /// /// 将后端「原生打印模板」JSON 渲染为 HTML,再通过调用方传入的 WebView2 实例导出 PDF base64。 /// 支持元素类型:text/title/subtitle/date/pageNo/qrcode/barcode/image/freeTable/table/detailTable。 /// public static class NativePrintRenderService { private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; // ─── 入口 ────────────────────────────────────────────────────────────── /// /// 将模板 JSON + 数据对象渲染为完整的可打印 HTML 页面(自包含,无外部依赖)。 /// 屏幕样式与后端前端预览保持一致:深灰底(#525659)+ 白纸居中 + 页间分割线。 /// 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() ?? 210; var heightMm = page["height"]?.GetValue() ?? 297; var margin = page["margin"]?.AsArray(); var mt = margin?[0]?.GetValue() ?? 0; var mr = margin?[1]?.GetValue() ?? 0; var mb = margin?[2]?.GetValue() ?? 0; var ml = margin?[3]?.GetValue() ?? 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() .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() ?? 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("\n\n\n\n"); if (enableScreenAutoFit) { sb.Append("\n"); } sb.Append("\n\n"); sb.Append($"
\n"); // 按 zIndex 升序渲染(低 zIndex 先画,高 zIndex 覆盖在上层) var sortedElements = elements.OfType() .OrderBy(el => el?["zIndex"]?.GetValue() ?? 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($"
\n"); sb.Append($"
\n"); } sb.Append("
\n"); 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() ?? 0; var y = el["y"]?.GetValue() ?? 0; var w = el["w"]?.GetValue() ?? 20; var h = el["h"]?.GetValue() ?? 8; var zIndex = el["zIndex"]?.GetValue() ?? 0; var rotate = el["rotate"]?.GetValue() ?? 0; var style = el["style"]; var fs = style?["fontSize"]?.GetValue() ?? 12; var fw = ReadAsString(style?["fontWeight"], "400"); var color = ReadAsString(style?["color"], "#111111"); var align = ReadAsString(style?["textAlign"], "left"); var lh = style?["lineHeight"]?.GetValue() ?? 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() ?? 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 }; } /// /// 判断元素是否属于报表头区域(与前端 isElementInHeaderRegion 一致)。 /// 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 $"
{EscapeHtml(text)}
\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($"
{EscapeHtml(textPerPage)}
\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 = $""; } catch { return string.Empty; } var wrapStyle = "display:flex;align-items:center;justify-content:center;"; if (!bandRepeat || totalPages <= 1) return $"
{inner}
\n"; var baseNoTop = RemoveCssProperty(posStyle, "top"); var sb = new StringBuilder(); for (var p = 0; p < totalPages; p++) { var top = designY + p * pageHeightMm; sb.Append($"
{inner}
\n"); } return sb.ToString(); } // ─── Barcode ──────────────────────────────────────────────────────────── /// /// 使用 ZXing.Net 的 SVG 渲染器生成真实可扫码的 1D 条码,与 web 端 jsbarcode 输出的 /// SVG 行为一致(preserveAspectRatio="xMidYMid meet",外层 CSS 100% 保比例铺满)。 /// 失败时回退到原占位 SVG,避免渲染流程中断。 /// 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() ?? 2d)); var barHeightPx = Math.Max(10, (int)Math.Round(el["barHeight"]?.GetValue() ?? 60d)); var barFontSize = Math.Max(8, (int)Math.Round(el["fontSize"]?.GetValue() ?? 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 $"
{inner}
\n"; var baseNoTop = RemoveCssProperty(posStyle, "top"); var sb = new StringBuilder(); for (var p = 0; p < totalPages; p++) { var top = designY + p * pageHeightMm; sb.Append($"
{inner}
\n"); } return sb.ToString(); } /// /// 把模板元素上的 format 字符串映射到 ZXing 的 BarcodeFormat 枚举。 /// 覆盖 web 端设计器(jsbarcode)支持的全部码制;对 ZXing 不直接支持的子集 /// (CODE128 子集、MSI 变体、EAN-5/EAN-2、pharmacode)做最接近映射,避免渲染失败。 /// 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, }; } /// /// 把 ZXing 输出的 SVG 转换为"由外层 CSS 控制尺寸 + 可配置铺满策略"的形式: /// 1) 强制清除根 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 双向拉伸,条码视觉变粗、变高。 /// 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, @"]*)>", 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 $""; }, RegexOptions.IgnoreCase); } /// 条码生成失败时的占位 SVG,至少把原文字显示出来便于排查。 private static string BuildFallbackBarcodeSvg(string value) { var escapedVal = EscapeHtml(value); return $"" + $"" + $"{escapedVal}" + $""; } /// /// 表格单元格用:包装统一的 BuildBarcodeSvgInner,保持与独立 barcode 元素一致的视觉比例。 /// 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); /// /// 调用 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 底部注入 <text> 元素实现,与 web 端 jsbarcode 行为对齐。 /// 注意:ZXing.Net 的 SVG 渲染器不生成人类可读文字(PureBarcode=false 在 SvgRenderer 无效), /// 必须手动注入;同时只将 barHeight 传给 ZXing 渲染条形,避免条形撑满含文字区的总高度。 /// 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 底部添加 ,扩展 viewBox 高度以容纳文字区 if (displayValue) normalized = InjectBarcodeText(normalized, value, barFontSize, textAlign); return normalized; } catch { return BuildFallbackBarcodeSvg(value); } } /// /// 向 ZXing 条码 SVG 底部注入人类可读文字并扩展 viewBox 高度,与 web 端 jsbarcode displayValue 行为对齐。 /// textAlign 支持 center / left / right / justify(两端对齐,通过 textLength+lengthAdjust 实现)。 /// 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" => $"{esc}", "right" => $"{esc}", "justify" => $"{esc}", _ => $"{esc}", }; // 插入 闭合标签之前 return Regex.Replace(svg, @"", $"{textElem}", RegexOptions.IgnoreCase); } /// /// 收紧条码水平 viewBox,裁掉左右静区;仅调整 x/width,不改 y/height。 /// 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)); } /// /// 从 SVG 中提取条码“黑色墨迹”的左右边界。 /// 优先扫描 <rect>,忽略白色/透明填充;失败时回退到整块 viewBox。 /// 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, @"]*>", 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); } /// /// 估算 1D 条码所需的模块数(即最窄条单位个数),用于给 ZXing 提供合适的 Width, /// 确保 SVG viewBox 比例稳定。各码制按字符 × 每字符模块数 + 起止/校验粗略估算。 /// 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 = $""; if (!bandRepeat || totalPages <= 1) return $"
{inner}
\n"; var baseNoTop = RemoveCssProperty(posStyle, "top"); var sb = new StringBuilder(); for (var p = 0; p < totalPages; p++) { var top = designY + p * pageHeightMm; sb.Append($"
{inner}
\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 $"
{innerHtml}
\n"; var sb = new StringBuilder(); for (var p = 0; p < totalPages; p++) { var top = designY + p * pageHeightMm; sb.Append($"
{innerHtml}
\n"); } return sb.ToString(); } private static string BuildFreeTableInnerHtml(JsonNode el, JsonObject data, double wMm, double hMm) { var rowCount = Math.Max(1, el["rowCount"]?.GetValue() ?? 1); var colCount = Math.Max(1, el["colCount"]?.GetValue() ?? 1); var cells = el["cells"]?.AsArray() ?? []; var borderColor = ReadAsString(el["borderColor"], "#222222") ?? "#222222"; var borderWidth = Math.Max(1, el["borderWidth"]?.GetValue() ?? 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($""); sb.Append(""); foreach (var cw in colWidths) sb.Append($""); sb.Append(""); for (var r = 0; r < rowCount; r++) { var rh = r < rowHeights.Length ? rowHeights[r] : hMm / rowCount; sb.Append($""); if (!anchorsByRow.TryGetValue(r, out var rowAnchors)) rowAnchors = new List(); 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($""); sb.Append(innerHtml); sb.Append(""); } sb.Append(""); } sb.Append("
"); 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() ?? 0), Col = Math.Max(0, c["col"]?.GetValue() ?? 0), Rowspan = Math.Max(1, c["rowspan"]?.GetValue() ?? 1), Colspan = Math.Max(1, c["colspan"]?.GetValue() ?? 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() ?? 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() ?? 100d)), ImageFit = imgFit, DecimalPlaces = Math.Clamp(c["decimalPlaces"]?.GetValue() ?? 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() ?? 14d)), }; } private static FreeTableAnchorCell DefaultFreeTableCell(int row, int col) => new() { Row = row, Col = col }; /// /// 移植自前端 normalizeFreeTableAnchors:去除重叠、填补空格,返回排序后的锚点列表。 /// private static List NormalizeFreeTableAnchors(int rowCount, int colCount, JsonArray cells) { var occ = new bool[rowCount, colCount]; var anchors = new List(); // 解析 + 排序 var parsed = cells.OfType() .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; } /// /// 找到"拥有" (r, c) 位置的锚点格(考虑合并跨度)。 /// private static FreeTableAnchorCell GetFreeTableOwnerAt(List 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() ?? 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() ?? 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(); 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; // ─── 自由表格:边框体系 ─────────────────────────────────────────────── /// /// 移植自前端 resolveFreeTableCellBorderSides:计算单元格四边是否画线。 /// 同时考虑 outerBorder / innerBorder 开关、单元格 hideBorder* 及相邻格共享边。 /// private static (bool top, bool right, bool bottom, bool left) ResolveFreeTableCellBorderSides( JsonNode el, List 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); } /// /// 移植自前端 resolveFreeTableCellLineStyleKeys:按位置判断各边线型(外框/横/竖)。 /// 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 $""; } 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 $""; } 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 $"
{svg}
"; } 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(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() ?? 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() ?? 12d; var headerFontSizeBase = el["headerFontSize"]?.GetValue() ?? 12d; var columnsList = columns.OfType().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() ?? 8d; var repeatFreeConstrains = RepeatingFreeTableConstrainsDetail(allElements, designY, detailH); List> chunks; var tableMode = (ReadAsString(el["tableHeightMode"], "autoPage") ?? "autoPage").ToLowerInvariant(); if (tableMode == "fixedrows") { var pageSize = Math.Max(1, el["fixedRows"]?.GetValue() ?? 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($"
"); sb.Append(""); if (showHeader) { var headerRows = BuildTableHeaderRows(el, columnsList); var headerHeightMm = Math.Max(6d, el["headerHeight"]?.GetValue() ?? 10d); var headerRowHeightMm = headerHeightMm / Math.Max(1, headerRows.Count); sb.Append(""); foreach (var hrow in headerRows) { sb.Append($""); 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() ?? 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($"{EscapeHtml(hcell.Title)}"); } sb.Append(""); } sb.Append(""); } sb.Append(""); for (var rowIdx = 0; rowIdx < chunkRows.Count; rowIdx++) { var row = chunkRows[rowIdx]; sb.Append($""); 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() ?? 30d); var useCustomFs = string.Equals(ReadAsString(col?["useCustomFontSize"]), "true", StringComparison.OrdinalIgnoreCase); var bodyBaseSize = useCustomFs ? (col?["fontSize"]?.GetValue() ?? 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($"{html}"); } sb.Append(""); } sb.Append(""); if (showFooter && (footerMode == "page" || isLastChunk)) { var footerRows = footerMode == "page" ? chunkRows : rows; sb.Append(BuildFooterHtml(el, footerRows, columns)); } sb.Append("
"); } return sb.ToString(); } // ─── 行合并 ──────────────────────────────────────────────────────────── private static Dictionary BuildRowSpanMap( List rows, JsonArray columns, string[] mergeColumnKeys, bool strictGrouping) { var map = new Dictionary(); 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 ResolveMergeFields(JsonArray columns, string[] mergeColumnKeys) { var colList = columns.OfType().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 map, List<(int start, int end)> nextRanges) BuildRangesByField(List rows, string field, List<(int start, int end)> ranges) { var map = new Dictionary(); 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> ChunkRowsFixed(List rows, int pageSize) { var size = Math.Max(1, pageSize); if (rows.Count == 0) return new List> { new() }; var result = new List>(); for (var i = 0; i < rows.Count; i += size) result.Add(rows.Skip(i).Take(size).ToList()); return result; } private static List> ComputeAutoPageChunks( JsonNode el, List 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> { new() }; var showHeader = !string.Equals(ReadAsString(el["showHeader"], "true"), "false", StringComparison.OrdinalIgnoreCase); var headerHeightMm = showHeader ? Math.Max(6d, el["headerHeight"]?.GetValue() ?? 10d) : 0d; var footerMm = showFooter ? Math.Max(rowHeightMm, 6d) : 0d; var innerH = pageHeightMm - marginTopMm - marginBottomMm; var colList = columns.OfType().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>(); 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> { new() }; } private static int CalcMaxRows(List 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 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() ?? 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() ?? 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 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(""); var colList = columns.OfType().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($"{EscapeHtml(cellText)}"); } sb.Append(""); 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()) { 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() ?? 0d; var rowHeightMm = Math.Max(6d, el["rowHeight"]?.GetValue() ?? 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() ?? 5); tablePages = Math.Max(1, (int)Math.Ceiling(rows.Count / (double)pageSize)); } else { var repeatFreeConstrains = RepeatingFreeTableConstrainsDetail(elements, designY, el["h"]?.GetValue() ?? 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()) { 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() ?? 0d; if (fy >= dBottom - 0.02) continue; return true; } return false; } // ─── 表格行数据 & 单元格渲染 ─────────────────────────────────────────── private static List 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().ToList(); return new List(); } 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 $""; } 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() ?? 14d)); var svg = BuildBarcodeCellSvg(value, fmt, dv, null, bFontSize); return $"
{svg}
"; } if (t == "image") return $""; 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() ?? 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)──── /// /// 当列开启 autoFitFont 时,根据列宽/行高/文本长度动态缩减字号,最小 8px。 /// 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> BuildTableHeaderRows(JsonNode tableEl, List columns) { var colCount = Math.Max(1, columns.Count); var widths = columns.Select(c => c["width"]?.GetValue() ?? 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(); 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> { single }; } var cfg = tableEl["headerConfig"]; var rowCount = Math.Max(1, cfg?["rowCount"]?.GetValue() ?? 1); var cfgColCount = Math.Max(1, cfg?["colCount"]?.GetValue() ?? colCount); if (cfgColCount != colCount) return BuildTableHeaderRows(new JsonObject { ["enableMultiHeader"] = false }, columns); var owner = new HeaderCell?[rowCount, colCount]; var cells = new List(); var cfgCells = cfg?["cells"]?.AsArray() ?? new JsonArray(); foreach (var n in cfgCells.OfType()) { var r = Math.Max(0, n["row"]?.GetValue() ?? 0); var c = Math.Max(0, n["col"]?.GetValue() ?? 0); var rs = Math.Max(1, n["rowspan"]?.GetValue() ?? 1); var cs = Math.Max(1, n["colspan"]?.GetValue() ?? 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()).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(out var s) => s, JsonValue v when v.TryGetValue(out var d) => d, JsonValue v when v.TryGetValue(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(out var s)) return s; if (v.TryGetValue(out var d)) return d.ToString(CultureInfo.InvariantCulture); if (v.TryGetValue(out var i)) return i.ToString(CultureInfo.InvariantCulture); if (v.TryGetValue(out var l)) return l.ToString(CultureInfo.InvariantCulture); if (v.TryGetValue(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) + ";"; } }