- Nuevo layout responsivo mobile-first con tabs inferiores - Sidebar colapsable en desktop con cola de impresión - Sistema de templates reutilizables con localStorage - Soporte Dark/Light mode con UColorModeButton - Composables usePrintQueue y useTemplates para estado global - Componentes modulares: CommandBuilder, QuickActions, PrintQueue, QueueItem - Navegación por tabs: Constructor | Cola | Templates
287 lines
6.8 KiB
TypeScript
287 lines
6.8 KiB
TypeScript
// Server-side minimal ePOS-Print XML builder for Epson printers
|
|
// Focused on printer-only tags. Builds the inner message for <epos-print>.
|
|
|
|
// Tipos para las operaciones
|
|
export interface Operation {
|
|
op: string
|
|
[key: string]: any
|
|
}
|
|
|
|
export interface TextStyleOptions {
|
|
reverse?: boolean
|
|
ul?: boolean
|
|
em?: boolean
|
|
color?: string
|
|
}
|
|
|
|
export interface PulseOptions {
|
|
drawer?: string
|
|
time?: string
|
|
}
|
|
|
|
export interface BarcodeOptions {
|
|
type?: string
|
|
hri?: string
|
|
font?: string
|
|
width?: number
|
|
height?: number
|
|
}
|
|
|
|
export interface QRCodeOptions {
|
|
model?: string
|
|
level?: string
|
|
size?: number
|
|
}
|
|
|
|
function escapeXml(str: string | number): string {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
export class EposMessageBuilder {
|
|
private parts: string[] = []
|
|
|
|
// Text and styles
|
|
text(content: string): this {
|
|
this.parts.push(`<text>${escapeXml(content)}</text>`)
|
|
return this
|
|
}
|
|
|
|
textLang(lang: string): this {
|
|
this.parts.push(`<text lang="${lang}"/>`)
|
|
return this
|
|
}
|
|
|
|
textAlign(align: string): this {
|
|
this.parts.push(`<text align="${align}"/>`)
|
|
return this
|
|
}
|
|
|
|
textRotate(rotate: boolean): this {
|
|
this.parts.push(`<text rotate="${rotate ? 'true' : 'false'}"/>`)
|
|
return this
|
|
}
|
|
|
|
textLineSpace(linespc: number): this {
|
|
this.parts.push(`<text linespc="${linespc}"/>`)
|
|
return this
|
|
}
|
|
|
|
textFont(font: string): this {
|
|
this.parts.push(`<text font="${font}"/>`)
|
|
return this
|
|
}
|
|
|
|
textSmooth(smooth: boolean): this {
|
|
this.parts.push(`<text smooth="${smooth ? 'true' : 'false'}"/>`)
|
|
return this
|
|
}
|
|
|
|
textDouble(dw?: boolean, dh?: boolean): this {
|
|
const attrs = [
|
|
dw !== undefined ? `dw="${dw ? 'true' : 'false'}"` : '',
|
|
dh !== undefined ? `dh="${dh ? 'true' : 'false'}"` : ''
|
|
].filter(Boolean).join(' ')
|
|
this.parts.push(`<text ${attrs}/>`.trim())
|
|
return this
|
|
}
|
|
|
|
textSize(width?: number, height?: number): this {
|
|
const attrs = [
|
|
width !== undefined ? `width="${width}"` : '',
|
|
height !== undefined ? `height="${height}"` : ''
|
|
].filter(Boolean).join(' ')
|
|
this.parts.push(`<text ${attrs}/>`.trim())
|
|
return this
|
|
}
|
|
|
|
textStyle(options: TextStyleOptions = {}): this {
|
|
const { reverse, ul, em, color } = options
|
|
const attrs: string[] = []
|
|
if (reverse !== undefined) attrs.push(`reverse="${reverse ? 'true' : 'false'}"`)
|
|
if (ul !== undefined) attrs.push(`ul="${ul ? 'true' : 'false'}"`)
|
|
if (em !== undefined) attrs.push(`em="${em ? 'true' : 'false'}"`)
|
|
if (color !== undefined) attrs.push(`color="${color}"`)
|
|
this.parts.push(`<text ${attrs.join(' ')}/>`.trim())
|
|
return this
|
|
}
|
|
|
|
textPosition(x: number): this {
|
|
this.parts.push(`<text x="${x}"/>`)
|
|
return this
|
|
}
|
|
|
|
textVPosition(y: number): this {
|
|
this.parts.push(`<text y="${y}"/>`)
|
|
return this
|
|
}
|
|
|
|
// Feed
|
|
feed(): this {
|
|
this.parts.push('<feed/>')
|
|
return this
|
|
}
|
|
|
|
feedUnit(unit: number): this {
|
|
this.parts.push(`<feed unit="${unit}"/>`)
|
|
return this
|
|
}
|
|
|
|
feedLine(line: number): this {
|
|
this.parts.push(`<feed line="${line}"/>`)
|
|
return this
|
|
}
|
|
|
|
feedPosition(pos: number): this {
|
|
this.parts.push(`<feed pos="${pos}"/>`)
|
|
return this
|
|
}
|
|
|
|
// Cut
|
|
cut(type: string): this {
|
|
this.parts.push(`<cut type="${type}"/>`)
|
|
return this
|
|
}
|
|
|
|
// Drawer
|
|
pulse(options: PulseOptions = {}): this {
|
|
const { drawer = 'drawer_1', time = 'pulse_200' } = options
|
|
this.parts.push(`<pulse drawer="${drawer}" time="${time}"/>`)
|
|
return this
|
|
}
|
|
|
|
// Barcode and symbols
|
|
barcode(data: string, options: BarcodeOptions = {}): this {
|
|
const { type, hri, font, width, height } = options
|
|
const attrs: string[] = []
|
|
if (type) attrs.push(`type="${type}"`)
|
|
if (hri) attrs.push(`hri="${hri}"`)
|
|
if (font) attrs.push(`font="${font}"`)
|
|
if (width) attrs.push(`width="${width}"`)
|
|
if (height) attrs.push(`height="${height}"`)
|
|
this.parts.push(`<barcode ${attrs.join(' ')}>${escapeXml(data)}</barcode>`.trim())
|
|
return this
|
|
}
|
|
|
|
qrcode(data: string, options: QRCodeOptions = {}): this {
|
|
const { model = 'qrcode_model_2', level = 'level_m', size } = options
|
|
const attrs = [`type="${model}"`, `level="${level}"`]
|
|
if (size) attrs.push(`size="${size}"`)
|
|
this.parts.push(`<symbol ${attrs.join(' ')}>${escapeXml(data)}</symbol>`)
|
|
return this
|
|
}
|
|
|
|
// Image
|
|
imageRaw(options: {
|
|
width: number
|
|
height: number
|
|
mode?: string
|
|
base64: string
|
|
}): this {
|
|
const { width, height, mode = 'mono', base64 } = options
|
|
this.parts.push(`<image width="${width}" height="${height}" mode="${mode}">${base64}</image>`)
|
|
return this
|
|
}
|
|
|
|
build(): string {
|
|
return this.parts.join('')
|
|
}
|
|
}
|
|
|
|
// Accepts a list of operations: [{ op: 'text', args: {...} | [] }, ...]
|
|
export function buildFromOperations(ops: Operation[]): string {
|
|
const b = new EposMessageBuilder()
|
|
|
|
for (const item of ops || []) {
|
|
const { op } = item
|
|
if (!op) continue
|
|
|
|
switch (op) {
|
|
case 'text':
|
|
b.text(item.value ?? '')
|
|
break
|
|
case 'textLang':
|
|
b.textLang(item.lang)
|
|
break
|
|
case 'textAlign':
|
|
b.textAlign(item.align)
|
|
break
|
|
case 'textRotate':
|
|
b.textRotate(!!item.rotate)
|
|
break
|
|
case 'textLineSpace':
|
|
b.textLineSpace(item.linespc)
|
|
break
|
|
case 'textFont':
|
|
b.textFont(item.font)
|
|
break
|
|
case 'textSmooth':
|
|
b.textSmooth(!!item.smooth)
|
|
break
|
|
case 'textDouble':
|
|
b.textDouble(item.dw, item.dh)
|
|
break
|
|
case 'textSize':
|
|
b.textSize(item.width, item.height)
|
|
break
|
|
case 'textStyle':
|
|
b.textStyle({
|
|
reverse: item.reverse,
|
|
ul: item.ul,
|
|
em: item.em,
|
|
color: item.color
|
|
})
|
|
break
|
|
case 'textPosition':
|
|
b.textPosition(item.x)
|
|
break
|
|
case 'textVPosition':
|
|
b.textVPosition(item.y)
|
|
break
|
|
case 'feed':
|
|
b.feed()
|
|
break
|
|
case 'feedUnit':
|
|
b.feedUnit(item.unit)
|
|
break
|
|
case 'feedLine':
|
|
b.feedLine(item.line)
|
|
break
|
|
case 'feedPosition':
|
|
b.feedPosition(item.pos)
|
|
break
|
|
case 'cut':
|
|
b.cut(item.type || 'feed')
|
|
break
|
|
case 'pulse':
|
|
b.pulse({ drawer: item.drawer, time: item.time })
|
|
break
|
|
case 'barcode':
|
|
b.barcode(item.data || '', {
|
|
type: item.type,
|
|
hri: item.hri,
|
|
font: item.font,
|
|
width: item.width,
|
|
height: item.height
|
|
})
|
|
break
|
|
case 'qrcode':
|
|
b.qrcode(item.data || '', {
|
|
model: item.model,
|
|
level: item.level,
|
|
size: item.size
|
|
})
|
|
break
|
|
default:
|
|
// ignore unknown for now
|
|
break
|
|
}
|
|
}
|
|
|
|
return b.build()
|
|
}
|