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 列,单个超宽字符仍独占一行。 */