fix:命令行打印调试
This commit is contained in:
1 parent
ddf2af76a5
commit
5c47704e09
2 files changed
+206
-35
No files matched your search
+168
-34
@@ -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 & / 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) {
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -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 列,单个超宽字符仍独占一行。 */
|
||||
|
||||
Reference in new issue
Block a user