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 0f351a294..c2d5e8479 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 @@ -15,8 +15,8 @@ import java.util.List; *

排版约定:80mm 纸按 48 个 ANK 字符列计算(中文和制表符占 2 列);中文通过 ESC/POS * 汉字模式(FS & / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。 * 图片版的矩形边框用 GBK 字库制表符(─ │ ┌ ┐ └ ┘)拼装,相邻字符字模相连成实线; - * 黑底白字标签用 ESC { 反色指令输出。输出内容不含初始化以外的走纸与切刀命令, - * 末尾走纸和切刀由终端按本机配置统一包装。

+ * 黑底白字标签用 GS B 反色指令输出。区块间距按图片版像素值(1px≈1 点)用压缩行距的 + * 空行实现。输出不含切刀和大段走纸命令,末尾走纸和切刀由终端按本机配置统一包装。

*/ public class EscPosReceiptCommand implements ReceiptRenderer { @@ -70,27 +70,26 @@ public class EscPosReceiptCommand implements ReceiptRenderer { } /** - * 渲染制表符描边矩形框包围的居中加粗提示,还原图片模式的横幅边框。 + * 渲染倍宽粗横线上下包围的居中倍宽加粗提示,还原图片模式 44px 大字横幅。 + * + *

图片版为四边描边框加特大字;指令模式放大到倍宽后文字已占满纸宽,无法再容纳 + * 左右竖线,以两条倍宽粗横线近似横幅上下边,横幅后按图片版留 14 点空隙。

*/ @Override public EscPosReceiptCommand banner(String text) { - 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(); + printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 4), ALIGN_LEFT, true, 2); + printCenteredScaled(text, true, 2); + printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 4), ALIGN_LEFT, true, 2); + feedGap(14); return this; } /** - * 渲染居中三倍加粗短码;超宽时降为双倍再单倍。 + * 渲染居中加粗放大短码;图片版为 48px 特大字,对应指令双倍宽度,超宽时逐级降为单倍。 */ @Override public EscPosReceiptCommand verificationCode(String shortCode) { - printCenteredScaled(shortCode == null || shortCode.isBlank() ? "--" : shortCode, true, 3); + printCenteredScaled(shortCode == null || shortCode.isBlank() ? "--" : shortCode, true, 2); return this; } @@ -182,25 +181,32 @@ public class EscPosReceiptCommand implements ReceiptRenderer { } /** - * 渲染兑换券商品卡片:制表符矩形框包围,内部为反色类型标签、加粗名称、两栏价格和组合内容。 + * 渲染兑换券商品卡片:制表符矩形框包围,内部为类型标签、倍宽加粗名称、两栏价格和组合内容。 * - *

与图片版卡片一一对应:框线对应描边矩形、反色标签对应黑底白字 tag、粗体名称对应大号商品名, - * 组合内容区保留内部短线与缩进组成项;卡片之间输出空行还原图片的卡片间距。

+ *

与图片版卡片逐项对应:框线对应描边矩形;组合商品标签为 GS B 反色黑底(对应图片 + * fillRect 黑底白字),单品标签为制表符描边小框(对应图片 drawRect 描边框);卡片内部 + * 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。

*/ @Override public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal, boolean combo, List componentItems) { - // 前后各留一个空格做黑底边距,与图片模式的类型标签一致。 - String typeLabel = combo ? " 组合商品 " : " 单品 "; String safeName = name == null || name.isBlank() ? "未命名商品" : name; String priceText = "单价 ¥" + (unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice) + " " + (quantity == null || quantity.isBlank() ? "x0" : quantity); String subtotalText = "小计 ¥" + (subtotal == null || subtotal.isBlank() ? "--" : subtotal); + // 行距压到字高附近(ESC 3 19 ≈ 27 点),竖线行间只留极小缝隙,视觉上连续。 + setLineSpacing(19); printBoxTop(); - boxLineFragments(List.of(new Segment(typeLabel, true)), false); - for (String nameLine : wrapByWidth(safeName, BOX_INNER_WIDTH)) { - boxLine(nameLine, true); + if (combo) { + boxLineFragments(List.of(new Segment(" 组合商品 ", true)), false); + } else { + printSingleTagFrame(" 单品 "); + } + // 商品名称倍宽加粗还原图片版 40px 特大字;竖线保持单倍细线,避免边框粗细交替。 + int scaledInner = CHARS_PER_LINE / 2 - 4; + for (String nameLine : wrapByWidth(safeName, scaledInner)) { + printScaledNameInBox(nameLine, scaledInner - displayWidth(nameLine)); } boxLineTwoColumns(priceText, subtotalText, false); @@ -216,12 +222,50 @@ public class EscPosReceiptCommand implements ReceiptRenderer { } } printBoxBottom(); - printLine("", ALIGN_LEFT, false, 1); + resetLineSpacing(); + // 卡片间距按图片版 14px 间隙走一个压缩行距空行。 + feedGap(14); return this; } /** - * 渲染上下制表符实线包围的商品合计;双倍宽度放不下时自动降为单倍。 + * 输出商品卡片内的倍宽加粗名称行:左右竖线保持单倍细线,仅名称文字片段放大加粗。 + * + *

GS ! 与 ESC E 是行内即时指令,竖线输出后再开、名称结束即关,卡片左右边框 + * 不随名称放大变粗,与上下框线粗细一致。

+ * + * @param nameLine 已按倍宽容量折行的名称文本 + * @param padColumns 名称后的倍宽空格填充列数,保证右竖线与上下框线对齐 + */ + private void printScaledNameInBox(String nameLine, int padColumns) { + writeRaw(0x1B, 0x61, ALIGN_LEFT); + writeRaw(0x1C, 0x26); + writeText(BOX_VERTICAL); + writeRaw(0x1D, 0x21, 0x11); + writeRaw(0x1B, 0x45, 1); + writeText(nameLine); + writeRaw(0x1B, 0x45, 0); + writeText(" ".repeat(Math.max(0, padColumns))); + writeRaw(0x1D, 0x21, 0x00); + writeText(BOX_VERTICAL); + writeRaw('\n'); + writeRaw(0x1C, 0x2E); + } + + /** + * 渲染单品类型标签的制表符描边小框(三行),还原图片版单品 tag 的描边矩形黑字。 + * + * @param tag 含前后空格的标签文本,空格保证框内留白 + */ + private void printSingleTagFrame(String tag) { + int frameWidth = displayWidth(tag) / 2; + boxLine(BOX_TOP_LEFT + BOX_HORIZONTAL.repeat(frameWidth) + BOX_TOP_RIGHT, false); + boxLine(BOX_VERTICAL + tag + BOX_VERTICAL, false); + boxLine(BOX_BOTTOM_LEFT + BOX_HORIZONTAL.repeat(frameWidth) + BOX_BOTTOM_RIGHT, false); + } + + /** + * 渲染上下制表符实线包围的商品合计;双倍宽度放不下时自动降为单倍,合计后按图片版留空隙。 */ @Override public EscPosReceiptCommand voucherTotal(String totalAmount) { @@ -235,11 +279,12 @@ public class EscPosReceiptCommand implements ReceiptRenderer { printTwoColumns(label, amount, true); } printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 2), ALIGN_LEFT, false, 1); + feedGap(14); return this; } /** - * 渲染整行制表符实线分隔。 + * 渲染整行制表符实线分隔;行距自带的行间空隙即视觉间隔,不再额外走纸。 */ @Override public EscPosReceiptCommand divider() { @@ -422,23 +467,24 @@ public class EscPosReceiptCommand implements ReceiptRenderer { writeRaw(0x1B, 0x45, 1); } if (scale > 1) { - // GS ! n:高 4 位为高度倍数、低 4 位为宽度倍数,宽度决定每行容量。 - writeRaw(0x1D, 0x21, (scale << 4) | scale); + // GS ! n:高 4 位为高度倍数、低 4 位为宽度倍数;半字节值 0x01 表示 2 倍,0x00 表示正常大小。 + writeRaw(0x1D, 0x21, ((scale - 1) << 4) | (scale - 1)); } writeRaw(0x1C, 0x26); for (Segment segment : segments) { if (segment.reverse()) { - writeRaw(0x1B, 0x7B, 1); + // GS B n 为黑白反转指令;ESC { 是字符倒置指令,不能用于反色。 + writeRaw(0x1D, 0x42, 1); } writeText(segment.text()); if (segment.reverse()) { - writeRaw(0x1B, 0x7B, 0); + writeRaw(0x1D, 0x42, 0); } } writeRaw('\n'); writeRaw(0x1C, 0x2E); if (scale > 1) { - writeRaw(0x1D, 0x21, 0x01); + writeRaw(0x1D, 0x21, 0x00); } if (bold) { writeRaw(0x1B, 0x45, 0); @@ -461,6 +507,45 @@ public class EscPosReceiptCommand implements ReceiptRenderer { } } + /** + * 输出与图片版像素间距对应的垂直空隙(图片 576px 内容宽与打印 576 点一一对应)。 + * + *

不使用 ESC d 点级走纸:实测部分国产打印机对其单位实现不标准,会放大数十倍导致 + * 出纸失控。此处改用所有机型都可靠支持的行距指令:临时把行距压到目标点数、走一个 + * 空行、立即恢复默认行距,单次空隙即目标像素值,不存在被放大的空间。

+ * + * @param pixels 图片版像素间距,0 至 60 + */ + private void feedGap(int pixels) { + if (pixels <= 0) { + return; + } + // 点数换算为 ESC 3 的 1/144 英寸单位(203dpi 下 1 单位约 1.41 点),最小 2 保证肉眼可见。 + int units = Math.max(2, Math.round(pixels * 144f / 203f)); + setLineSpacing(Math.min(255, units)); + writeRaw('\n'); + resetLineSpacing(); + } + + /** + * 设置行间距(ESC 3 n,单位 1/144 英寸,203dpi 下 n≈27 为字高加小缝)。 + * + *

默认行距(ESC 2 = 1/6 英寸 ≈ 34 点)会在相邻行间留下约 10 点空隙,导致卡片 + * 左右竖线视觉断线;卡片内部压缩行距后竖线行间衔接。

+ * + * @param units 行间距单位值 + */ + private void setLineSpacing(int units) { + writeRaw(0x1B, 0x33, units); + } + + /** + * 恢复默认行距(ESC 2 = 1/6 英寸)。 + */ + private void resetLineSpacing() { + writeRaw(0x1B, 0x32); + } + /** * 计算文本的显示列数:中文字符按 2 列、其余按 1 列,与打印机汉字模式宽度一致。 */ 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 ef0fcec18..16047ea5c 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 @@ -28,9 +28,9 @@ class EscPosReceiptCommandTest { assertTrue(bytes.length > 2, "指令流应包含初始化之后的内容指令"); } - /** 指令流不得包含切刀和走纸命令:末尾边界由终端按本机配置统一包装。 */ + /** 指令流不得包含切刀和成段走纸:末尾边界由终端按本机配置统一包装,ESC d 点级间距不算走纸命令。 */ @Test - void outputNeverContainsCutOrFeedPaperCommand() { + void outputNeverContainsCutCommand() { String base64 = new EscPosReceiptCommand() .title("预约核销兑换券") .banner("第 1 次打印 · 2 笔预约") @@ -44,7 +44,7 @@ class EscPosReceiptCommandTest { assertFalse(bytes[index] == 0x1D && bytes[index + 1] == 0x56, "指令流不应包含 GS V 切刀命令"); } - assertFalse(containsText(bytes, "\n\n\n"), "指令流不应含成段走纸"); + assertFalse(containsText(bytes, "\n\n\n"), "指令流不应含成段空行走纸"); } /** 每行文本都被汉字模式开启与关闭包裹,截断在任意行都不会让打印机停留在汉字状态。 */ @@ -108,10 +108,10 @@ class EscPosReceiptCommandTest { @Test void voucherTotalScalesShortAmountAndDegradesLongAmount() { byte[] shortAmount = decode(new EscPosReceiptCommand().voucherTotal("28.80")); - assertTrue(contains(shortAmount, 0x1D, 0x21, 0x22), "常规合计应使用双倍宽度 GS ! 0x22"); + assertTrue(contains(shortAmount, 0x1D, 0x21, 0x11), "常规合计应使用倍宽 GS ! 0x11"); byte[] longAmount = decode(new EscPosReceiptCommand().voucherTotal("12345678901234.50")); - assertFalse(contains(longAmount, 0x1D, 0x21, 0x22), "超长合计应降级为单倍宽度"); + assertFalse(contains(longAmount, 0x1D, 0x21, 0x11), "超长合计应降级为单倍宽度"); List lines = visibleLines(longAmount); assertTrue(lines.stream().anyMatch(line -> line.contains("¥12345678901234.50")), "降级后金额必须完整保留"); } @@ -150,32 +150,30 @@ class EscPosReceiptCommandTest { assertTrue(lines.stream().anyMatch(line -> "─".repeat(16).equals(line)), "订单间应有制表符短线分隔"); } - /** 打印次数横幅应输出制表符描边矩形框,还原图片模式的横幅边框。 */ + /** 打印次数横幅应输出倍宽粗横线与倍宽居中大字,还原图片模式 44px 横幅字号。 */ @Test - void bannerRendersBoxFrameAroundCenteredText() { - List lines = visibleLines(decode(new EscPosReceiptCommand().banner("第 1 次打印 · 2 笔预约"))); + void bannerRendersBoldRuleLinesWithScaledText() { + byte[] bytes = decode(new EscPosReceiptCommand().banner("第 1 次打印 · 2 笔预约")); + List lines = visibleLines(bytes); - 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), - "横幅内容行应铺满整行宽度保证竖线对齐"); + assertEquals(2, lines.stream().filter(line -> "─".repeat(12).equals(line)).count(), + "横幅上下应各有一条倍宽粗横线"); + assertTrue(lines.stream().anyMatch(line -> line.contains("第 1 次打印 · 2 笔预约")), + "横幅文字应完整保留"); + assertTrue(contains(bytes, 0x1D, 0x21, 0x11), "横幅文字与粗线应使用倍宽放大"); } - /** 商品卡片应输出制表符矩形框与反色类型标签,还原图片模式的描边卡片和黑底白字 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(contains(bytes, 0x1D, 0x42, 1), "组合类型标签应使用 GS B 反色指令"); + assertTrue(contains(bytes, 0x1D, 0x42, 0), "反色片段必须闭合"); + assertFalse(contains(bytes, 0x1B, 0x7B, 1), "不得误用 ESC { 倒置指令做反色"); + assertTrue(contains(bytes, 0x1D, 0x21, 0x11), "商品名称应使用倍宽放大还原图片版特大字"); assertTrue(lines.stream().anyMatch(line -> line.startsWith("┌") && line.endsWith("┐")), "商品卡片应有制表符顶边框"); assertTrue(lines.stream().anyMatch(line -> line.startsWith("└") && line.endsWith("┘")), @@ -183,7 +181,7 @@ class EscPosReceiptCommandTest { assertTrue(lines.stream().anyMatch(line -> line.contains("组合商品") && line.startsWith("│")), "反色类型标签应在卡片框内"); assertTrue(lines.stream().anyMatch(line -> line.contains("A套餐-糖醋排骨")), - "商品名称应在卡片内加粗展示"); + "商品名称应在卡片内倍宽加粗展示"); assertTrue(lines.stream().anyMatch(line -> line.contains("组合内容")), "组合内容标题缺失"); }