This commit is contained in:
dzc committed 2026-09-22 09:35:17 +08:00
1 parent 5c47704e09
commit ccab8a9de6
2 files changed
+134 -51

No files matched your search

@@ -15,8 +15,8 @@ import java.util.List;
* <p>排版约定:80mm 纸按 48 个 ANK 字符列计算(中文和制表符占 2 列);中文通过 ESC/POS
* 汉字模式(FS &amp; / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。
* 图片版的矩形边框用 GBK 字库制表符(─ │ ┌ ┐ └ ┘)拼装,相邻字符字模相连成实线;
* 黑底白字标签用 ESC { 反色指令输出。输出内容不含初始化以外的走纸与切刀命令,
* 末尾走纸和切刀由终端按本机配置统一包装。</p>
* 黑底白字标签用 GS B 反色指令输出。区块间距按图片版像素值(1px≈1 点)用压缩行距的
* 空行实现。输出不含切刀和大段走纸命令,末尾走纸和切刀由终端按本机配置统一包装。</p>
*/
public class EscPosReceiptCommand implements ReceiptRenderer {
@@ -70,27 +70,26 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
}
/**
* 渲染制表符描边矩形框包围的居中加粗提示,还原图片模式的横幅边框。
* 渲染倍宽粗横线上下包围的居中倍宽加粗提示,还原图片模式 44px 大字横幅。
*
* <p>图片版为四边描边框加特大字;指令模式放大到倍宽后文字已占满纸宽,无法再容纳
* 左右竖线,以两条倍宽粗横线近似横幅上下边,横幅后按图片版留 14 点空隙。</p>
*/
@Override
public EscPosReceiptCommand banner(String text) {
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();
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 {
}
/**
* 渲染兑换券商品卡片:制表符矩形框包围,内部为反色类型标签、加粗名称、两栏价格和组合内容。
* 渲染兑换券商品卡片:制表符矩形框包围,内部为类型标签、倍宽加粗名称、两栏价格和组合内容。
*
* <p>与图片版卡片一一对应:框线对应描边矩形、反色标签对应黑底白字 tag、粗体名称对应大号商品名,
* 组合内容区保留内部短线与缩进组成项;卡片之间输出空行还原图片的卡片间距。</p>
* <p>与图片版卡片逐项对应:框线对应描边矩形;组合商品标签为 GS B 反色黑底(对应图片
* fillRect 黑底白字),单品标签为制表符描边小框(对应图片 drawRect 描边框);卡片内部
* 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。</p>
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> 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;
}
/**
* 渲染上下制表符实线包围的商品合计;双倍宽度放不下时自动降为单倍。
* 输出商品卡片内的倍宽加粗名称行:左右竖线保持单倍细线,仅名称文字片段放大加粗。
*
* <p>GS ! 与 ESC E 是行内即时指令,竖线输出后再开、名称结束即关,卡片左右边框
* 不随名称放大变粗,与上下框线粗细一致。</p>
*
* @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 点一一对应)。
*
* <p>不使用 ESC d 点级走纸:实测部分国产打印机对其单位实现不标准,会放大数十倍导致
* 出纸失控。此处改用所有机型都可靠支持的行距指令:临时把行距压到目标点数、走一个
* 空行、立即恢复默认行距,单次空隙即目标像素值,不存在被放大的空间。</p>
*
* @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 为字高加小缝)。
*
* <p>默认行距(ESC 2 = 1/6 英寸 ≈ 34 点)会在相邻行间留下约 10 点空隙,导致卡片
* 左右竖线视觉断线;卡片内部压缩行距后竖线行间衔接。</p>
*
* @param units 行间距单位值
*/
private void setLineSpacing(int units) {
writeRaw(0x1B, 0x33, units);
}
/**
* 恢复默认行距(ESC 2 = 1/6 英寸)。
*/
private void resetLineSpacing() {
writeRaw(0x1B, 0x32);
}
/**
* 计算文本的显示列数:中文字符按 2 列、其余按 1 列,与打印机汉字模式宽度一致。
*/
@@ -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<String> 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<String> lines = visibleLines(decode(new EscPosReceiptCommand().banner("第 1 次打印 · 2 笔预约")));
void bannerRendersBoldRuleLinesWithScaledText() {
byte[] bytes = decode(new EscPosReceiptCommand().banner("第 1 次打印 · 2 笔预约"));
List<String> 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<String> 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("组合内容")), "组合内容标题缺失");
}