增强条码元素和自由表格元素的渲染逻辑,支持更多条码格式和文本边框样式。新增条码渲染工具,优化打印预览窗口的打印机选择功能,提升用户体验和打印模板的灵活性。

This commit is contained in:
geht
2026-05-13 12:35:02 +08:00
parent d2f49add82
commit 2c8620522b
15 changed files with 1446 additions and 125 deletions

View File

@@ -4,6 +4,8 @@ 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;
@@ -312,6 +314,11 @@ public static class NativePrintRenderService
// ─── Barcode ────────────────────────────────────────────────────────────
/// <summary>
/// 使用 ZXing.Net 的 SVG 渲染器生成真实可扫码的 1D 条码,与 web 端 jsbarcode 输出的
/// SVG 行为一致preserveAspectRatio="xMidYMid meet",外层 CSS 100% 保比例铺满)。
/// 失败时回退到原占位 SVG避免渲染流程中断。
/// </summary>
private static string RenderBarcode(JsonNode el, JsonObject data, string posStyle, double w, double h,
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
{
@@ -321,12 +328,14 @@ public static class NativePrintRenderService
value = ResolveField(data, bindField)?.ToString() ?? value;
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var escapedVal = EscapeHtml(value);
var inner = $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" height=\"75%\">" +
$"<rect width=\"100%\" height=\"100%\" fill=\"white\"/>" +
$"<text x=\"50%\" y=\"50%\" font-size=\"8\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-family=\"monospace\">{escapedVal}</text>" +
$"</svg><span style=\"font-size:9px;font-family:monospace;\">{escapedVal}</span>";
var wrapStyle = "display:flex;flex-direction:column;align-items:center;justify-content:center;";
// 从元素配置取格式/显示文字开关;元素未设时按 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 inner = BuildBarcodeSvgInner(value, format, displayValue, textAlign);
var wrapStyle = "display:flex;align-items:center;justify-content:center;overflow:hidden;";
if (!bandRepeat || totalPages <= 1)
return $"<div class=\"el\" style=\"{posStyle}{wrapStyle}\">{inner}</div>\n";
@@ -341,6 +350,230 @@ public static class NativePrintRenderService
return sb.ToString();
}
/// <summary>
/// 把模板元素上的 format 字符串映射到 ZXing 的 BarcodeFormat 枚举。
/// 覆盖 web 端设计器jsbarcode支持的全部码制对 ZXing 不直接支持的子集
/// CODE128 子集、MSI 变体、EAN-5/EAN-2、pharmacode做最接近映射避免渲染失败。
/// </summary>
private static BarcodeFormat ParseBarcodeFormat(string? fmt)
{
var key = (fmt ?? string.Empty).Trim().ToUpperInvariant().Replace("-", "_");
return key switch
{
// Code128 系列ZXing 自动识别 A/B/C 编码集,统一映射到 CODE_128
"" => BarcodeFormat.CODE_128,
"CODE128" => BarcodeFormat.CODE_128,
"CODE_128" => BarcodeFormat.CODE_128,
"CODE128A" => BarcodeFormat.CODE_128,
"CODE128B" => BarcodeFormat.CODE_128,
"CODE128C" => BarcodeFormat.CODE_128,
// Code 39 / 93
"CODE39" => BarcodeFormat.CODE_39,
"CODE_39" => BarcodeFormat.CODE_39,
"CODE93" => BarcodeFormat.CODE_93,
"CODE_93" => BarcodeFormat.CODE_93,
// EAN / UPC
"EAN13" => BarcodeFormat.EAN_13,
"EAN_13" => BarcodeFormat.EAN_13,
"EAN8" => BarcodeFormat.EAN_8,
"EAN_8" => BarcodeFormat.EAN_8,
// EAN-5 / EAN-2 是 EAN-13 的附加码ZXing 不直接支持;为避免渲染失败回退为 CODE_128
"EAN5" => BarcodeFormat.CODE_128,
"EAN_5" => BarcodeFormat.CODE_128,
"EAN2" => BarcodeFormat.CODE_128,
"EAN_2" => BarcodeFormat.CODE_128,
"UPC" => BarcodeFormat.UPC_A,
"UPCA" => BarcodeFormat.UPC_A,
"UPC_A" => BarcodeFormat.UPC_A,
"UPCE" => BarcodeFormat.UPC_E,
"UPC_E" => BarcodeFormat.UPC_E,
// ITF / ITF-14
"ITF" => BarcodeFormat.ITF,
"ITF14" => BarcodeFormat.ITF,
"ITF_14" => BarcodeFormat.ITF,
// MSI 全部变体ZXing 不直接支持,回退到 CODE_128保证至少能扫码识别值
"MSI" => BarcodeFormat.CODE_128,
"MSI10" => BarcodeFormat.CODE_128,
"MSI11" => BarcodeFormat.CODE_128,
"MSI1010" => BarcodeFormat.CODE_128,
"MSI1110" => BarcodeFormat.CODE_128,
// PharmacodeZXing 不支持,回退 CODE_128
"PHARMACODE" => BarcodeFormat.CODE_128,
// Codabar
"CODABAR" => BarcodeFormat.CODABAR,
_ => BarcodeFormat.CODE_128,
};
}
/// <summary>
/// 把 ZXing 输出的 SVG 转换为"由外层 CSS 控制尺寸 + 保持原始条宽比例"的形式:
/// 1) 若原 SVG 无 viewBox用原 width/height 派生(让 preserveAspectRatio 生效);
/// 2) 删除根 svg 节点的 width/height 属性(移交给 CSS
/// 3) 强制 preserveAspectRatio="xMidYMid meet"(与 web 端 jsbarcode 一致);
/// 4) 重新挂上 width="100%" height="100%",确保 div 100% 铺满。
/// 防护层:若 ZXing 输出的 SVG 已带 viewBox直接复用否则从 width/height 推导。
/// </summary>
private static string? NormalizeBarcodeSvg(string svg)
{
if (string.IsNullOrWhiteSpace(svg)) return null;
return Regex.Replace(svg, @"<svg\b([^>]*)>", m =>
{
var attrs = m.Groups[1].Value;
// 取原 width/height 数值(若有),用于派生 viewBox
var widthMatch = Regex.Match(attrs, @"\swidth\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
var heightMatch = Regex.Match(attrs, @"\sheight\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
var hasViewBox = Regex.IsMatch(attrs, @"\sviewBox\s*=", RegexOptions.IgnoreCase);
if (!hasViewBox && widthMatch.Success && heightMatch.Success
&& double.TryParse(Regex.Replace(widthMatch.Groups[1].Value, @"[^\d.]", ""),
NumberStyles.Any, CultureInfo.InvariantCulture, out var w) && w > 0
&& double.TryParse(Regex.Replace(heightMatch.Groups[1].Value, @"[^\d.]", ""),
NumberStyles.Any, CultureInfo.InvariantCulture, out var h) && h > 0)
{
attrs += $" viewBox=\"0 0 {w.ToString("0.###", CultureInfo.InvariantCulture)} {h.ToString("0.###", CultureInfo.InvariantCulture)}\"";
}
attrs = Regex.Replace(attrs, @"\s(width|height)\s*=\s*""[^""]*""", string.Empty, RegexOptions.IgnoreCase);
if (!Regex.IsMatch(attrs, @"preserveAspectRatio", RegexOptions.IgnoreCase))
attrs += " preserveAspectRatio=\"xMidYMid meet\"";
return $"<svg width=\"100%\" height=\"100%\"{attrs}>";
}, RegexOptions.IgnoreCase);
}
/// <summary>条码生成失败时的占位 SVG至少把原文字显示出来便于排查。</summary>
private static string BuildFallbackBarcodeSvg(string value)
{
var escapedVal = EscapeHtml(value);
return $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" height=\"100%\" preserveAspectRatio=\"xMidYMid meet\" viewBox=\"0 0 200 60\">" +
$"<rect width=\"200\" height=\"60\" fill=\"white\"/>" +
$"<text x=\"100\" y=\"35\" font-size=\"12\" text-anchor=\"middle\" font-family=\"monospace\">{escapedVal}</text>" +
$"</svg>";
}
/// <summary>
/// 表格单元格用:包装统一的 BuildBarcodeSvgInner保持与独立 barcode 元素一致的视觉比例。
/// </summary>
private static string BuildBarcodeCellSvg(string value, string? format = null, bool displayValue = true, string? textAlign = null)
=> BuildBarcodeSvgInner(value ?? string.Empty, ParseBarcodeFormat(format), displayValue, textAlign);
/// <summary>
/// 调用 ZXing.Net 生成条码 SVG 的统一入口:用与 web 端 jsbarcode 默认参数等价的比例
/// lineWidth ≈ 2px/module、barHeight ≈ 60px、底部文字区 ≈ 18px来派生 ZXing 的
/// Width/Height使生成的 SVG 内部 viewBox 比例稳定(与 web 视觉风格对齐),不会随
/// 元素框形状90×60 / 50×30 mm漂移成 1.5:1。外层 div 用 100%+preserveAspectRatio
/// ="xMidYMid meet" 自适应铺满。
/// textAlign 控制底部文字对齐center / left / right / justify两端通过 SVG
/// 后处理修改 ZXing 输出的 &lt;text&gt; 节点 x/text-anchor 实现,与 web 端逻辑同源。
/// </summary>
private static string BuildBarcodeSvgInner(string value, BarcodeFormat format, bool displayValue, string? textAlign = null)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
try
{
// moduleCount 仅用于派生稳定的 SVG viewBox 宽度ZXing 实际按编码后的真实模块数
// 1:1 绘制;多给一点宽度不会让条变粗,只会让两侧留白略多。
var moduleCount = EstimateBarcodeModuleCount(value, format);
const int lineWidth = 2;
const int barHeight = 60;
var fontFooter = displayValue ? 18 : 0; // jsbarcode 默认 fontSize 14 + 上下 padding ≈ 18px
var widthPx = Math.Max(120, moduleCount * lineWidth);
var heightPx = barHeight + fontFooter;
var writer = new BarcodeWriterSvg
{
Format = format,
Options = new ZXing.Common.EncodingOptions
{
Width = widthPx,
Height = heightPx,
Margin = 0,
PureBarcode = !displayValue,
},
};
var svgStr = writer.Write(value)?.Content ?? string.Empty;
var normalized = NormalizeBarcodeSvg(svgStr) ?? BuildFallbackBarcodeSvg(value);
// 仅当显示文字时执行对齐后处理
if (displayValue) normalized = ApplyBarcodeTextAlign(normalized, textAlign);
return normalized;
}
catch
{
return BuildFallbackBarcodeSvg(value);
}
}
/// <summary>
/// 后处理 ZXing 输出 SVG 中的底部文字 &lt;text&gt; 节点,按 textAlign 修改 x/text-anchor
/// justify两端对齐通过 textLength + lengthAdjust=spacing 让浏览器自动拉伸字符间距。
/// 宽度从 SVG 自身的 viewBox 第三个分量读取,不依赖 ZXing 的内部缩放策略,保证
/// "user units 坐标系内对齐 + 浏览器渲染" 一致。
/// </summary>
private static string ApplyBarcodeTextAlign(string svg, string? textAlign)
{
if (string.IsNullOrEmpty(svg)) return svg;
var align = (textAlign ?? "center").Trim().ToLowerInvariant();
if (align is not ("left" or "right" or "justify")) return svg; // center 即 ZXing 默认,不动
// 解析 viewBox 取 user units 宽度,作为对齐坐标参照
double vbWidth = 0;
var vbMatch = Regex.Match(svg, @"viewBox\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
if (vbMatch.Success)
{
var parts = Regex.Split(vbMatch.Groups[1].Value.Trim(), @"[\s,]+");
if (parts.Length >= 4
&& double.TryParse(parts[2], NumberStyles.Any, CultureInfo.InvariantCulture, out var w) && w > 0)
{
vbWidth = w;
}
}
if (vbWidth <= 0) return svg; // 拿不到 viewBox 宽度时不动,保持 ZXing 默认居中
return Regex.Replace(svg, @"<text\b([^>]*)>", m =>
{
var attrs = m.Groups[1].Value;
attrs = Regex.Replace(attrs, @"\s(x|text-anchor|textLength|lengthAdjust)\s*=\s*""[^""]*""", string.Empty, RegexOptions.IgnoreCase);
var widthStr = vbWidth.ToString("0.###", CultureInfo.InvariantCulture);
return align switch
{
"left" => $"<text x=\"0\" text-anchor=\"start\"{attrs}>",
"right" => $"<text x=\"{widthStr}\" text-anchor=\"end\"{attrs}>",
"justify" => $"<text x=\"0\" text-anchor=\"start\" textLength=\"{widthStr}\" lengthAdjust=\"spacing\"{attrs}>",
_ => m.Value,
};
}, RegexOptions.IgnoreCase);
}
/// <summary>
/// 估算 1D 条码所需的模块数(即最窄条单位个数),用于给 ZXing 提供合适的 Width
/// 确保 SVG viewBox 比例稳定。各码制按字符 × 每字符模块数 + 起止/校验粗略估算。
/// </summary>
private static int EstimateBarcodeModuleCount(string value, BarcodeFormat format)
{
var n = Math.Max(1, value?.Length ?? 1);
return format switch
{
BarcodeFormat.CODE_128 => n * 11 + 35,
BarcodeFormat.CODE_39 => (n + 2) * 13,
BarcodeFormat.CODE_93 => (n + 4) * 9,
BarcodeFormat.EAN_13 => 95,
BarcodeFormat.UPC_A => 95,
BarcodeFormat.EAN_8 => 67,
BarcodeFormat.UPC_E => 51,
BarcodeFormat.ITF => (n / 2 + 1) * 9 + 7,
BarcodeFormat.CODABAR => (n + 2) * 10,
_ => n * 11 + 35,
};
}
// ─── Image ─────────────────────────────────────────────────────────────
private static string RenderImage(JsonNode el, JsonObject data, string posStyle,
@@ -798,7 +1031,8 @@ public static class NativePrintRenderService
{
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>";
var svg = BuildBarcodeCellSvg(innerArg);
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:{ws};height:{hs};margin:0 auto;overflow:hidden;\">{svg}</div>";
}
return EscapeHtml(displayValue);
}
@@ -1350,7 +1584,10 @@ public static class NativePrintRenderService
catch { return EscapeHtml(value); }
}
if (t == "barcode")
return $"<div style=\"font-family:monospace;text-align:center;\">{EscapeHtml(value)}</div>";
{
var svg = BuildBarcodeCellSvg(value);
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:100%;height:100%;overflow:hidden;\">{svg}</div>";
}
if (t == "image")
return $"<img src=\"{EscapeHtml(value)}\" style=\"display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:contain;\" />";
return EscapeHtml(value);

View File

@@ -90,10 +90,31 @@ public class PrintDotService : IPrintDotService
var status = resDoc?["status"]?.GetValue<string>();
if (status == null) continue;
if (status == "success") return;
throw new InvalidOperationException(resDoc?["message"]?.GetValue<string>() ?? "PrintDot 打印失败");
var rawMsg = resDoc?["message"]?.GetValue<string>() ?? "PrintDot 打印失败";
throw new InvalidOperationException(EnhanceErrorMessage(rawMsg));
}
}
/// <summary>
/// 将 PrintDot 返回的部分英文错误转换为带本地处理步骤的中文提示。
/// 与 web 端 printDotBridge.ts::enhancePrintDotErrorMessage 行为一致,方便桌面端用户自助排查。
/// </summary>
private static string EnhanceErrorMessage(string raw)
{
var m = (raw ?? string.Empty).Trim();
// 缺 SumatraPDF是 PrintDot 客户端最常见的初始化错误
if (System.Text.RegularExpressions.Regex.IsMatch(m, @"SumatraPDF\.exe not found", System.Text.RegularExpressions.RegexOptions.IgnoreCase)
|| System.Text.RegularExpressions.Regex.IsMatch(m, "SUMATRAPDF_PATH", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
return m + "。\n本地处理PrintDot 依赖 SumatraPDF 静默打印 PDF。请安装 SumatraPDF 后任选其一:\n" +
"① 将 SumatraPDF.exe 放在 PrintDot 客户端 exe 同目录;\n" +
"② 或将 SumatraPDF 安装目录加入系统 PATH\n" +
"③ 或设置用户/系统环境变量 SUMATRAPDF_PATH 指向 SumatraPDF.exe 的完整路径;\n" +
"然后重启 PrintDot 桥接器即可。";
}
return m;
}
private static async Task<string> ReceiveTextAsync(ClientWebSocket ws, CancellationToken ct)
{
var buffer = new ArraySegment<byte>(new byte[64 * 1024]);