@@ -1,5 +1,8 @@
import ' dart:io ' show Platform ;
import ' package:flutter/foundation.da rt' show kIsWeb ;
import ' dart:io ' show Platform , Process , File , Directory ;
import ' dart:conve rt' show ascii ;
import ' dart:typed_data ' ;
import ' dart:ui ' as ui ;
import ' package:flutter/foundation.dart ' show kIsWeb , debugPrint ;
import ' package:flutter/services.dart ' ;
import ' ../errors/error_reporter.dart ' ;
import ' package:pdf/pdf.dart ' ;
@@ -29,26 +32,167 @@ String _fileTs() {
// ── 设计色彩 token ──────────────────────────────────────────────────────────
const _navy = PdfColor ( 0.122 , 0.165 , 0.227 ) ; // #1F2A3A header/chip
const _cream = PdfColor ( 0.957 , 0.925 , 0.847 ) ; // #F4ECD8 footer/reversed text
const _muted = PdfColor ( 0.533 , 0.533 , 0.533 ) ; // #888 field labels
const _footnote = PdfColor ( 0.353 , 0.306 , 0.208 ) ; // #5A4E35 footer text
const _ink = PdfColor ( 0.067 , 0.067 , 0.067 ) ; // #111 body text
pw . Widget _labelSpecRow ( pw . Font font , String label , String value ) = >
pw . Row (
crossAxisAlignment: pw . CrossAxisAlignment . start ,
children: [
pw . SizedBox (
width: 30 ,
child: pw . Text ( label ,
style: pw . TextStyle ( font: font , fontSize: 5.5 , color: _muted ) ) ,
) ,
pw . SizedBox ( width: 4 ) ,
pw . Expanded (
child: pw . Text ( value ,
style: pw . TextStyle ( font: font , fontSize: 5.5 , color: _ink ) ) ,
) ,
] ,
) ;
// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)────────
// 得力 DL-888B 等热敏机走系统打印(CUPS 通用驱动)会多走纸/空白,必须发原生 TSPL。
// 直接画位图而非走 PDF 光栅化:避免二维码渲染丢失、SIZE 用整数避免多走纸。
/// 查找热敏标签机的 CUPS 队列名(名字含 DL-888 / 888B / Deli)
Future < String ? > _findThermalQueue ( ) async {
try {
final r = await Process . run ( ' lpstat ' , [ ' -e ' ] ) ;
if ( r . exitCode ! = 0 ) return null ;
for ( final raw in ( r . stdout as String ) . split ( ' \n ' ) ) {
final q = raw . trim ( ) ;
if ( q . isEmpty ) continue ;
final lo = q . toLowerCase ( ) ;
if ( lo . contains ( ' dl-888 ' ) | | lo . contains ( ' dl_888 ' ) | |
lo . contains ( ' 888b ' ) | | lo . contains ( ' deli ' ) ) {
return q ;
}
}
} catch ( e ) {
debugPrint ( ' [label] lpstat 失败: $ e ' ) ;
}
return null ;
}
/// 在 canvas 上画一行文字(黑色,单行不换行,CJK 走系统字体回退)
void _drawText ( ui . Canvas canvas , String s , double x , double y , double size ,
double maxW , { bool bold = false , bool center = false } ) {
if ( s . isEmpty ) return ;
final pb = ui . ParagraphBuilder ( ui . ParagraphStyle (
fontSize: size ,
fontWeight: bold ? ui . FontWeight . bold : ui . FontWeight . normal ,
textAlign: center ? ui . TextAlign . center : ui . TextAlign . left ,
maxLines: 1 ,
ellipsis: ' ' ,
) )
. . pushStyle ( ui . TextStyle ( color: const ui . Color ( 0xFF000000 ) ) )
. . addText ( s ) ;
final p = pb . build ( ) . . layout ( ui . ParagraphConstraints ( width: maxW ) ) ;
canvas . drawParagraph ( p , ui . Offset ( x , y ) ) ;
}
/// 测量单行文本在指定字号下的宽度
double _measureWidth ( String s , double size , bool bold ) {
final pb = ui . ParagraphBuilder ( ui . ParagraphStyle (
fontSize: size ,
fontWeight: bold ? ui . FontWeight . bold : ui . FontWeight . normal ,
maxLines: 1 ,
) ) . . addText ( s ) ;
final p = pb . build ( ) . . layout ( const ui . ParagraphConstraints ( width: 100000 ) ) ;
return p . maxIntrinsicWidth ;
}
/// 自适应字号:在 maxW × maxH 范围内尽量大(受宽度、行高、上限三者约束)
double _fitFont ( String s , double maxW , double maxH , bool bold , double cap ) {
if ( s . isEmpty ) return 0 ;
final w100 = _measureWidth ( s , 100 , bold ) ;
final byW = w100 > 0 ? maxW / w100 * 100 : cap ;
var size = byW < maxH ? byW : maxH ;
if ( size > cap ) size = cap ;
return size ;
}
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
Future < bool > _printFlatLabelThermal ( {
required String shop ,
required String name ,
required String degSpec ,
required String date ,
required Uint8List qrBytes ,
} ) async {
if ( kIsWeb | | ! Platform . isMacOS ) return false ; // 目前仅 macOS( Windows 待加)
final queue = await _findThermalQueue ( ) ;
debugPrint ( ' [label] thermal queue = $ queue ' ) ;
if ( queue = = null ) return false ;
const w = 320 , h = 160 ; // 40× 20mm @203dpi
final recorder = ui . PictureRecorder ( ) ;
final canvas = ui . Canvas (
recorder , ui . Rect . fromLTWH ( 0 , 0 , w . toDouble ( ) , h . toDouble ( ) ) ) ;
canvas . drawRect ( ui . Rect . fromLTWH ( 0 , 0 , w . toDouble ( ) , h . toDouble ( ) ) ,
ui . Paint ( ) . . color = const ui . Color ( 0xFFFFFFFF ) ) ;
// 右侧二维码(解码后端 PNG 直接画上)
const qrS = 124.0 ;
const qrTop = 8.0 ;
const qrLeft = w - qrS - 4 ; // 192
try {
final codec = await ui . instantiateImageCodec ( qrBytes ) ;
final qrImg = ( await codec . getNextFrame ( ) ) . image ;
canvas . drawImageRect (
qrImg ,
ui . Rect . fromLTWH ( 0 , 0 , qrImg . width . toDouble ( ) , qrImg . height . toDouble ( ) ) ,
const ui . Rect . fromLTWH ( qrLeft , qrTop , qrS , qrS ) ,
ui . Paint ( ) ) ;
} catch ( e ) {
debugPrint ( ' [label] QR 解码失败: $ e ' ) ;
}
// 扫码溯源:居中于二维码正下方
_drawText ( canvas , ' 扫码溯源 ' , qrLeft , qrTop + qrS + 2 , 14 , qrS , center: true ) ;
// 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充
const lx = 22.0 ;
const textMaxW = qrLeft - lx - 6 ; // 164
final rows = < ( String , double , bool ) > [
( shop , 0.9 , true ) ,
( name , 1.5 , true ) ,
if ( degSpec . isNotEmpty ) ( degSpec , 1.05 , false ) ,
if ( date . isNotEmpty ) ( date , 1.0 , false ) ,
] ;
const topY = 4.0 , botY = 156.0 ;
final totalWeight = rows . fold < double > ( 0 , ( a , r ) = > a + r . $2 ) ;
double yy = topY ;
for ( final r in rows ) {
final rowH = ( botY - topY ) * r . $2 / totalWeight ;
final fs = _fitFont ( r . $1 , textMaxW , rowH * 0.84 , r . $3 , 30 ) ;
final ty = yy + ( rowH - fs * 1.25 ) / 2 ;
_drawText ( canvas , r . $1 , lx , ty < yy ? yy : ty , fs , textMaxW , bold: r . $3 ) ;
yy + = rowH ;
}
final img = await recorder . endRecording ( ) . toImage ( w , h ) ;
final bd = await img . toByteData ( format: ui . ImageByteFormat . rawRgba ) ;
final rgba = bd ! . buffer . asUint8List ( ) ;
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
const wbytes = ( w + 7 ) > > 3 ;
final out = BytesBuilder ( ) ;
out . add ( ascii . encode ( ' SIZE 40 mm,20 mm \r \n GAP 2 mm,0 mm \r \n DIRECTION 1 \r \n '
' REFERENCE 0,0 \r \n DENSITY 10 \r \n SPEED 2 \r \n CLS \r \n '
' BITMAP 0,0, $ wbytes , $ h ,1, ' ) ) ;
for ( int yy = 0 ; yy < h ; yy + + ) {
final row = Uint8List ( wbytes ) . . fillRange ( 0 , wbytes , 0xFF ) ; // 默认白 bit=1
for ( int xx = 0 ; xx < w ; xx + + ) {
final i = ( yy * w + xx ) * 4 ;
final a = rgba [ i + 3 ] ;
final lum = a < 128
? 255.0
: 0.299 * rgba [ i ] + 0.587 * rgba [ i + 1 ] + 0.114 * rgba [ i + 2 ] ;
if ( lum < 128 ) row [ xx > > 3 ] & = ~ ( 0x80 > > ( xx & 7 ) ) ; // 黑点 -> bit0
}
out . add ( row ) ;
}
out . add ( ascii . encode ( ' \r \n PRINT 1,1 \r \n ' ) ) ;
final tspl = out . toBytes ( ) ;
final f = File (
' ${ Directory . systemTemp . path } /label_ ${ DateTime . now ( ) . millisecondsSinceEpoch } .tspl ' ) ;
await f . writeAsBytes ( tspl , flush: true ) ;
debugPrint ( ' [label] flat tspl ${ tspl . length } B -> lp -d $ queue -o raw ${ f . path } ' ) ;
final r = await Process . run ( ' lp ' , [ ' -d ' , queue , ' -o ' , ' raw ' , f . path ] ) ;
debugPrint ( ' [label] lp exit= ${ r . exitCode } out= ${ r . stdout } err= ${ r . stderr } ' ) ;
if ( r . exitCode ! = 0 ) {
throw Exception ( ' lp 裸发失败(exit ${ r . exitCode } ): ${ r . stderr } ' ) ;
}
return true ;
}
Future < void > printProductLabelImpl ( {
required Uint8List qrBytes ,
@@ -68,19 +212,30 @@ Future<void> printProductLabelImpl({
final doc = pw . Document ( ) ;
final qrImage = pw . MemoryImage ( qrBytes ) ;
// Label: 4 × 2 inch
const labelW = 4.0 * PdfPageFormat . inch ;
const labelH = 2.0 * PdfPageFormat . inch ;
const headerH = labelH * 0.215 ;
const footerH = labelH * 0.09 ;
// Label: 40 × 20 mm(匹配实际标签纸尺寸)
const labelW = 40.0 * PdfPageFormat . mm ;
const labelH = 20.0 * PdfPageFormat . mm ;
const headerH = labelH * 0.20 ;
const footerH = labelH * 0.11 ;
final specVal = ( spec ? ? ' ' ) . isNotEmpty ? spec ! : ' — ' ;
final seriesVal = ( series ? ? ' ' ) . isNotEmpty ? series ! : ' — ' ;
final batchVal = ( batchNo ? ? ' ' ) . isNotEmpty ? batchNo ! : ' — ' ;
final specVal = ( spec ? ? ' ' ) . isNotEmpty ? spec ! : ' ' ;
final seriesVal = ( series ? ? ' ' ) . isNotEmpty ? series ! : ' ' ;
// 度数(系列) + 规格 同一行
final degSpecVal = [ seriesVal , specVal ] . where ( ( s ) = > s . isNotEmpty ) . join ( ' ' ) ;
final dateVal = ( productionDate ? ? ' ' ) . isNotEmpty
? ( productionDate ! . length > 10 ? productionDate . substring ( 0 , 10 ) : productionDate )
: ' — ' ;
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if ( await _printFlatLabelThermal (
shop: shopName ,
name: name ,
degSpec: degSpecVal ,
date: dateVal = = ' — ' ? ' ' : dateVal , // 无生产日期(商品详情)则不显示该行
qrBytes: qrBytes ) ) {
return ;
}
final contact = [
if ( shopAddress . isNotEmpty ) shopAddress ,
if ( shopPhone . isNotEmpty ) shopPhone ,
@@ -104,32 +259,22 @@ Future<void> printProductLabelImpl({
pw . Container (
height: headerH ,
color: _navy ,
padding: const pw . EdgeInsets . symmetric ( horizontal: 11 ) ,
child: pw . Row (
mainAxisAlignment: pw . MainAxisAlignment . spaceBetween ,
crossAxisAlignment : pw . CrossAxisAlignment . center ,
children: [
pw . Flexible (
child: pw . Text ( shopName ,
style: pw . TextStyle (
font: font , fontSize: 13 ,
fontWeight: pw . FontWeight . bold ,
color: _cream , letterSpacing: 1.5 ,
) ) ,
) ,
pw . Text ( ' Certificate of Authenticity ' ,
style: pw . TextStyle (
font: font , fontSize: 5 ,
color: const PdfColor ( 0.82 , 0.79 , 0.72 ) ) ) ,
] ,
) ,
padding: const pw . EdgeInsets . symmetric ( horizontal: 4 ) ,
alignment: pw . Alignment . centerLeft ,
child: pw . Text ( shopName ,
maxLines: 1 , overflow : pw . TextOverflow . clip ,
style: pw . TextStyle (
font: font , fontSize: 6 ,
fontWeight: pw . FontWeight . bold ,
color: _cream , letterSpacing: 0.5 ,
) ) ,
) ,
// ── Body ─────────────────────────────────────────────────────────────
pw . Expanded (
child: pw . Container (
color: PdfColors . white ,
padding: const pw . EdgeInsets . fromLTRB ( 11 , 8 , 11 , 6 ) ,
padding: const pw . EdgeInsets . fromLTRB ( 4 , 2 , 4 , 2 ) ,
child: pw . Row (
crossAxisAlignment: pw . CrossAxisAlignment . center ,
children: [
@@ -138,38 +283,41 @@ Future<void> printProductLabelImpl({
mainAxisAlignment: pw . MainAxisAlignment . center ,
crossAxisAlignment: pw . CrossAxisAlignment . start ,
children: [
// 商品名
// 商品名(过长自动缩小并最多两行)
pw . Text ( name ,
maxLines: 2 ,
overflow: pw . TextOverflow . clip ,
style: pw . TextStyle (
font: font , fontSize: 12 ,
font: font ,
fontSize: name . runes . length > 10
? 5.5
: ( name . runes . length > 7 ? 6.5 : 7.5 ) ,
fontWeight: pw . FontWeight . bold ,
color: _ink , letterSpacing: 0.5 ) ) ,
pw . SizedBox ( height: 6 ) ,
// 规格 + 系列
_label2ColRow ( font , ' 规 格 ' , specVal , ' 系 列 ' , seriesVal ) ,
pw . SizedBox ( height: 3 ) ,
// 批号 + 生产日期
_label2ColRow ( font , ' 批 号 ' , batchVal , ' 生产日期 ' , dateVal ) ,
if ( ( remark ? ? ' ' ) . isNotEmpty ) . . . [
pw . SizedBox ( height: 3 ) ,
_labelSpecRow ( font , ' 备 注 ' , remark ! ) ,
] ,
color: _ink , letterSpacing: 0.3 ) ) ,
pw . SizedBox ( height: 2.5 ) ,
// 度数(系列) + 规格 同一行, 无字段名标签
pw . Text ( degSpecVal ,
style: pw . TextStyle ( font: font , fontSize: 5.5 , color: _ink ) ) ,
pw . SizedBox ( height: 1.5 ) ,
// 生产日期(只显示日期,无标签)
pw . Text ( dateVal ,
style: pw . TextStyle ( font: font , fontSize: 5.5 , color: _ink ) ) ,
] ,
) ,
) ,
pw . SizedBox ( width: 8 ) ,
pw . SizedBox ( width: 4 ) ,
// QR
pw . Column (
mainAxisAlignment: pw . MainAxisAlignment . center ,
children: [
pw . SizedBox (
width: 60 , height: 60 ,
width: 38 , height: 38 ,
child: pw . Image ( qrImage , fit: pw . BoxFit . contain ) ,
) ,
pw . SizedBox ( height: 3 ) ,
pw . SizedBox ( height: 1 ) ,
pw . Text ( ' 扫码溯源 ' ,
style: pw . TextStyle (
font: font , fontSize: 4.5 ,
font: font , fontSize: 3.5 ,
color: PdfColors . grey600 ) ) ,
] ,
) ,
@@ -182,17 +330,18 @@ Future<void> printProductLabelImpl({
pw . Container (
height: footerH ,
color: _cream ,
padding: const pw . EdgeInsets . symmetric ( horizontal: 11 ) ,
padding: const pw . EdgeInsets . symmetric ( horizontal: 4 ) ,
child: pw . Row (
mainAxisAlignment: pw . MainAxisAlignment . spaceBetween ,
crossAxisAlignment: pw . CrossAxisAlignment . center ,
children: [
pw . Flexible (
child: pw . Text ( footerLeft ,
style: pw . TextStyle ( font: font , fontSize: 4 , co lor : _footnote ) ) ,
maxLines: 1 , overf low : pw . TextOverflow . clip ,
style: pw . TextStyle ( font: font , fontSize: 3 , color: _footnote ) ) ,
) ,
pw . Text ( genTime ,
style: pw . TextStyle ( font: font , fontSize: 4 , color: _footnote ) ) ,
style: pw . TextStyle ( font: font , fontSize: 3 , color: _footnote ) ) ,
] ,
) ,
) ,
@@ -207,42 +356,15 @@ Future<void> printProductLabelImpl({
await Future < void > . delayed ( Duration . zero ) ;
}
await Printing . layoutPdf (
name: ' 标签_ ${ _fileTs ( ) } ' , onLayout: ( _ ) async = > bytes ) ;
name: ' 标签_ ${ _fileTs ( ) } ' ,
dynamicLayout: false ,
onLayout: ( _ ) async = > bytes ) ;
} catch ( e , st ) {
reportError ( e , st ) ;
throw Exception ( ' 打印失败,请检查打印机连接和驱动是否正常。 \n 详情: $ e ' ) ;
}
}
pw . Widget _label2ColRow (
pw . Font font , String lbl1 , String val1 , String lbl2 , String val2 ) {
return pw . Row (
children: [
pw . SizedBox (
width: 28 ,
child: pw . Text ( lbl1 ,
style: pw . TextStyle ( font: font , fontSize: 5.5 , color: _muted ) ) ,
) ,
pw . SizedBox ( width: 3 ) ,
pw . Expanded (
child: pw . Text ( val1 ,
style: pw . TextStyle ( font: font , fontSize: 5.5 , color: _ink ) ) ,
) ,
pw . SizedBox ( width: 6 ) ,
pw . SizedBox (
width: 28 ,
child: pw . Text ( lbl2 ,
style: pw . TextStyle ( font: font , fontSize: 5.5 , color: _muted ) ) ,
) ,
pw . SizedBox ( width: 3 ) ,
pw . Expanded (
child: pw . Text ( val2 ,
style: pw . TextStyle ( font: font , fontSize: 5.5 , color: _ink ) ) ,
) ,
] ,
) ;
}
pw . Widget _buildOrderDoc ( {
required pw . Font font ,
required pw . Font bold ,
@@ -452,7 +574,9 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
await Future < void > . delayed ( Duration . zero ) ;
}
await Printing . layoutPdf (
name: ' 入库单_ ${ _fileTs ( ) } ' , onLayout: ( _ ) async = > bytes ) ;
name: ' 入库单_ ${ _fileTs ( ) } ' ,
dynamicLayout: false ,
onLayout: ( _ ) async = > bytes ) ;
} catch ( e , st ) {
reportError ( e , st ) ;
throw Exception ( ' 打印失败,请检查打印机连接和驱动是否正常。 \n 详情: $ e ' ) ;
@@ -512,7 +636,9 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
await Future < void > . delayed ( Duration . zero ) ;
}
await Printing . layoutPdf (
name: ' 出库单_ ${ _fileTs ( ) } ' , onLayout: ( _ ) async = > bytes ) ;
name: ' 出库单_ ${ _fileTs ( ) } ' ,
dynamicLayout: false ,
onLayout: ( _ ) async = > bytes ) ;
} catch ( e , st ) {
reportError ( e , st ) ;
throw Exception ( ' 打印失败,请检查打印机连接和驱动是否正常。 \n 详情: $ e ' ) ;