fix:命令行打印调试

This commit is contained in:
dzc committed 2026-09-21 10:01:00 +08:00
1 parent ddf2af76a5
commit 5c47704e09
2 files changed
+206 -35

No files matched your search

@@ -12,15 +12,32 @@ import java.util.List;
* PNG、在指令模式产出 ESC/POS 字节流,终端按本机设置二选一直写 USB。指令模式跳过
* 图片栅格化与传输,出纸速度显著快于图片打印。</p>
*
* <p>排版约定:80mm 纸按 48 个 ANK 字符列计算(中文字符占 2 列);中文通过 ESC/POS
* <p>排版约定:80mm 纸按 48 个 ANK 字符列计算(中文和制表符占 2 列);中文通过 ESC/POS
* 汉字模式(FS &amp; / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。
* 输出内容不含初始化以外的走纸与切刀命令,末尾走纸和切刀由终端按本机配置统一包装。</p>
* 图片版的矩形边框用 GBK 字库制表符(─ │ ┌ ┐ └ ┘)拼装,相邻字符字模相连成实线;
* 黑底白字标签用 ESC { 反色指令输出。输出内容不含初始化以外的走纸与切刀命令,
* 末尾走纸和切刀由终端按本机配置统一包装。</p>
*/
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<String> 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<String> 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<String> componentItems) {
boolean combo, List<String> componentItems) {
return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
* 渲染兑换券商品卡片:类型标签与名称加粗同行,单价数量与小计两栏,组合内容缩进说明。
* 渲染兑换券商品卡片:制表符矩形框包围,内部为反色类型标签、加粗名称、两栏价格和组合内容。
*
* <p>图片模式以描边卡片区分商品;指令模式用商品块后的空行近似同样的视觉间隔。</p>
* <p>与图片版卡片一一对应:框线对应描边矩形、反色标签对应黑底白字 tag、粗体名称对应大号商品名,
* 组合内容区保留内部短线与缩进组成项;卡片之间输出空行还原图片的卡片间距。</p>
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems) {
String typeLabel = combo ? "[组合商品]" : "[单品]";
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;
+ " " + (quantity == null || quantity.isBlank() ? "x0" : quantity);
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);
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<String> 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<Segment> inner, boolean bold) {
int innerWidth = 0;
List<Segment> 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<Segment> 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 { 包裹)、换行、汉字模式关。
*
* <p>每个样式指令在行内声明,行后仅复位放大与加粗,对齐由下一行重新声明,
* 保证任意指令流截断或拼接都不会让打印机停留在未知样式状态。</p>
@@ -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<Segment> 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) {
}
}
@@ -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<String> 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<String> 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 列,单个超宽字符仍独占一行。 */