优化自由表格渲染逻辑,增强单元格样式和内容处理,支持更灵活的表格布局。新增锚点单元格记录结构,改进边框计算和内容渲染,提升打印模板的可定制性和用户体验。

This commit is contained in:
geht
2026-05-12 18:49:12 +08:00
parent fcedc66f7a
commit bdb4eb5148

View File

@@ -392,56 +392,67 @@ public static class NativePrintRenderService
private static string BuildFreeTableInnerHtml(JsonNode el, JsonObject data, double wMm, double hMm)
{
var rowCount = el["rowCount"]?.GetValue<int>() ?? 1;
var colCount = el["colCount"]?.GetValue<int>() ?? 1;
var 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");
var borderWidth = el["borderWidth"]?.GetValue<int>() ?? 1;
var colWidths = el["colWidths"]?.AsArray()?.Select(n => n?.GetValue<double>() ?? wMm / colCount).ToArray()
?? Enumerable.Repeat(wMm / colCount, colCount).ToArray();
var rowHeights = el["rowHeights"]?.AsArray()?.Select(n => n?.GetValue<double>() ?? hMm / rowCount).ToArray()
?? Enumerable.Repeat(hMm / rowCount, rowCount).ToArray();
var borderColor = ReadAsString(el["borderColor"], "#222222") ?? "#222222";
var borderWidth = Math.Max(1, el["borderWidth"]?.GetValue<int>() ?? 1);
var cellMap = new Dictionary<(int row, int col), JsonNode>();
foreach (var c in cells)
if (c != null)
cellMap[(c["row"]?.GetValue<int>() ?? 0, c["col"]?.GetValue<int>() ?? 0)] = c;
var 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 occupied = new HashSet<(int, int)>();
var sb = new StringBuilder();
sb.Append($"<table style=\"border-collapse:collapse;width:{wMm}mm;table-layout:fixed;\">");
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}mm;\" />");
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}mm;\">");
for (var c = 0; c < colCount; c++)
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)
{
if (occupied.Contains((r, c))) continue;
cellMap.TryGetValue((r, c), out var cell);
var rs = cell?["rowspan"]?.GetValue<int>() ?? 1;
var cs = cell?["colspan"]?.GetValue<int>() ?? 1;
var bindField = ReadAsString(cell?["bindField"]);
var rawText = ReadAsString(cell?["text"], string.Empty);
var cellText = bindField != null
? ResolveField(data, bindField)?.ToString() ?? rawText
: rawText;
var ta = ReadAsString(cell?["align"], "left");
var va = ReadAsString(cell?["verticalAlign"], "middle");
var cFs = cell?["fontSize"]?.GetValue<double>() ?? 12;
var cColor = ReadAsString(cell?["color"], "#111111");
var cBg = ReadAsString(cell?["backgroundColor"], "#ffffff");
for (var dr = 0; dr < rs; dr++)
for (var dc = 0; dc < cs; dc++)
if (dr > 0 || dc > 0) occupied.Add((r + dr, c + dc));
var 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];
// 内容渲染(支持所有 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 ? $"{rh.ToString("0.###", CultureInfo.InvariantCulture)}mm" : "1.3";
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;
var bdr = $"border:{borderWidth}px solid {borderColor};";
sb.Append($"<td{rsAttr}{csAttr} style=\"{bdr}padding:1mm;text-align:{ta};vertical-align:{va};font-size:{cFs}px;color:{cColor};background:{cBg};word-break:break-all;\">");
sb.Append(EscapeHtml(cellText ?? string.Empty));
sb.Append($"<td{rsAttr}{csAttr} style=\"box-sizing:border-box;{borderCss}{widthCss}padding:2mm;text-align:{cell.Align};vertical-align:{cell.VerticalAlign};font-size:{cell.FontSize.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>");
@@ -450,6 +461,376 @@ public static class NativePrintRenderService
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;
}
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),
};
}
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() ?? cell.Text;
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)}%";
return $"<div style=\"display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:{ws};height:{hs};margin:0 auto;font-family:monospace;\">{EscapeHtml(innerArg)}</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,
@@ -459,11 +840,17 @@ public static class NativePrintRenderService
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 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);
@@ -475,7 +862,7 @@ public static class NativePrintRenderService
.ToArray() ?? [];
var strictGrouping = !string.Equals(ReadAsString(el["strictGrouping"], "true"), "false", StringComparison.OrdinalIgnoreCase);
var detailH = el["h"]?.GetValue<double>() ?? 8d;
var detailH = el["h"]?.GetValue<double>() ?? 8d;
var repeatFreeConstrains = RepeatingFreeTableConstrainsDetail(allElements, designY, detailH);
List<List<JsonObject>> chunks;
@@ -496,24 +883,17 @@ public static class NativePrintRenderService
var baseStyleNoTop = RemoveCssProperty(RemoveCssProperty(posStyle, "height"), "top");
for (var pageIdx = 0; pageIdx < chunks.Count; pageIdx++)
{
// 续页起点
// - repeatFreeConstrains → 沿用 designY重复自由表格占据了顶部空间
// - 否则 → 顶排到页面上边距 + 报表头带高度(消除空白)
// 续页起点
double top;
if (pageIdx == 0 || repeatFreeConstrains)
{
top = designY + pageIdx * pageHeightMm;
}
else
{
top = pageIdx * pageHeightMm + marginTopMm
+ (repeatHeaderByPage ? headerBandHeight : 0d);
}
var isLastChunk = pageIdx == chunks.Count - 1;
var chunkRows = chunks[pageIdx];
// 计算当前 chunk 的行合并 rowSpanMap按 chunk 内相对行号)
var rowSpanMap = BuildRowSpanMap(chunkRows, columns, mergeColumnKeys, strictGrouping);
sb.Append($"<div class=\"el qhmes-native-table-chunk\" style=\"top:{top.ToString("0.###", CultureInfo.InvariantCulture)}mm;{baseStyleNoTop}height:auto;overflow:visible;\">");
@@ -521,18 +901,35 @@ public static class NativePrintRenderService
if (showHeader)
{
var headerRows = BuildTableHeaderRows(el, columns.OfType<JsonObject>().ToList());
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 cell in hrow)
foreach (var hcell in hrow)
{
var rs = cell.RowSpan > 1 ? $" rowspan=\"{cell.RowSpan}\"" : string.Empty;
var cs = cell.ColSpan > 1 ? $" colspan=\"{cell.ColSpan}\"" : string.Empty;
sb.Append($"<th{rs}{cs} style=\"border:1px solid #222;padding:2mm;text-align:{cell.Align};font-weight:600;background:{headerBg};color:{headerText};width:{cell.WidthPercent.ToString("0.###", CultureInfo.InvariantCulture)}%;\">{EscapeHtml(cell.Title)}</th>");
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>");
}
@@ -549,7 +946,7 @@ public static class NativePrintRenderService
var field = ReadAsString(col?["bindField"]) ?? ReadAsString(col?["field"]);
var spanKey = $"{rowIdx}_{field ?? string.Empty}";
if (rowSpanMap.TryGetValue(spanKey, out var spanVal) && spanVal == 0)
continue; // 被上方行合并,跳过此格
continue;
var rowspanAttr = (rowSpanMap.TryGetValue(spanKey, out var rsVal) && rsVal > 1)
? $" rowspan=\"{rsVal}\""
@@ -561,10 +958,22 @@ public static class NativePrintRenderService
var raw = !string.IsNullOrWhiteSpace(field) ? ResolveField(row, field!) : null;
var text = FormatColumnValue(raw, col, contentType);
var html = ResolveTableCellInnerHtml(contentType, text);
var ws = autoWrap ? "normal" : "nowrap";
var wb = autoWrap ? "break-all" : "normal";
var lhv = autoWrap ? "1.3" : $"{rowHeightMm.ToString("0.###", CultureInfo.InvariantCulture)}mm";
sb.Append($"<td{rowspanAttr} style=\"border:1px solid #222;padding:2mm;text-align:{align};white-space:{ws};word-break:{wb};line-height:{lhv};\">{html}</td>");
// 字体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>");
}
@@ -583,10 +992,6 @@ public static class NativePrintRenderService
// ─── 行合并 ────────────────────────────────────────────────────────────
/// <summary>
/// 移植自前端 buildRowSpanMap。返回 "{rowIdx}_{field}" → rowspan 的映射;
/// 值为 0 表示该格被上方行合并,渲染时跳过;值 &gt; 1 表示需加 rowspan 属性。
/// </summary>
private static Dictionary<string, int> BuildRowSpanMap(
List<JsonObject> rows, JsonArray columns,
string[] mergeColumnKeys, bool strictGrouping)
@@ -688,8 +1093,6 @@ public static class NativePrintRenderService
while (i < rows.Count)
{
var remaining = rows.Count - i;
// repeatFreeConstrains → 续页可用高度同首页(重复自由表格占据了 designY 空间)
// 否则 → 续页顶排减去报表头带高度headerBandMm
var avail = pageIdx == 0
? innerH - designY - headerHeightMm
: repeatFreeConstrains
@@ -972,6 +1375,27 @@ public static class NativePrintRenderService
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
@@ -1089,12 +1513,12 @@ public static class NativePrintRenderService
}
return node switch
{
JsonArray a => a,
JsonObject o => o,
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()
_ => node?.ToString()
};
}