From 5c47704e094fa47021ccb31c748ef6d8a6f939c1 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Mon, 21 Sep 2026 10:01:00 +0800
Subject: [PATCH] =?UTF-8?q?fix:=E5=91=BD=E4=BB=A4=E8=A1=8C=E6=89=93?=
=?UTF-8?q?=E5=8D=B0=E8=B0=83=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../hard/api/utils/EscPosReceiptCommand.java | 202 +++++++++++++++---
.../api/utils/EscPosReceiptCommandTest.java | 39 +++-
2 files changed, 206 insertions(+), 35 deletions(-)
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
index 528431b94..0f351a294 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
@@ -12,15 +12,32 @@ import java.util.List;
* PNG、在指令模式产出 ESC/POS 字节流,终端按本机设置二选一直写 USB。指令模式跳过
* 图片栅格化与传输,出纸速度显著快于图片打印。
*
- * 排版约定:80mm 纸按 48 个 ANK 字符列计算(中文字符占 2 列);中文通过 ESC/POS
+ *
排版约定:80mm 纸按 48 个 ANK 字符列计算(中文和制表符占 2 列);中文通过 ESC/POS
* 汉字模式(FS & / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。
- * 输出内容不含初始化以外的走纸与切刀命令,末尾走纸和切刀由终端按本机配置统一包装。
+ * 图片版的矩形边框用 GBK 字库制表符(─ │ ┌ ┐ └ ┘)拼装,相邻字符字模相连成实线;
+ * 黑底白字标签用 ESC { 反色指令输出。输出内容不含初始化以外的走纸与切刀命令,
+ * 末尾走纸和切刀由终端按本机配置统一包装。
*/
public class EscPosReceiptCommand implements ReceiptRenderer {
/** 80mm 纸标准 ANK 字符列数,与终端 EscGenerator 的 80mm 每行列数一致。 */
public static final int CHARS_PER_LINE = 48;
+ /** 制表符矩形框内部内容列数:两侧竖线各占 2 列。 */
+ private static final int BOX_INNER_WIDTH = CHARS_PER_LINE - 4;
+
+ /** 矩形框横线字符。 */
+ private static final String BOX_HORIZONTAL = "─";
+
+ /** 矩形框竖线字符。 */
+ private static final String BOX_VERTICAL = "│";
+
+ /** 矩形框四角字符。 */
+ private static final String BOX_TOP_LEFT = "┌";
+ private static final String BOX_TOP_RIGHT = "┐";
+ private static final String BOX_BOTTOM_LEFT = "└";
+ private static final String BOX_BOTTOM_RIGHT = "┘";
+
/** ESC/POS 左对齐参数值。 */
private static final int ALIGN_LEFT = 0;
@@ -30,7 +47,7 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
/** ESC/POS 右对齐参数值。 */
private static final int ALIGN_RIGHT = 2;
- /** GBK 字符集;绝大多数国产热敏打印机默认按 GBK 解析中文。 */
+ /** GBK 字符集;绝大多数国产热敏打印机默认按 GBK 解析中文与制表符。 */
private static final Charset GBK = Charset.forName("GBK");
/** 指令字节累积缓冲;toBase64 前不做任何 IO。 */
@@ -53,13 +70,18 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
}
/**
- * 渲染上下横线包围的居中加粗提示,近似图片模式的描边横幅。
+ * 渲染制表符描边矩形框包围的居中加粗提示,还原图片模式的横幅边框。
*/
@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);
+ List lines = wrapByWidth(text == null ? "" : text, BOX_INNER_WIDTH);
+ printBoxTop();
+ for (String line : lines) {
+ int pad = BOX_INNER_WIDTH - displayWidth(line);
+ int left = pad / 2;
+ boxLine(" ".repeat(left) + line + " ".repeat(pad - left), true);
+ }
+ printBoxBottom();
return this;
}
@@ -101,18 +123,19 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
}
/**
- * 渲染合并批次中单笔订单摘要:标签与订单号同行加粗,日期餐段随行,订单间短线分隔。
+ * 渲染合并批次中单笔订单摘要:反色黑底标签与订单号同行加粗,日期餐段随行,订单间制表符短线分隔。
*/
@Override
public EscPosReceiptCommand voucherOrderSummary(int index, int total, String orderNo,
- String orderDate, String mealName) {
+ String orderDate, String mealName) {
int safeTotal = Math.max(1, total);
int safeIndex = Math.min(Math.max(1, index), safeTotal);
- String tag = "订单 " + safeIndex + "/" + safeTotal;
+ // 前后各留一个空格做黑底边距,与图片模式的黑底标签一致。
+ String tag = " 订单 " + safeIndex + "/" + safeTotal + " ";
String safeOrderNo = orderNo == null || orderNo.isBlank() ? "--" : orderNo;
int orderCapacity = CHARS_PER_LINE - displayWidth(tag) - 2;
List orderLines = wrapByWidth(safeOrderNo, Math.max(8, orderCapacity));
- printTwoColumns(tag, orderLines.get(0), true);
+ printTagTrailing(tag, orderLines.get(0));
for (int lineIndex = 1; lineIndex < orderLines.size(); lineIndex++) {
printLine(" ".repeat(displayWidth(tag) + 2) + orderLines.get(lineIndex), ALIGN_LEFT, true, 1);
}
@@ -122,7 +145,7 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
printLine(line, ALIGN_LEFT, false, 1);
}
if (safeIndex < safeTotal) {
- printLine("-".repeat(32), ALIGN_LEFT, false, 1);
+ printLine(BOX_HORIZONTAL.repeat(16), ALIGN_LEFT, false, 1);
}
return this;
}
@@ -154,52 +177,56 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, int quantity, String unitPrice, String subtotal,
- boolean combo, List componentItems) {
+ boolean combo, List componentItems) {
return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
- * 渲染兑换券商品卡片:类型标签与名称加粗同行,单价数量与小计两栏,组合内容缩进说明。
+ * 渲染兑换券商品卡片:制表符矩形框包围,内部为反色类型标签、加粗名称、两栏价格和组合内容。
*
- * 图片模式以描边卡片区分商品;指令模式用商品块后的空行近似同样的视觉间隔。
+ * 与图片版卡片一一对应:框线对应描边矩形、反色标签对应黑底白字 tag、粗体名称对应大号商品名,
+ * 组合内容区保留内部短线与缩进组成项;卡片之间输出空行还原图片的卡片间距。
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
- boolean combo, List componentItems) {
- String typeLabel = combo ? "[组合商品]" : "[单品]";
+ boolean combo, List 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;
+ + " " + (quantity == null || quantity.isBlank() ? "x0" : quantity);
String subtotalText = "小计 ¥" + (subtotal == null || subtotal.isBlank() ? "--" : subtotal);
- int nameCapacity = CHARS_PER_LINE - displayWidth(typeLabel) - 1;
- List 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);
+ printBoxTop();
+ boxLineFragments(List.of(new Segment(typeLabel, true)), false);
+ for (String nameLine : wrapByWidth(safeName, BOX_INNER_WIDTH)) {
+ boxLine(nameLine, true);
}
- printTwoColumns(priceText, subtotalText, false);
+ boxLineTwoColumns(priceText, subtotalText, false);
List safeComponents = componentItems == null ? List.of() : componentItems.stream()
.filter(item -> item != null && !item.isBlank()).toList();
if (combo && !safeComponents.isEmpty()) {
- printLine("组合内容", ALIGN_LEFT, true, 1);
+ boxLine(" " + BOX_HORIZONTAL.repeat(14) + " ", false);
+ boxLine("组合内容", true);
for (String component : safeComponents) {
- printLine(" - " + component, ALIGN_LEFT, false, 1);
+ for (String componentLine : wrapByWidth(" - " + component, BOX_INNER_WIDTH)) {
+ boxLine(componentLine, false);
+ }
}
}
+ printBoxBottom();
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);
+ printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 2), ALIGN_LEFT, false, 1);
String label = "商品合计";
String amount = "¥" + safeAmount;
if (displayWidth(label) + displayWidth(amount) <= CHARS_PER_LINE / 2) {
@@ -207,16 +234,16 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
} else {
printTwoColumns(label, amount, true);
}
- printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 2), ALIGN_LEFT, false, 1);
return this;
}
/**
- * 渲染整行横线分隔。
+ * 渲染整行制表符实线分隔。
*/
@Override
public EscPosReceiptCommand divider() {
- printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 2), ALIGN_LEFT, false, 1);
return this;
}
@@ -229,6 +256,83 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
return java.util.Base64.getEncoder().encodeToString(buffer.toByteArray());
}
+ /**
+ * 渲染矩形框顶边。
+ */
+ private void printBoxTop() {
+ printLine(BOX_TOP_LEFT + BOX_HORIZONTAL.repeat(BOX_INNER_WIDTH / 2) + BOX_TOP_RIGHT,
+ ALIGN_LEFT, false, 1);
+ }
+
+ /**
+ * 渲染矩形框底边。
+ */
+ private void printBoxBottom() {
+ printLine(BOX_BOTTOM_LEFT + BOX_HORIZONTAL.repeat(BOX_INNER_WIDTH / 2) + BOX_BOTTOM_RIGHT,
+ ALIGN_LEFT, false, 1);
+ }
+
+ /**
+ * 渲染框内单行内容:竖线 + 左对齐内容填充到框宽 + 竖线。
+ *
+ * @param content 已拼接的框内文本
+ * @param bold 是否加粗
+ */
+ private void boxLine(String content, boolean bold) {
+ boxLineFragments(List.of(new Segment(content, false)), bold);
+ }
+
+ /**
+ * 渲染框内含反色片段的单行:竖线 + 片段序列 + 空格填充到框宽 + 竖线。
+ *
+ * @param inner 框内片段序列
+ * @param bold 是否加粗
+ */
+ private void boxLineFragments(List inner, boolean bold) {
+ int innerWidth = 0;
+ List all = new ArrayList<>();
+ all.add(new Segment(BOX_VERTICAL, false));
+ all.addAll(inner);
+ for (Segment segment : inner) {
+ innerWidth += displayWidth(segment.text());
+ }
+ if (innerWidth < BOX_INNER_WIDTH) {
+ all.add(new Segment(" ".repeat(BOX_INNER_WIDTH - innerWidth), false));
+ }
+ all.add(new Segment(BOX_VERTICAL, false));
+ printFragments(all, ALIGN_LEFT, bold, 1);
+ }
+
+ /**
+ * 渲染框内两栏行:左栏左对齐、右栏右对齐;放不下时右栏折行右对齐。
+ */
+ private void boxLineTwoColumns(String left, String right, boolean bold) {
+ int gap = BOX_INNER_WIDTH - displayWidth(left) - displayWidth(right);
+ if (gap >= 1) {
+ boxLine(left + " ".repeat(gap) + right, bold);
+ return;
+ }
+ boxLine(left, bold);
+ for (String line : wrapByWidth(right, BOX_INNER_WIDTH)) {
+ int pad = BOX_INNER_WIDTH - displayWidth(line);
+ boxLine(" ".repeat(pad) + line, bold);
+ }
+ }
+
+ /**
+ * 渲染反色标签加同行右侧文本:标签黑底白字,右侧文本加粗右对齐。
+ */
+ private void printTagTrailing(String tag, String trailing) {
+ int gap = CHARS_PER_LINE - displayWidth(tag) - displayWidth(trailing);
+ List segments = new ArrayList<>();
+ segments.add(new Segment(tag, true));
+ if (gap >= 1) {
+ segments.add(new Segment(" ".repeat(gap), false));
+ }
+ segments.add(new Segment(trailing, false));
+ printFragments(segments, ALIGN_LEFT, true, 1);
+ }
+
/**
* 输出居中放大文本;放大倍数放不下时逐级降为 2 倍再 1 倍,保证单行完整展示。
*/
@@ -289,7 +393,8 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
}
/**
- * 输出单行文本:对齐、加粗、放大、汉字模式开、GBK 编码、换行、汉字模式关。
+ * 按片段序列输出单行文本:对齐、加粗、放大、汉字模式开、逐片段输出(反色片段用
+ * ESC { 包裹)、换行、汉字模式关。
*
* 每个样式指令在行内声明,行后仅复位放大与加粗,对齐由下一行重新声明,
* 保证任意指令流截断或拼接都不会让打印机停留在未知样式状态。
@@ -300,6 +405,18 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
* @param scale 放大倍数 1 单倍 2 双倍 3 三倍
*/
private void printLine(String text, int align, boolean bold, int scale) {
+ printFragments(List.of(new Segment(text, false)), align, bold, scale);
+ }
+
+ /**
+ * 按片段序列输出单行:对齐、加粗、放大、汉字模式开、GBK 输出、换行、汉字模式关。
+ *
+ * @param segments 行内片段序列;反色片段输出时被 ESC { 1 / ESC { 0 包裹
+ * @param align ESC a 对齐参数 0 左 1 中 2 右
+ * @param bold 是否加粗
+ * @param scale 放大倍数 1 单倍 2 双倍 3 三倍
+ */
+ private void printFragments(List segments, int align, boolean bold, int scale) {
writeRaw(0x1B, 0x61, align);
if (bold) {
writeRaw(0x1B, 0x45, 1);
@@ -309,7 +426,15 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
writeRaw(0x1D, 0x21, (scale << 4) | scale);
}
writeRaw(0x1C, 0x26);
- writeText(text);
+ for (Segment segment : segments) {
+ if (segment.reverse()) {
+ writeRaw(0x1B, 0x7B, 1);
+ }
+ writeText(segment.text());
+ if (segment.reverse()) {
+ writeRaw(0x1B, 0x7B, 0);
+ }
+ }
writeRaw('\n');
writeRaw(0x1C, 0x2E);
if (scale > 1) {
@@ -369,4 +494,13 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
if (line.length() > 0 || lines.isEmpty()) lines.add(line.toString());
return lines;
}
+
+ /**
+ * 行内文本片段;反色片段以黑底白字输出,用于还原图片模式的黑底标签。
+ *
+ * @param text 片段文本
+ * @param reverse 是否反色输出
+ */
+ private record Segment(String text, boolean reverse) {
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
index d583ef9f3..ef0fcec18 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
@@ -147,7 +147,44 @@ class EscPosReceiptCommandTest {
assertTrue(lines.stream().anyMatch(line -> line.contains("2096780000000000001")), "订单号缺失");
assertTrue(lines.stream().anyMatch(line -> line.contains("订餐日期:2026-09-07")), "订餐日期缺失");
assertTrue(lines.stream().anyMatch(line -> line.contains("餐段:午餐")), "餐段缺失");
- assertTrue(lines.stream().anyMatch(line -> "-".repeat(32).equals(line)), "订单间应有短线分隔");
+ assertTrue(lines.stream().anyMatch(line -> "─".repeat(16).equals(line)), "订单间应有制表符短线分隔");
+ }
+
+ /** 打印次数横幅应输出制表符描边矩形框,还原图片模式的横幅边框。 */
+ @Test
+ void bannerRendersBoxFrameAroundCenteredText() {
+ List lines = visibleLines(decode(new EscPosReceiptCommand().banner("第 1 次打印 · 2 笔预约")));
+
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("┌") && line.endsWith("┐")),
+ "横幅应有制表符顶边框");
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("└") && line.endsWith("┘")),
+ "横幅应有制表符底边框");
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("│") && line.endsWith("│")
+ && line.contains("第 1 次打印 · 2 笔预约")), "横幅文字应被竖线框包围且居中");
+ String contentLine = lines.stream()
+ .filter(line -> line.contains("第 1 次打印")).findFirst().orElse("");
+ assertEquals(EscPosReceiptCommand.CHARS_PER_LINE, EscPosReceiptCommand.displayWidth(contentLine),
+ "横幅内容行应铺满整行宽度保证竖线对齐");
+ }
+
+ /** 商品卡片应输出制表符矩形框与反色类型标签,还原图片模式的描边卡片和黑底白字 tag。 */
+ @Test
+ void voucherProductRendersBoxFrameWithReverseTypeTag() {
+ byte[] bytes = decode(new EscPosReceiptCommand()
+ .voucherProduct("A套餐-糖醋排骨", "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2")));
+ List lines = visibleLines(bytes);
+
+ assertTrue(contains(bytes, 0x1B, 0x7B, 1), "类型标签应使用 ESC { 反色指令");
+ assertTrue(contains(bytes, 0x1B, 0x7B, 0), "反色片段必须闭合");
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("┌") && line.endsWith("┐")),
+ "商品卡片应有制表符顶边框");
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("└") && line.endsWith("┘")),
+ "商品卡片应有制表符底边框");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("组合商品") && line.startsWith("│")),
+ "反色类型标签应在卡片框内");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("A套餐-糖醋排骨")),
+ "商品名称应在卡片内加粗展示");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("组合内容")), "组合内容标题缺失");
}
/** 显示宽度与折行:中文 2 列、ASCII 1 列,单个超宽字符仍独占一行。 */