feat:取消系统自带软键盘设计,改为系统自用键盘组件-初步

feat:将打印分为图片打印和命令式打印-初步
This commit is contained in:
dzc committed 2026-09-20 18:24:46 +08:00
1 parent b1b67b4dc7
commit ddf2af76a5
9 files changed
+962 -60

No files matched your search

@@ -115,6 +115,13 @@ public class HardInvoiceEntity extends Model<HardInvoiceEntity> {
@Schema(description="打印图片")
private String pic;
/**
* 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节),供终端指令打印直写 USB。
* 历史任务和图片生成失败时为空,终端指令模式下回退 pic 图片打印。
*/
@Schema(description="打印指令流Base64")
private String printCmd;
/**
* 创建人
*/
@@ -0,0 +1,372 @@
package com.bsd.pjcloud.hard.api.utils;
import java.io.ByteArrayOutputStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
/**
* 面向 80mm 热敏打印机的 ESC/POS 文本指令拼装器,实现 {@link ReceiptRenderer}。
*
* <p>与 {@link ThermalReceiptImage} 保持同一套版式语义:相同的调用顺序在图片模式产出
* PNG、在指令模式产出 ESC/POS 字节流,终端按本机设置二选一直写 USB。指令模式跳过
* 图片栅格化与传输,出纸速度显著快于图片打印。</p>
*
* <p>排版约定:80mm 纸按 48 个 ANK 字符列计算(中文字符占 2 列);中文通过 ESC/POS
* 汉字模式(FS &amp; / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。
* 输出内容不含初始化以外的走纸与切刀命令,末尾走纸和切刀由终端按本机配置统一包装。</p>
*/
public class EscPosReceiptCommand implements ReceiptRenderer {
/** 80mm 纸标准 ANK 字符列数,与终端 EscGenerator 的 80mm 每行列数一致。 */
public static final int CHARS_PER_LINE = 48;
/** ESC/POS 左对齐参数值。 */
private static final int ALIGN_LEFT = 0;
/** ESC/POS 居中对齐参数值。 */
private static final int ALIGN_CENTER = 1;
/** ESC/POS 右对齐参数值。 */
private static final int ALIGN_RIGHT = 2;
/** GBK 字符集;绝大多数国产热敏打印机默认按 GBK 解析中文。 */
private static final Charset GBK = Charset.forName("GBK");
/** 指令字节累积缓冲;toBase64 前不做任何 IO。 */
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(2048);
/**
* 创建指令拼装器并输出 ESC @ 初始化,确保终端写入前打印机残留样式被复位。
*/
public EscPosReceiptCommand() {
writeRaw(0x1B, 0x40);
}
/**
* 渲染居中加粗放大标题;超宽时自动降为单倍宽度保证一行放下。
*/
@Override
public EscPosReceiptCommand title(String text) {
printCenteredScaled(text, true, 2);
return this;
}
/**
* 渲染上下横线包围的居中加粗提示,近似图片模式的描边横幅。
*/
@Override
public EscPosReceiptCommand banner(String text) {
printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
printCenteredScaled(text, true, 2);
printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
return this;
}
/**
* 渲染居中三倍加粗短码;超宽时降为双倍再单倍。
*/
@Override
public EscPosReceiptCommand verificationCode(String shortCode) {
printCenteredScaled(shortCode == null || shortCode.isBlank() ? "--" : shortCode, true, 3);
return this;
}
/**
* 渲染键值行;值放不下整行时标签独占一行,值从下一行起按整行宽度折行。
*/
@Override
public EscPosReceiptCommand keyValue(String label, String value) {
String safeLabel = (label == null ? "" : label) + ":";
String safeValue = value == null || value.isBlank() ? "--" : value;
int valueCapacity = CHARS_PER_LINE - displayWidth(safeLabel);
if (displayWidth(safeValue) <= valueCapacity) {
printLine(safeLabel + safeValue, ALIGN_LEFT, false, 1);
return this;
}
printLine(safeLabel, ALIGN_LEFT, false, 1);
for (String line : wrapByWidth(safeValue, CHARS_PER_LINE)) {
printLine(line, ALIGN_LEFT, false, 1);
}
return this;
}
/**
* 渲染左对齐加粗区块小标题。
*/
@Override
public EscPosReceiptCommand sectionTitle(String text) {
printLine(text == null ? "" : text, ALIGN_LEFT, true, 1);
return this;
}
/**
* 渲染合并批次中单笔订单摘要:标签与订单号同行加粗,日期餐段随行,订单间短线分隔。
*/
@Override
public EscPosReceiptCommand voucherOrderSummary(int index, int total, String orderNo,
String orderDate, String mealName) {
int safeTotal = Math.max(1, total);
int safeIndex = Math.min(Math.max(1, index), safeTotal);
String tag = "订单 " + safeIndex + "/" + safeTotal;
String safeOrderNo = orderNo == null || orderNo.isBlank() ? "--" : orderNo;
int orderCapacity = CHARS_PER_LINE - displayWidth(tag) - 2;
List<String> orderLines = wrapByWidth(safeOrderNo, Math.max(8, orderCapacity));
printTwoColumns(tag, orderLines.get(0), true);
for (int lineIndex = 1; lineIndex < orderLines.size(); lineIndex++) {
printLine(" ".repeat(displayWidth(tag) + 2) + orderLines.get(lineIndex), ALIGN_LEFT, true, 1);
}
String summary = "订餐日期:" + (orderDate == null || orderDate.isBlank() ? "--" : orderDate)
+ " 餐段:" + (mealName == null || mealName.isBlank() ? "--" : mealName);
for (String line : wrapByWidth(summary, CHARS_PER_LINE)) {
printLine(line, ALIGN_LEFT, false, 1);
}
if (safeIndex < safeTotal) {
printLine("-".repeat(32), ALIGN_LEFT, false, 1);
}
return this;
}
/**
* 渲染商品或套餐组成项:名称左、数量右,组成项缩进两列并加“-”前缀。
*/
@Override
public EscPosReceiptCommand item(String name, String quantity, boolean child, boolean emphasized) {
String prefix = child ? " - " : "";
String safeName = prefix + (name == null || name.isBlank() ? "未命名商品" : name);
String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity;
printNameTrailing(safeName, safeQuantity, 12, emphasized);
return this;
}
/**
* 渲染发票商品行:名称左、数量加金额摘要右。
*/
@Override
public EscPosReceiptCommand invoiceItem(String name, String quantity, String amount) {
String trailing = String.format("%s %s", quantity, amount);
printNameTrailing(name == null || name.isBlank() ? "未命名商品" : name, trailing, 20, false);
return this;
}
/**
* 渲染兑换券商品卡片,数量为整数份数。
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, int quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems) {
return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
* 渲染兑换券商品卡片:类型标签与名称加粗同行,单价数量与小计两栏,组合内容缩进说明。
*
* <p>图片模式以描边卡片区分商品;指令模式用商品块后的空行近似同样的视觉间隔。</p>
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems) {
String typeLabel = combo ? "[组合商品]" : "[单品]";
String safeName = name == null || name.isBlank() ? "未命名商品" : name;
String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity;
String priceText = "单价 ¥" + (unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice)
+ " " + safeQuantity;
String subtotalText = "小计 ¥" + (subtotal == null || subtotal.isBlank() ? "--" : subtotal);
int nameCapacity = CHARS_PER_LINE - displayWidth(typeLabel) - 1;
List<String> nameLines = wrapByWidth(safeName, nameCapacity);
printLine(typeLabel + " " + nameLines.get(0), ALIGN_LEFT, true, 1);
for (int lineIndex = 1; lineIndex < nameLines.size(); lineIndex++) {
printLine(" ".repeat(displayWidth(typeLabel) + 1) + nameLines.get(lineIndex), ALIGN_LEFT, true, 1);
}
printTwoColumns(priceText, subtotalText, false);
List<String> safeComponents = componentItems == null ? List.of() : componentItems.stream()
.filter(item -> item != null && !item.isBlank()).toList();
if (combo && !safeComponents.isEmpty()) {
printLine("组合内容", ALIGN_LEFT, true, 1);
for (String component : safeComponents) {
printLine(" - " + component, ALIGN_LEFT, false, 1);
}
}
printLine("", ALIGN_LEFT, false, 1);
return this;
}
/**
* 渲染上下横线包围的商品合计;双倍宽度放不下时自动降为单倍。
*/
@Override
public EscPosReceiptCommand voucherTotal(String totalAmount) {
String safeAmount = totalAmount == null || totalAmount.isBlank() ? "--" : totalAmount;
printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
String label = "商品合计";
String amount = "¥" + safeAmount;
if (displayWidth(label) + displayWidth(amount) <= CHARS_PER_LINE / 2) {
printTwoColumnsScaled(label, amount, 2);
} else {
printTwoColumns(label, amount, true);
}
printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
return this;
}
/**
* 渲染整行横线分隔。
*/
@Override
public EscPosReceiptCommand divider() {
printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
return this;
}
/**
* 输出 Base64 编码的完整指令流,供 MQTT 与打印待办接口下发给终端直写 USB。
*
* @return Base64 指令字符串;调用方直接持久化或下发,不需要再编码
*/
public String toBase64() {
return java.util.Base64.getEncoder().encodeToString(buffer.toByteArray());
}
/**
* 输出居中放大文本;放大倍数放不下时逐级降为 2 倍再 1 倍,保证单行完整展示。
*/
private void printCenteredScaled(String text, boolean bold, int scale) {
String safeText = text == null ? "" : text;
int actualScale = scale;
while (actualScale > 1 && displayWidth(safeText) > CHARS_PER_LINE / actualScale) {
actualScale--;
}
printLine(safeText, ALIGN_CENTER, bold, actualScale);
}
/**
* 输出名称左、摘要右的两栏明细;名称按剩余宽度折行,摘要始终在首行右对齐。
*
* @param name 左侧名称文本
* @param trailing 首行右对齐摘要
* @param trailingWidth 右侧预留列数
* @param bold 是否加粗
*/
private void printNameTrailing(String name, String trailing, int trailingWidth, boolean bold) {
List<String> lines = wrapByWidth(name, CHARS_PER_LINE - trailingWidth);
printTwoColumns(lines.get(0), trailing, bold);
for (int index = 1; index < lines.size(); index++) {
printLine(lines.get(index), ALIGN_LEFT, bold, 1);
}
}
/**
* 输出单倍宽度两栏文本;左右间距不足 1 列时右侧文本自动折到下一行右对齐。
*/
private void printTwoColumns(String left, String right, boolean bold) {
int gap = CHARS_PER_LINE - displayWidth(left) - displayWidth(right);
if (gap >= 1) {
printLine(left + " ".repeat(gap) + right, ALIGN_LEFT, bold, 1);
return;
}
printLine(left, ALIGN_LEFT, bold, 1);
for (String line : wrapByWidth(right, CHARS_PER_LINE)) {
printLine(line, ALIGN_RIGHT, bold, 1);
}
}
/**
* 输出双倍宽度两栏文本;倍宽后每行容量减半,放不下时右栏折行右对齐。
*/
private void printTwoColumnsScaled(String left, String right, int scale) {
int capacity = CHARS_PER_LINE / scale;
int gap = capacity - displayWidth(left) - displayWidth(right);
if (gap >= 1) {
printLine(left + " ".repeat(gap) + right, ALIGN_LEFT, true, scale);
return;
}
printLine(left, ALIGN_LEFT, true, scale);
for (String line : wrapByWidth(right, capacity)) {
printLine(line, ALIGN_RIGHT, true, scale);
}
}
/**
* 输出单行文本:对齐、加粗、放大、汉字模式开、GBK 编码、换行、汉字模式关。
*
* <p>每个样式指令在行内声明,行后仅复位放大与加粗,对齐由下一行重新声明,
* 保证任意指令流截断或拼接都不会让打印机停留在未知样式状态。</p>
*
* @param text 行文本
* @param align ESC a 对齐参数 0 左 1 中 2 右
* @param bold 是否加粗
* @param scale 放大倍数 1 单倍 2 双倍 3 三倍
*/
private void printLine(String text, int align, boolean bold, int scale) {
writeRaw(0x1B, 0x61, align);
if (bold) {
writeRaw(0x1B, 0x45, 1);
}
if (scale > 1) {
// GS ! n:高 4 位为高度倍数、低 4 位为宽度倍数,宽度决定每行容量。
writeRaw(0x1D, 0x21, (scale << 4) | scale);
}
writeRaw(0x1C, 0x26);
writeText(text);
writeRaw('\n');
writeRaw(0x1C, 0x2E);
if (scale > 1) {
writeRaw(0x1D, 0x21, 0x01);
}
if (bold) {
writeRaw(0x1B, 0x45, 0);
}
}
/**
* 追加文本的 GBK 字节;GBK 为 JDK 内置字符集,不会抛出编码异常。
*/
private void writeText(String text) {
buffer.writeBytes(text.getBytes(GBK));
}
/**
* 追加原始指令字节。
*/
private void writeRaw(int... bytes) {
for (int value : bytes) {
buffer.write(value);
}
}
/**
* 计算文本的显示列数:中文字符按 2 列、其余按 1 列,与打印机汉字模式宽度一致。
*/
static int displayWidth(String text) {
int width = 0;
for (int index = 0; index < text.length(); index++) {
width += text.charAt(index) > 0x7F ? 2 : 1;
}
return width;
}
/**
* 按显示列数折行;单个超宽字符仍至少独占一行,与图片绘制器的折行语义一致。
*/
static List<String> wrapByWidth(String text, int maxWidth) {
List<String> lines = new ArrayList<>();
StringBuilder line = new StringBuilder();
int lineWidth = 0;
String safeText = text == null ? "" : text;
for (int index = 0; index < safeText.length(); index++) {
char character = safeText.charAt(index);
int charWidth = character > 0x7F ? 2 : 1;
if (lineWidth > 0 && lineWidth + charWidth > maxWidth) {
lines.add(line.toString());
line.setLength(0);
lineWidth = 0;
}
line.append(character);
lineWidth += charWidth;
}
if (line.length() > 0 || lines.isEmpty()) lines.add(line.toString());
return lines;
}
}
@@ -0,0 +1,134 @@
package com.bsd.pjcloud.hard.api.utils;
import java.util.List;
/**
* 打印凭证版式渲染器契约,发票与兑换券的数据拼装只依赖本接口。
*
* <p>当前实现有两个:{@link ThermalReceiptImage} 渲染 576px PNG 图片供图片栅格打印;
* {@link EscPosReceiptCommand} 直接拼装 ESC/POS 文本指令供指令打印,出纸更快。
* 业务层用同一份调用序列驱动两个渲染器,保证两种输出方式的凭证内容始终一致。</p>
*
* <p>实现类的方法返回类型允许返回自身具体类型以保留链式调用(协变返回),拼接顺序
* 由调用方保证,本接口不承载状态查询。</p>
*/
public interface ReceiptRenderer {
/**
* 渲染居中大号标题,例如“智慧食堂发票”。
*
* @param text 标题文本
* @return 当前渲染器
*/
ReceiptRenderer title(String text);
/**
* 渲染醒目的整行提示,例如“第 1 次打印 · 2 笔预约”。
*
* @param text 提示文本
* @return 当前渲染器
*/
ReceiptRenderer banner(String text);
/**
* 渲染醒目的核销或取件短码。
*
* @param shortCode 短码文本;空值由实现决定占位展示
* @return 当前渲染器
*/
ReceiptRenderer verificationCode(String shortCode);
/**
* 渲染一行“字段名:字段值”信息;值过长时由实现负责折行。
*
* @param label 字段名
* @param value 字段值;空白由实现决定占位展示
* @return 当前渲染器
*/
ReceiptRenderer keyValue(String label, String value);
/**
* 渲染区块小标题,例如“用户信息”、“商品明细”。
*
* @param text 标题文本
* @return 当前渲染器
*/
ReceiptRenderer sectionTitle(String text);
/**
* 渲染多订单合并兑换券中的单笔订单摘要。
*
* @param index 当前订单在合并批次中的一号起始序号
* @param total 合并批次订单总数
* @param orderNo 对外展示的完整订单号
* @param orderDate 预约订餐日期
* @param mealName 餐段名称
* @return 当前渲染器
*/
ReceiptRenderer voucherOrderSummary(int index, int total, String orderNo,
String orderDate, String mealName);
/**
* 渲染商品或套餐组成项,数量在首行右对齐。
*
* @param name 商品名称
* @param quantity 数量文本
* @param child 是否为套餐组成项
* @param emphasized 是否加粗强调
* @return 当前渲染器
*/
ReceiptRenderer item(String name, String quantity, boolean child, boolean emphasized);
/**
* 渲染带数量和金额摘要的发票商品行。
*
* @param name 商品名称
* @param quantity 数量或重量文本
* @param amount 含货币符号的商品总价
* @return 当前渲染器
*/
ReceiptRenderer invoiceItem(String name, String quantity, String amount);
/**
* 渲染兑换券商品卡片,数量为整数份数。
*
* @param name 商品快照名称
* @param quantity 剩余可履约数量
* @param unitPrice 商品快照单价,单位元且不含货币符号
* @param subtotal 剩余可履约数量对应的小计,单位元且不含货币符号
* @param combo 是否为组合商品
* @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空
* @return 当前渲染器
*/
ReceiptRenderer voucherProduct(String name, int quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems);
/**
* 渲染兑换券商品卡片,数量为已格式化文本(份数“x2”或称重“0.5/kg”)。
*
* @param name 商品快照名称
* @param quantity 数量展示文本
* @param unitPrice 商品快照单价,单位元且不含货币符号
* @param subtotal 商品小计,单位元且不含货币符号
* @param combo 是否为组合商品
* @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空
* @return 当前渲染器
*/
ReceiptRenderer voucherProduct(String name, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems);
/**
* 渲染商品合计,形成票据金额层级。
*
* @param totalAmount 商品合计金额,单位元且不含货币符号
* @return 当前渲染器
*/
ReceiptRenderer voucherTotal(String totalAmount);
/**
* 渲染一条横向分隔线。
*
* @return 当前渲染器
*/
ReceiptRenderer divider();
}
@@ -17,8 +17,11 @@ import java.util.List;
* 面向 80mm、576px ESC/POS 打印的动态高度 PNG 绘制器。
*
* <p>商品名称按真实像素宽度换行,数量列固定右对齐,保证长名称不会与数量错配。</p>
*
* <p>实现 {@link ReceiptRenderer} 版式契约,方法返回自身具体类型保留链式调用;
* 指令打印由同包的 {@link EscPosReceiptCommand} 以相同调用序列产出 ESC/POS 字节。</p>
*/
public class ThermalReceiptImage {
public class ThermalReceiptImage implements ReceiptRenderer {
/** 80mm 打印头常见有效像素宽度。 */
public static final int PAPER_WIDTH_PIXELS = 576;