From 6cc292d61a9b0c7a145c0771f5242b6d3817d18c Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 12 Feb 2026 08:01:10 +0000 Subject: [PATCH] feat: add ESC/POS renderer --- src/engine/escpos.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++ src/engine/render.ts | 73 ++++++++++++++++++++++++++++++++++++++ tests/render.test.ts | 52 +++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 src/engine/escpos.ts create mode 100644 src/engine/render.ts create mode 100644 tests/render.test.ts diff --git a/src/engine/escpos.ts b/src/engine/escpos.ts new file mode 100644 index 0000000..c15c4fa --- /dev/null +++ b/src/engine/escpos.ts @@ -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 + ]); +} diff --git a/src/engine/render.ts b/src/engine/render.ts new file mode 100644 index 0000000..ed6954c --- /dev/null +++ b/src/engine/render.ts @@ -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; +} diff --git a/tests/render.test.ts b/tests/render.test.ts new file mode 100644 index 0000000..d087035 --- /dev/null +++ b/tests/render.test.ts @@ -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('========'); + }); +});