@@ -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) preserveAspectRatio: fillCell=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 ;
at trs = 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 vbS tr = $"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 输出的 <text> 节点 x/text-anchor 实现,与 web 端逻辑同源 。
/// textAlign 控制底部文字对齐: center / left / right / justify( 两端) ,
/// 文字通过 InjectBarcodeText 向 SVG 底部注入 <text> 元素实现,与 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 中的底部文字 <text> 节点,按 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 Apply BarcodeTextAlign ( string svg , string? textAlign )
private static string Inject BarcodeText( 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 x 0)
& & 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 中提取条码“黑色墨迹”的左右边界。
/// 优先扫描 <rect>,忽略白色/透明填充;失败时回退到整块 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>" ;
}