diff --git a/.gitignore b/.gitignore index 3a4fc14..0ec1481 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ inventory-web/*.local *.log pgdata_docker/ inventory-backend/uploads/ +.aider* diff --git a/inventory-backend/app/services/print/label_service.py b/inventory-backend/app/services/print/label_service.py index f08476a..f4b0d7d 100644 --- a/inventory-backend/app/services/print/label_service.py +++ b/inventory-backend/app/services/print/label_service.py @@ -4,12 +4,11 @@ import os from io import BytesIO from PIL import Image, ImageDraw, ImageFont -# 引入条形码生成库 +# 引入二维码生成库 try: - import barcode - from barcode.writer import ImageWriter + import qrcode except ImportError: - print("❌ 警告: 未安装 python-barcode 库,无法生成真实条形码。请执行: pip install python-barcode") + print("❌ 警告: 未安装 qrcode 库,无法生成二维码。请执行: pip install qrcode[pil]") class LabelPrintService: @@ -25,53 +24,65 @@ class LabelPrintService: LABEL_WIDTH = int(LABEL_WIDTH_MM * DOTS_PER_MM) LABEL_HEIGHT = int(LABEL_HEIGHT_MM * DOTS_PER_MM) - # 顶部留白 - TOP_MARGIN_MM = 1.5 - TOP_MARGIN_PX = int(TOP_MARGIN_MM * DOTS_PER_MM) + # ================= 2. 布局配置 ================= + MARGIN_LEFT = int(2 * DOTS_PER_MM) # 左边距 2mm + MARGIN_RIGHT = int(1 * DOTS_PER_MM) # 右边距 1mm + TOP_MARGIN = int(5 * DOTS_PER_MM) # 顶部边距 2mm - # 定义左边距 (3mm) - 用于正文左对齐 - MARGIN_LEFT = int(3 * DOTS_PER_MM) - # 定义右边距 (防止文字贴边,留 2mm) - MARGIN_RIGHT = int(2 * DOTS_PER_MM) + # 二维码尺寸 15mm * 15mm + QR_SIZE_MM = 15 + QR_SIZE_PX = int(QR_SIZE_MM * DOTS_PER_MM) # 180px - # 计算文字允许的最大像素宽度 - MAX_TEXT_WIDTH = LABEL_WIDTH - MARGIN_LEFT - MARGIN_RIGHT + # 左右分栏的间距 + GAP_COLUMNS = int(2 * DOTS_PER_MM) # 2mm 间距 @staticmethod def _get_font(size): - """获取字体""" - # 尝试加载中文字体,否则乱码 - font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf"] - base_dirs = [os.getcwd(), os.path.dirname(__file__)] + """获取字体 (优先使用黑体/微软雅黑)""" + font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf", "NotoSansCJK-Regular.ttc"] + base_dirs = [os.getcwd(), os.path.dirname(__file__), "/usr/share/fonts", "C:\\Windows\\Fonts"] for d in base_dirs: for name in font_names: path = os.path.join(d, name) if os.path.exists(path): - return ImageFont.truetype(path, size) + try: + return ImageFont.truetype(path, size) + except: + continue return ImageFont.load_default() @staticmethod - def _generate_barcode_image(content, width_px, height_px): - """生成真实的条形码图片""" + def _generate_qr_image(content, size_px): + """生成指定像素大小的二维码""" try: - if not content: content = "0000000000" - code128 = barcode.get('code128', content, writer=ImageWriter()) - buffer = BytesIO() - code128.write(buffer, options={"write_text": False, "module_height": 10.0, "quiet_zone": 1.0}) - buffer.seek(0) - bc_img = Image.open(buffer) - return bc_img.resize((width_px, height_px), Image.Resampling.LANCZOS) + if not content: content = "000000" + + # 创建二维码对象 + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=0, + ) + qr.add_data(content) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # [重要] 必须转为 RGB 模式 + img = img.convert('RGB') + + # 调整为指定像素大小 + return img.resize((size_px, size_px), Image.Resampling.LANCZOS) except Exception as e: - print(f"条形码生成失败: {e}") - return Image.new('RGB', (width_px, height_px), color='black') + print(f"二维码生成失败: {e}") + return Image.new('RGB', (size_px, size_px), color='gray') @staticmethod - def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0): + def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0, stroke_width=1): """ [核心功能] 自动换行绘制文本 - :param line_spacing: 行与行之间的额外像素距离 - :return: 绘制结束后的 Y 坐标 """ if not text: return y @@ -79,36 +90,36 @@ class LabelPrintService: lines = [] current_line = "" - # 1. 计算折行逻辑 + # 计算折行 for char in text: - # 预测加入新字符后的宽度 test_line = current_line + char width = font.getlength(test_line) if width <= max_width: current_line = test_line else: - # 宽度超出,将当前行存入,新字符作为下一行开头 - lines.append(current_line) + if current_line: lines.append(current_line) current_line = char - # 将最后剩余的内容加入 if current_line: lines.append(current_line) - # 2. 绘制每一行 + # 绘制 current_y = y - font_height = font.size # 获取字号高度 + font_height = font.size for line in lines: - # 边界检查:如果超出图片高度,停止绘制 if current_y + font_height > LabelPrintService.LABEL_HEIGHT: break - # 绘制文字 (stroke_width=1 加粗) - draw.text((x, current_y), line, font=font, fill='black', stroke_width=1, stroke_fill='black') - - # 更新 Y 坐标 (字高 + 行间距) + draw.text( + (x, current_y), + line, + font=font, + fill='black', + stroke_width=stroke_width, # 支持动态调整粗细 + stroke_fill='black' + ) current_y += font_height + line_spacing return current_y @@ -117,121 +128,144 @@ class LabelPrintService: def _create_image_object(data): """ [绘图层] 生成标签图片 + 新布局逻辑: + --------------------------------------- + | [QR Code] (15mm) | 名: XXXXXX | + | | 规: XXXXXX | + | SKU: XXXXX(大/粗)| 属: XXXXXX | + | 库: XXXXX (中/粗)| SN: XXXXXX | + --------------------------------------- """ # 1. 创建画布 img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white') d = ImageDraw.Draw(img) - # 2. 字体配置 - # [保持] 正文内容维持 24号,以节省空间 - font_body = LabelPrintService._get_font(24) - # [修改] 编码(SKU)字体设置为 30号 - font_code = LabelPrintService._get_font(30) + # 2. 字体配置 (字号再次加大) + # [修改] 通用字体加大到 28 + font_text = LabelPrintService._get_font(28) + # [修改] SKU字体加大到 34 (特大) + font_sku = LabelPrintService._get_font(34) # 3. 数据准备 - sku_code = data.get('sku') - if not sku_code: - sku_code = data.get('serial_number') or str(data.get('global_print_id', '0000000000')).zfill(10) + sku_code = str(data.get('sku') or data.get('serial_number') or '000000') - # ==================== 绘制布局 ==================== - - GLOBAL_OFFSET_X = LabelPrintService.MARGIN_LEFT - CURRENT_Y = LabelPrintService.TOP_MARGIN_PX - - # --- A. 绘制条形码 (居中) --- - bc_w = int(37 * LabelPrintService.DOTS_PER_MM) - bc_h = int(8 * LabelPrintService.DOTS_PER_MM) # 高度 - - bc_img = LabelPrintService._generate_barcode_image(sku_code, bc_w, bc_h) - - # [修改核心] 计算条形码的居中 X 坐标 - # 公式:(标签总宽 - 条码宽) / 2 - bc_x_centered = (LabelPrintService.LABEL_WIDTH - bc_w) // 2 - - img.paste(bc_img, (bc_x_centered, CURRENT_Y)) - - # --- B. 绘制条形码下方数字 (居中 + 30号字) --- - text_y_pos = CURRENT_Y + bc_h + 2 - - # [修改核心] 计算文字宽度 并 居中 - text_width = font_code.getlength(sku_code) - text_x_centered = (LabelPrintService.LABEL_WIDTH - text_width) // 2 - - d.text( - (text_x_centered, text_y_pos), - sku_code, - font=font_code, # 使用30号字体 - fill='black', - stroke_width=1, - stroke_fill='black' - ) - - # 更新 Y 坐标,准备开始绘制正文 - # 30(字高) + 4(间距) - CURRENT_Y = text_y_pos + 30 + 4 - - # --- C. 绘制其余信息 (保持左对齐 + 24号字 + 自动换行) --- - - # 1. 准备完整文本 name = str(data.get('material_name', '') or '-') spec = str(data.get('spec_model', '') or '-') loc = str(data.get('warehouse_loc', '') or '-') cat = str(data.get('category', '') or '') typ = str(data.get('material_type', '') or '') - attr = f"{cat}/{typ}" + attr = f"{cat}/{typ}" if (cat or typ) else "-" - # 底部文字 - bottom_text = "" + # 底部编号逻辑 + bottom_val = "" + bottom_label = "NO" if data.get('print_no'): - val = str(data.get('print_no')) - label_type = data.get('print_label', '') - bottom_text = f"{'SN' if label_type == '序' else 'BN' if label_type == '批' else 'NO'}: {val}" + bottom_val = str(data.get('print_no')) + l_type = data.get('print_label', '') + bottom_label = 'SN' if l_type == '序' else 'BN' if l_type == '批' else 'NO' elif data.get('serial_number'): - bottom_text = f"SN: {data.get('serial_number')}" + bottom_label = "SN" + bottom_val = str(data.get('serial_number')) elif data.get('batch_number'): - bottom_text = f"BN: {data.get('batch_number')}" + bottom_label = "BN" + bottom_val = str(data.get('batch_number')) else: - bottom_text = f"NO: {sku_code}" + bottom_val = sku_code - # 2. 依次调用自动换行绘制函数 (使用正文字体 font_body,且坐标使用 GLOBAL_OFFSET_X) + bottom_text_full = f"{bottom_label}:{bottom_val}" - # 绘制名称 - CURRENT_Y = LabelPrintService.draw_text_wrap( - d, f"名: {name}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 + # ==================== 绘制区域划分 ==================== + + # --- A. 左侧区域 (二维码 + SKU + 库位) --- + qr_x = LabelPrintService.MARGIN_LEFT + qr_y = LabelPrintService.TOP_MARGIN + + # 1. 绘制二维码 + qr_img = LabelPrintService._generate_qr_image(sku_code, LabelPrintService.QR_SIZE_PX) + img.paste(qr_img, (qr_x, qr_y)) + + # 计算中心点,用于 SKU 和 库位 居中 + qr_center_x = qr_x + (LabelPrintService.QR_SIZE_PX // 2) + + # 2. 绘制 SKU (特大 + 特粗) + # 位于二维码下方,留 6px 间距 + current_left_y = qr_y + LabelPrintService.QR_SIZE_PX + 6 + + sku_w = font_sku.getlength(sku_code) + sku_x = int(qr_center_x - (sku_w // 2)) + if sku_x < 2: sku_x = 2 # 边界保护 + + d.text( + (sku_x, current_left_y), + sku_code, + font=font_sku, + fill='black', + stroke_width=2, # [修改] SKU 增加到 2px 描边,更粗 + stroke_fill='black' ) - CURRENT_Y += 2 - # 绘制规格 - CURRENT_Y = LabelPrintService.draw_text_wrap( - d, f"规: {spec}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 + # 3. 绘制 库位 (放在 SKU 下方) + # 位于 SKU 下方,留 6px 间距 + current_left_y += 34 + 6 # 34是字号大致高度 + + loc_text = f"库:{loc}" + loc_w = font_text.getlength(loc_text) + loc_x = int(qr_center_x - (loc_w // 2)) + if loc_x < 2: loc_x = 2 + + d.text( + (loc_x, current_left_y), + loc_text, + font=font_text, + fill='black', + stroke_width=1, # 普通加粗 + stroke_fill='black' ) - CURRENT_Y += 2 - # 绘制库位 - CURRENT_Y = LabelPrintService.draw_text_wrap( - d, f"库: {loc}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 + # --- B. 右侧区域 (名称、规格、属性、编号) --- + + # 右侧起始 X + right_start_x = LabelPrintService.MARGIN_LEFT + LabelPrintService.QR_SIZE_PX + LabelPrintService.GAP_COLUMNS + # 右侧最大宽度 + right_max_width = LabelPrintService.LABEL_WIDTH - right_start_x - LabelPrintService.MARGIN_RIGHT + + current_right_y = LabelPrintService.TOP_MARGIN + + # [修改] 增大行间距 line_spacing=8 + LINE_SPACING = 8 + + # 1. 名称 + current_right_y = LabelPrintService.draw_text_wrap( + d, f"名:{name}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING ) - CURRENT_Y += 2 + current_right_y += LINE_SPACING - # 绘制属性 - CURRENT_Y = LabelPrintService.draw_text_wrap( - d, f"属: {attr}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 + # 2. 规格 + current_right_y = LabelPrintService.draw_text_wrap( + d, f"规:{spec}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING ) - CURRENT_Y += 2 + current_right_y += LINE_SPACING - # 绘制底部编号 - CURRENT_Y = LabelPrintService.draw_text_wrap( - d, bottom_text, GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2 + # 3. 属性 + current_right_y = LabelPrintService.draw_text_wrap( + d, f"属:{attr}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING + ) + current_right_y += LINE_SPACING + + # 4. 序列号/批号 + LabelPrintService.draw_text_wrap( + d, bottom_text_full, right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING ) return img @staticmethod def generate_preview_image(data): + """生成 Base64 预览图""" img = LabelPrintService._create_image_object(data) output_buffer = BytesIO() - img.save(output_buffer, format='JPEG') + img.save(output_buffer, format='JPEG', quality=95) base64_str = base64.b64encode(output_buffer.getvalue()).decode('utf-8') return f"data:image/jpeg;base64,{base64_str}" @@ -241,12 +275,21 @@ class LabelPrintService: port = LabelPrintService.PRINTER_PORT try: + # 1. 获取 RGB 图像 img_rgb = LabelPrintService._create_image_object(data) + + # 2. 转换为灰度 img_gray = img_rgb.convert('L') - img_bw = img_gray.convert('1', dither=Image.Dither.NONE) + + # 3. 二值化处理 + img_bw = img_gray.point(lambda x: 0 if x < 128 else 255, '1') + + # 4. 生成打印指令 bitmap_data = img_bw.tobytes() width_bytes = (img_bw.width + 7) // 8 height_dots = img_bw.height + + # TSPL 协议头 header = ( f"SIZE {LabelPrintService.LABEL_WIDTH_MM} mm, {LabelPrintService.LABEL_HEIGHT_MM} mm\r\n" "GAP 2 mm, 0 mm\r\n" @@ -254,8 +297,12 @@ class LabelPrintService: "DIRECTION 1\r\n" "REFERENCE 0, 0\r\n" ).encode('gbk') + + # 位图指令 bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode('gbk') footer = b"\r\nPRINT 1,1\r\n" + + # 5. 发送 socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(5) s.connect((ip, port)) @@ -264,4 +311,8 @@ class LabelPrintService: return True except Exception as e: print(f"❌ 打印异常: {e}") - raise Exception(f"打印机连接失败: {str(e)}") \ No newline at end of file + raise Exception(f"打印机连接失败: {str(e)}") + + +if __name__ == "__main__": + pass \ No newline at end of file diff --git a/inventory-backend/requirements.txt b/inventory-backend/requirements.txt index bc62a0c..f3a43a7 100644 --- a/inventory-backend/requirements.txt +++ b/inventory-backend/requirements.txt @@ -6,7 +6,11 @@ marshmallow-sqlalchemy==1.0.0 psycopg2-binary==2.9.9 python-dotenv==1.0.0 flask-cors==4.0.0 +# 图片处理核心库 Pillow>=10.0.0 +# [旧] 条形码生成库 (建议保留,防止旧代码报错) python-barcode>=0.14.0 +# [新增] 二维码生成库 (标签打印必需,包含PIL支持) +qrcode[pil]>=7.4.2 # [新增] 必须添加,用于处理 token 登录 Flask-JWT-Extended==4.6.0 \ No newline at end of file diff --git a/inventory-web/src/components/QrScanner/index.vue b/inventory-web/src/components/QrScanner/index.vue index 49460f0..9987ecf 100644 --- a/inventory-web/src/components/QrScanner/index.vue +++ b/inventory-web/src/components/QrScanner/index.vue @@ -6,7 +6,7 @@