feat: add ESC/POS renderer

This commit is contained in:
Developer 2026-02-12 08:01:10 +00:00
parent c561dae461
commit 6cc292d61a
3 changed files with 209 additions and 0 deletions

84
src/engine/escpos.ts Normal file
View File

@ -0,0 +1,84 @@
// ESC/POS 指令常量
export const ESC = 0x1B;
export const GS = 0x1D;
export const LF = 0x0A;
export const CR = 0x0D;
// 初始化打印机
export function initialize(): Uint8Array {
return new Uint8Array([ESC, 0x40]);
}
// 切纸
export function cut(): Uint8Array {
return new Uint8Array([GS, 0x56, 0x00]);
}
// 文本编码
export function text(str: string): Uint8Array {
const encoder = new TextEncoder();
return encoder.encode(str);
}
// 换行
export function newline(): Uint8Array {
return new Uint8Array([LF]);
}
// 加粗
export function bold(on: boolean): Uint8Array {
return new Uint8Array([ESC, 0x45, on ? 1 : 0]);
}
// 下划线
export function underline(on: boolean): Uint8Array {
return new Uint8Array([ESC, 0x2D, on ? 1 : 0]);
}
// 对齐
export function align(align: 'left' | 'center' | 'right'): Uint8Array {
const n = align === 'left' ? 0 : align === 'center' ? 1 : 2;
return new Uint8Array([ESC, 0x61, n]);
}
// 字体大小
export function fontSize(size: 'small' | 'normal' | 'large' | 'xlarge'): Uint8Array {
let n = 0;
switch (size) {
case 'small': n = 0; break;
case 'normal': n = 0; break;
case 'large': n = 0x11; break;
case 'xlarge': n = 0x22; break;
}
return new Uint8Array([GS, 0x21, n]);
}
// 恢复默认字体大小
export function resetFontSize(): Uint8Array {
return new Uint8Array([GS, 0x21, 0]);
}
// 打印条码 (CODE128)
export function barcodeCode128(data: string, height: number = 64): Uint8Array {
const encoder = new TextEncoder();
const dataBytes = encoder.encode(data);
return new Uint8Array([
GS, 0x68, height,
GS, 0x6B, 0x49, dataBytes.length,
...dataBytes,
0
]);
}
// 打印 QR 码
export function qrCode(data: string, size: number = 3): Uint8Array {
const encoder = new TextEncoder();
const dataBytes = encoder.encode(data);
const len = dataBytes.length + 3;
return new Uint8Array([
GS, 0x28, 0x6B, 0x03, 0x00, 0x31, 0x43, size,
GS, 0x28, 0x6B, len & 0xFF, (len >> 8) & 0xFF, 0x31, 0x50, 0x30,
...dataBytes,
GS, 0x28, 0x6B, 0x03, 0x00, 0x31, 0x51, 0x30
]);
}

73
src/engine/render.ts Normal file
View File

@ -0,0 +1,73 @@
import Mustache from 'mustache';
import type { Template, Block } from '../types/template';
import * as escpos from './escpos';
export function renderTemplate(template: Template, data: any): Uint8Array {
const buffers: Uint8Array[] = [];
// 初始化打印机
buffers.push(escpos.initialize());
// 渲染每个 block
for (const block of template.blocks) {
buffers.push(renderBlock(block, data, template.defaults));
}
// 切纸
buffers.push(escpos.cut());
return mergeBuffers(buffers);
}
function renderBlock(block: Block, data: any, defaults: any = {}): Uint8Array {
// 根据 block 类型渲染
switch (block.type) {
case 'text':
return renderTextBlock(block, data);
case 'divider':
return renderDividerBlock(block);
case 'space':
return renderSpaceBlock(block);
default:
console.warn(`Unknown block type: ${(block as any).type}`);
return new Uint8Array(0);
}
}
function renderTextBlock(block: any, data: any): Uint8Array {
const buffers: Uint8Array[] = [];
buffers.push(escpos.align(block.align || 'left'));
buffers.push(escpos.bold(block.bold || false));
const content = Mustache.render(block.content, data);
buffers.push(escpos.text(content));
buffers.push(escpos.newline());
buffers.push(escpos.bold(false));
buffers.push(escpos.align('left'));
return mergeBuffers(buffers);
}
function renderDividerBlock(block: any): Uint8Array {
const char = block.char || '-';
const line = char.repeat(48);
return mergeBuffers([escpos.text(line), escpos.newline()]);
}
function renderSpaceBlock(block: any): Uint8Array {
const lines = block.lines || 1;
const buffers: Uint8Array[] = [];
for (let i = 0; i < lines; i++) {
buffers.push(escpos.newline());
}
return mergeBuffers(buffers);
}
function mergeBuffers(buffers: Uint8Array[]): Uint8Array {
const totalLength = buffers.reduce((sum, buf) => sum + buf.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const buf of buffers) {
result.set(buf, offset);
offset += buf.length;
}
return result;
}

52
tests/render.test.ts Normal file
View File

@ -0,0 +1,52 @@
import { describe, it, expect } from 'bun:test';
import { renderTemplate } from '../src/engine/render';
import type { Template } from '../src/types/template';
describe('Template Renderer', () => {
it('should render text block', () => {
const template: Template = {
id: 'test',
name: 'Test',
width: '80mm',
blocks: [
{ type: 'text', content: 'Hello World', align: 'center' }
]
};
const data = {};
const result = renderTemplate(template, data);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBeGreaterThan(0);
});
it('should substitute mustache variables', () => {
const template: Template = {
id: 'test',
name: 'Test',
width: '80mm',
blocks: [
{ type: 'text', content: 'Hello {{name}}' }
]
};
const data = { name: 'Alice' };
const result = renderTemplate(template, data);
const text = new TextDecoder().decode(result);
expect(text).toContain('Hello Alice');
});
it('should render divider', () => {
const template: Template = {
id: 'test',
name: 'Test',
width: '80mm',
blocks: [
{ type: 'divider', char: '=' }
]
};
const result = renderTemplate(template, {});
const text = new TextDecoder().decode(result);
expect(text).toContain('========');
});
});