新增条码元素的填充选项,支持无损填满外层容器的配置。更新相关渲染逻辑和接口,提升条码显示的灵活性和用户体验。

This commit is contained in:
geht
2026-05-13 14:06:13 +08:00
parent b6e468abfa
commit 210f3614ea
6 changed files with 221 additions and 69 deletions

View File

@@ -345,8 +345,12 @@ public static class NativePrintRenderService
var lineWidthPx = Math.Max(1, (int)Math.Round(el["lineWidth"]?.GetValue<double>() ?? 2d));
var barHeightPx = Math.Max(10, (int)Math.Round(el["barHeight"]?.GetValue<double>() ?? 60d));
var barFontSize = Math.Max(8, (int)Math.Round(el["fontSize"]?.GetValue<double>() ?? 14d));
// 与后端对齐:优先读元素级 fillCell兼容旧模板的 style.fillCell。
// 均未配置时默认 false保比例居中
var fillCellNode = el["fillCell"] ?? el["style"]?["fillCell"];
var fillCell = string.Equals(ReadAsString(fillCellNode, "false"), "true", StringComparison.OrdinalIgnoreCase);
var inner = BuildBarcodeSvgInner(value, format, displayValue, textAlign, lineWidthPx, barHeightPx, barFontSize);
var inner = BuildBarcodeSvgInner(value, format, displayValue, textAlign, lineWidthPx, barHeightPx, barFontSize, fillCell);
var wrapStyle = "display:flex;align-items:center;justify-content:center;overflow:hidden;";
@@ -426,38 +430,53 @@ public static class NativePrintRenderService
}
/// <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 推导。
/// 把 ZXing 输出的 SVG 转换为"由外层 CSS 控制尺寸 + 可配置铺满策略"的形式:
/// 1) <b>强制清除</b>根 svg 节点上原有的 width / height / viewBox / preserveAspectRatio 属性
/// (不再依赖 ZXing 自身输出格式,无论它用单引号、双引号、无引号都覆写
/// 2) 用调用方传入的 viewBoxWidth × viewBoxHeight 作为 viewBox保证容器内
/// 条码比例与 BuildBarcodeSvgInner 计算的画布比例一致;
/// 3) preserveAspectRatiofillCell=true → "none"(按容器拉伸铺满,矢量缩放无损);
/// fillCell=false → "xMidYMid meet"(保比例居中,与 web jsbarcode 默认一致);
/// 4) 重新挂上 width="100%" height="100%",确保外层 div 100% 铺满。
/// 这里不再"尝试从原 SVG 派生 viewBox",否则 regex 不匹配 ZXing 实际输出(单引号 /
/// 无属性 / 顺序差异viewBox 会被漏掉,导致 SVG 被 CSS 双向拉伸,条码视觉变粗、变高。
/// </summary>
private static string? NormalizeBarcodeSvg(string svg)
private static string? NormalizeBarcodeSvg(string svg, bool fillCell = false,
double viewBoxWidth = 0, double viewBoxHeight = 0)
{
if (string.IsNullOrWhiteSpace(svg)) return null;
var targetAspect = fillCell ? "none" : "xMidYMid meet";
return Regex.Replace(svg, @"<svg\b([^>]*)>", m =>
{
var attrs = m.Groups[1].Value;
// 取原 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);
// 同时兼容单/双引号,彻底清理可能影响最终缩放策略的属性
attrs = Regex.Replace(attrs, @"\s(width|height|viewBox|preserveAspectRatio)\s*=\s*(""[^""]*""|'[^']*')",
string.Empty, 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)
// 用调用方提供的稳定画布比例作为 viewBox调用方没传时再回退去读原属性
var vbW = viewBoxWidth;
var vbH = viewBoxHeight;
if (vbW <= 0 || vbH <= 0)
{
attrs += $" viewBox=\"0 0 {w.ToString("0.###", CultureInfo.InvariantCulture)} {h.ToString("0.###", CultureInfo.InvariantCulture)}\"";
// 兼容回退路径:从原 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;
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}>";
var vbStr = $"0 0 {vbW.ToString("0.###", CultureInfo.InvariantCulture)} {vbH.ToString("0.###", CultureInfo.InvariantCulture)}";
return $"<svg width=\"100%\" height=\"100%\" viewBox=\"{vbStr}\" preserveAspectRatio=\"{targetAspect}\"{attrs}>";
}, RegexOptions.IgnoreCase);
}
@@ -483,37 +502,47 @@ public static class NativePrintRenderService
/// 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 端逻辑同源
/// textAlign 控制底部文字对齐center / left / right / justify两端
/// 文字通过 InjectBarcodeText 向 SVG 底部注入 &lt;text&gt; 元素实现,与 web 端 jsbarcode 行为对齐
/// 注意ZXing.Net 的 SVG 渲染器不生成人类可读文字PureBarcode=false 在 SvgRenderer 无效),
/// 必须手动注入;同时只将 barHeight 传给 ZXing 渲染条形,避免条形撑满含文字区的总高度。
/// </summary>
private static string BuildBarcodeSvgInner(string value, BarcodeFormat format, bool displayValue,
string? textAlign = null, int lineWidth = 2, int barHeight = 60, int barFontSize = 14)
string? textAlign = null, int lineWidth = 2, int barHeight = 60, int barFontSize = 14, bool fillCell = false)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
try
{
// moduleCount 仅用于派生稳定的 SVG viewBox 宽度ZXing 实际按编码后的真实模块数
// 1:1 绘制;多给一点宽度不会让条变粗,只会让两侧留白略多。
var moduleCount = EstimateBarcodeModuleCount(value, format);
var fontFooter = displayValue ? Math.Max(8, barFontSize) + 4 : 0; // fontSize + padding ≈ jsbarcode 行为
var widthPx = Math.Max(120, moduleCount * lineWidth);
var heightPx = Math.Max(10, barHeight) + fontFooter;
var barOnlyH = Math.Max(10, barHeight); // 只把条高给 ZXing文字由 InjectBarcodeText 注入
var writer = new BarcodeWriterSvg
{
Format = format,
Options = new ZXing.Common.EncodingOptions
{
Width = widthPx,
Height = heightPx,
Margin = 0,
PureBarcode = !displayValue,
Width = widthPx,
Height = barOnlyH,
Margin = 0,
PureBarcode = true, // ZXing SVG renderer 不支持文字,统一用 PureBarcode=true
},
};
var svgStr = writer.Write(value)?.Content ?? string.Empty;
var normalized = NormalizeBarcodeSvg(svgStr) ?? BuildFallbackBarcodeSvg(value);
// 仅当显示文字时执行对齐后处理
if (displayValue) normalized = ApplyBarcodeTextAlign(normalized, textAlign);
var svgStr = writer.Write(value)?.Content ?? string.Empty;
// 关键:把我们提供给 ZXing 的 widthPx × barOnlyH 作为目标 viewBox 强制写入,
// 让桌面端 SVG 与 web jsbarcode 同样进入"viewBox + preserveAspectRatio=meet"渲染
// 路径,避免某些 ZXing 输出顺序/引号风格让 regex 漏掉 viewBox 派生导致条码被 CSS 双向拉伸。
//(注:底部文字区由后续 InjectBarcodeText 再把 viewBox 的高度扩展上去,
// 因此此处只用条形高度 barOnlyH。
var normalized = NormalizeBarcodeSvg(svgStr, fillCell, widthPx, barOnlyH)
?? BuildFallbackBarcodeSvg(value);
// 裁掉 ZXing 可能保留的左右静区,让条码主体宽度更贴近 web 端 jsbarcode 观感。
normalized = TightenBarcodeHorizontalViewBox(normalized, widthPx);
// 文字注入:在 viewBox 底部添加 <text>,扩展 viewBox 高度以容纳文字区
if (displayValue)
normalized = InjectBarcodeText(normalized, value, barFontSize, textAlign);
return normalized;
}
catch
@@ -523,45 +552,139 @@ public static class NativePrintRenderService
}
/// <summary>
/// 后处理 ZXing 输出 SVG 中的底部文字 &lt;text&gt; 节点,按 textAlign 修改 x/text-anchor
/// justify两端对齐通过 textLength + lengthAdjust=spacing 让浏览器自动拉伸字符间距
/// 宽度从 SVG 自身的 viewBox 第三个分量读取,不依赖 ZXing 的内部缩放策略,保证
/// "user units 坐标系内对齐 + 浏览器渲染" 一致。
/// ZXing 条码 SVG 底部注入人类可读文字并扩展 viewBox 高度,与 web 端 jsbarcode displayValue 行为对齐。
/// textAlign 支持 center / left / right / justify两端对齐通过 textLength+lengthAdjust 实现)
/// </summary>
private static string ApplyBarcodeTextAlign(string svg, string? textAlign)
private static string InjectBarcodeText(string svg, string text, int fontSize, 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 默认,不动
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 取 user units 宽度,作为对齐坐标参照
double vbWidth = 0;
var vbMatch = Regex.Match(svg, @"viewBox\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
if (vbMatch.Success)
// 扩展 viewBox 高度(保留原始 x0/y0
svg = Regex.Replace(svg, @"viewBox\s*=\s*""([^""]+)""", m =>
{
var parts = Regex.Split(vbMatch.Groups[1].Value.Trim(), @"[\s,]+");
return $"viewBox=\"{x0.ToString("0.###", CultureInfo.InvariantCulture)} {y0.ToString("0.###", CultureInfo.InvariantCulture)} {wStr} {hStr}\"";
}, RegexOptions.IgnoreCase);
// 计算文字 y 基线(条形底边 + textMargin + fontSize与 jsbarcode 版式一致
var yStr = (y0 + vbHeight + textMargin + safeFs).ToString("0.###", CultureInfo.InvariantCulture);
// 关键:文字水平对齐基于“条码墨迹真实范围”而非整块 viewBox。
// 否则当 SVG 内部存在左右静区/内缩时,文字会看起来和条码不对齐。
var (inkLeft, inkRight) = ResolveBarcodeInkBounds(svg, vbWidth);
var inkWidth = Math.Max(1e-6, inkRight - inkLeft);
var leftStr = inkLeft.ToString("0.###", CultureInfo.InvariantCulture);
var rightStr = inkRight.ToString("0.###", CultureInfo.InvariantCulture);
var widthStr = inkWidth.ToString("0.###", CultureInfo.InvariantCulture);
var midX = (inkLeft + inkWidth / 2d).ToString("0.###", CultureInfo.InvariantCulture);
var align = (textAlign ?? "center").Trim().ToLowerInvariant();
var esc = EscapeHtml(text);
var textElem = align switch
{
"left" => $"<text x=\"{leftStr}\" y=\"{yStr}\" text-anchor=\"start\" font-size=\"{safeFs}\" font-family=\"monospace\" fill=\"#000000\">{esc}</text>",
"right" => $"<text x=\"{rightStr}\" y=\"{yStr}\" text-anchor=\"end\" font-size=\"{safeFs}\" font-family=\"monospace\" fill=\"#000000\">{esc}</text>",
"justify" => $"<text x=\"{leftStr}\" y=\"{yStr}\" text-anchor=\"start\" textLength=\"{widthStr}\" lengthAdjust=\"spacing\" font-size=\"{safeFs}\" font-family=\"monospace\" fill=\"#000000\">{esc}</text>",
_ => $"<text x=\"{midX}\" y=\"{yStr}\" text-anchor=\"middle\" font-size=\"{safeFs}\" font-family=\"monospace\" fill=\"#000000\">{esc}</text>",
};
// 插入 </svg> 闭合标签之前
return Regex.Replace(svg, @"</svg>", $"{textElem}</svg>", RegexOptions.IgnoreCase);
}
/// <summary>
/// 收紧条码水平 viewBox裁掉左右静区仅调整 x/width不改 y/height。
/// </summary>
private static string TightenBarcodeHorizontalViewBox(string svg, double fallbackWidth)
{
var (x0, y0, vbW, vbH) = ParseViewBoxOrDefault(svg, fallbackWidth);
var (inkLeft, inkRight) = ResolveBarcodeInkBounds(svg, vbW);
var inkW = inkRight - inkLeft;
if (inkW <= 1e-6) return svg;
var newX = x0 + inkLeft;
var newViewBox = $"viewBox=\"{newX.ToString("0.###", CultureInfo.InvariantCulture)} {y0.ToString("0.###", CultureInfo.InvariantCulture)} {inkW.ToString("0.###", CultureInfo.InvariantCulture)} {vbH.ToString("0.###", CultureInfo.InvariantCulture)}\"";
return Regex.Replace(svg, @"viewBox\s*=\s*""[^""]+""", newViewBox, RegexOptions.IgnoreCase);
}
private static (double x0, double y0, double w, double h) ParseViewBoxOrDefault(string svg, double fallbackWidth = 200, double fallbackHeight = 60)
{
var m = Regex.Match(svg ?? string.Empty, @"viewBox\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
if (m.Success)
{
var parts = Regex.Split(m.Groups[1].Value.Trim(), @"[\s,]+");
if (parts.Length >= 4
&& double.TryParse(parts[2], NumberStyles.Any, CultureInfo.InvariantCulture, out var w) && w > 0)
&& 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)
{
vbWidth = w;
return (x0, y0, w, h);
}
}
if (vbWidth <= 0) return svg; // 拿不到 viewBox 宽度时不动,保持 ZXing 默认居中
return (0d, 0d, Math.Max(1d, fallbackWidth), Math.Max(1d, fallbackHeight));
}
return Regex.Replace(svg, @"<text\b([^>]*)>", m =>
/// <summary>
/// 从 SVG 中提取条码“黑色墨迹”的左右边界。
/// 优先扫描 &lt;rect&gt;,忽略白色/透明填充;失败时回退到整块 viewBox。
/// </summary>
private static (double left, double right) ResolveBarcodeInkBounds(string svg, double fallbackWidth)
{
if (string.IsNullOrWhiteSpace(svg))
return (0d, Math.Max(1d, fallbackWidth));
var rects = Regex.Matches(svg, @"<rect\b[^>]*>", RegexOptions.IgnoreCase);
double minX = double.PositiveInfinity;
double maxX = double.NegativeInfinity;
foreach (Match m in rects)
{
var 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);
var tag = m.Value;
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);
// 跳过明显的白底/透明背景 rect
var fill = ReadSvgAttr(tag, "fill")?.Trim().ToLowerInvariant();
if (fill is "none" or "transparent" or "#fff" or "#ffffff" or "white")
continue;
var xs = ReadSvgAttr(tag, "x");
var ws = ReadSvgAttr(tag, "width");
if (!TryParseSvgNumber(xs, out var x) || !TryParseSvgNumber(ws, out var w) || w <= 0)
continue;
if (x < minX) minX = x;
if (x + w > maxX) maxX = x + w;
}
if (double.IsInfinity(minX) || double.IsInfinity(maxX) || maxX <= minX)
return (0d, Math.Max(1d, fallbackWidth));
return (minX, maxX);
}
private static string? ReadSvgAttr(string tag, string attr)
{
if (string.IsNullOrWhiteSpace(tag) || string.IsNullOrWhiteSpace(attr))
return null;
var mm = Regex.Match(tag, $@"\b{Regex.Escape(attr)}\s*=\s*(""([^""]*)""|'([^']*)')", RegexOptions.IgnoreCase);
if (!mm.Success) return null;
return !string.IsNullOrEmpty(mm.Groups[2].Value) ? mm.Groups[2].Value : mm.Groups[3].Value;
}
private static bool TryParseSvgNumber(string? raw, out double value)
{
value = 0;
if (string.IsNullOrWhiteSpace(raw)) return false;
var s = raw.Trim();
// 支持 "123", "123.45", "123px";百分比不参与墨迹边界计算
if (s.EndsWith("%", StringComparison.Ordinal)) return false;
s = Regex.Replace(s, @"[^\d\.\-+eE]", "");
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value);
}
/// <summary>
@@ -1617,9 +1740,9 @@ public static class NativePrintRenderService
}
if (t == "barcode")
{
var fmt = ReadAsString(col?["barcodeFormat"]);
var dv = !string.Equals(ReadAsString(col?["displayValue"]), "false", StringComparison.OrdinalIgnoreCase);
var bFontSize = Math.Max(8, (int)Math.Round(col?["barcodeFontSize"]?.GetValue<double>() ?? 14d));
var fmt = ReadAsString(col?["barcodeFormat"]);
var dv = !string.Equals(ReadAsString(col?["displayValue"]), "false", StringComparison.OrdinalIgnoreCase);
var bFontSize = Math.Max(8, (int)Math.Round(col?["barcodeFontSize"]?.GetValue<double>() ?? 14d));
var svg = BuildBarcodeCellSvg(value, fmt, dv, null, bFontSize);
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:100%;height:100%;overflow:hidden;\">{svg}</div>";
}