11
This commit is contained in:
1 parent
5c47704e09
commit
ccab8a9de6
2 files changed
+134
-51
No files matched your search
+114
-29
@@ -15,8 +15,8 @@ import java.util.List;
|
||||
* <p>排版约定:80mm 纸按 48 个 ANK 字符列计算(中文和制表符占 2 列);中文通过 ESC/POS
|
||||
* 汉字模式(FS & / 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 列,与打印机汉字模式宽度一致。
|
||||
*/
|
||||
|
||||
+20
-22
@@ -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("组合内容")), "组合内容标题缺失");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user