/** * ESC/POS Document Compiler * Converts high-level document nodes into raw ESC/POS byte commands for thermal printers */ import type {PrinterOptions} from '../../printer' import { createSeparator, createStrikethroughLine, createTableBorder, formatColumns, paperToTextWidth, wrapText, } from '../../shared' import {concatBytes} from '../common' import type {CompileContext, Node, TableCell} from '../types' import { align, barcode, bold, cut, doubleStrike, feedAndCut, init, lf, qrNative, reverse, selectFont, setCharSize, setLeftMargin, setLineSpacing, setPrintAreaWidth, underline, } from './commands' import {encodeVietnamese} from './vietnamese' /** * Helper: Apply per-node margin by adding spaces * @param text Text content * @param marginLeft Left margin in characters (overrides global) * @param marginRight Right margin in characters (overrides global) * @param paperWidthChars Total paper width in characters * @returns Text with margins applied */ function applyNodeMargin( text: string, marginLeft: number | undefined, marginRight: number | undefined, paperWidthChars: number, ): string { const leftSpaces = marginLeft !== undefined ? ' '.repeat(marginLeft) : '' const rightSpaces = marginRight !== undefined ? ' '.repeat(marginRight) : '' // If both margins specified, ensure text fits if (marginLeft !== undefined && marginRight !== undefined) { const availableWidth = paperWidthChars - marginLeft - marginRight if (availableWidth > 0 && text.length > availableWidth) { // Truncate text if too long text = text.substring(0, availableWidth) } } return leftSpaces + text + rightSpaces } /** * Helper: Normalize TableCell to object format * @param cell TableCell (string or object) * @returns Normalized cell object */ function normalizeCell(cell: TableCell): {text: string; strikethrough?: string; style?: any} { if (typeof cell === 'string') { return {text: cell} } return cell } /** * Compile document nodes to ESC/POS byte commands * @param nodes Array of document nodes to compile * @param options Printer options with settings (marginMm should be set by caller) * @returns Raw ESC/POS command bytes ready for printing */ export function compileDocument(nodes: Node[], options: PrinterOptions = {}): Uint8Array { console.log('[JS] Compiler.compileDocument - START') console.log('[JS] Compiler.compileDocument - PARAMS:', {nodes: nodes.length, options}) // Create context with defaults const paperWidthMm = options.paperWidthMm || 58 const marginMm = options.marginMm !== undefined ? options.marginMm : 1 // Fallback default const lineSpacing = options.lineSpacing !== undefined ? options.lineSpacing : 30 // Default: 60/180 inch const ctx: CompileContext = { paperWidthMm, paperWidthChars: paperToTextWidth(paperWidthMm, 'A'), // Default: Font A encoding: options.encoding || 'utf8', codepage: options.codepage, // Custom codepage if specified marginMm, lineSpacing, disableCutPaper: options.disableCutPaper || false, currentFont: 'A', // Default font: Font A (12x24 dots) } console.log('[JS] Compiler.compileDocument - PROCESS: Context created', { paperWidthMm, paperWidthChars: ctx.paperWidthChars, encoding: ctx.encoding, codepage: ctx.codepage, marginMm: ctx.marginMm, lineSpacing: ctx.lineSpacing, }) const result = compileEscpos(nodes, ctx) console.log('[JS] Compiler.compileDocument - SUCCESS: Compiled', result.length, 'bytes') return result } /** * Internal ESC/POS compiler - processes nodes and generates commands * @internal */ function compileEscpos(doc: Node[], ctx: CompileContext): Uint8Array { const chunks: Uint8Array[] = [] // Initialize printer to default state chunks.push(init()) console.log('[JS] Compiler.compileEscpos - INIT: ESC @ sent') // Force Font A immediately after init to ensure consistent font across all printers chunks.push(selectFont(ctx.currentFont)) console.log('[JS] Compiler.compileEscpos - FONT: Force Font A (ESC M 0)') // Set print area width (GS W) with margins subtracted const printAreaMm = ctx.paperWidthMm - ctx.marginMm * 2 const printAreaBytes = setPrintAreaWidth(ctx.paperWidthMm, ctx.marginMm) chunks.push(printAreaBytes) const printAreaHex = Array.from(printAreaBytes) .map((b) => b.toString(16).padStart(2, '0').toUpperCase()) .join(' ') console.log( `[JS] Compiler.compileEscpos - PRINT AREA: GS W ${printAreaMm}mm (${printAreaMm * 8} dots) with ${ ctx.marginMm }mm margins = ${printAreaHex}`, ) // Set left margin (GS L) to offset print area from left edge (only if marginMm > 0) // This creates symmetric margins: GS W reduces width, GS L offsets from left if (ctx.marginMm > 0) { const leftMarginBytes = setLeftMargin(ctx.marginMm) chunks.push(leftMarginBytes) const marginHex = Array.from(leftMarginBytes) .map((b) => b.toString(16).padStart(2, '0').toUpperCase()) .join(' ') console.log( `[JS] Compiler.compileEscpos - LEFT MARGIN: GS L ${ctx.marginMm}mm (${ ctx.marginMm * 8 } dots) = ${marginHex}`, ) } // Set line spacing (user can override via options.lineSpacing) // Default: 60 = 60/180 inch = 8.47mm (more spacing between lines) chunks.push(setLineSpacing(ctx.lineSpacing)) console.log(`[JS] Compiler.compileEscpos - LINE SPACING: ${ctx.lineSpacing}/180 inch`) console.log('[JS] Compiler.compileEscpos - PROCESS: Compiling', doc.length, 'nodes') for (let i = 0; i < doc.length; i++) { const n = doc[i] console.log(`[JS] Compiler.compileEscpos - PROCESS: Node ${i + 1}/${doc.length} type=${n.type}`) switch (n.type) { case 'text': { // Apply font if specified (must come before alignment and size) let fontChanged = false if (n.style?.font && n.style.font !== ctx.currentFont) { chunks.push(selectFont(n.style.font)) ctx.currentFont = n.style.font // Update paperWidthChars for correct wrapping and margins ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, n.style.font) fontChanged = true console.log( `[JS] Compiler.compileEscpos - FONT CHANGE: ${n.style.font} → paperWidthChars=${ctx.paperWidthChars}`, ) } // Apply text alignment if (n.style?.align) chunks.push(align(n.style.align)) // Apply text formatting (bold, underline, etc.) if (n.style?.bold) chunks.push(bold(true)) if (n.style?.underline) chunks.push(underline(true)) if (n.style?.doubleStrike) chunks.push(doubleStrike(true)) if (n.style?.reverse) chunks.push(reverse(true)) // Apply text size (maps directly to ESC/POS size 1-8) if (n.style?.size) { // size is already 1-8, just clamp for safety const size = Math.max(1, Math.min(8, n.style.size)) chunks.push(setCharSize(size, size)) // 1→1x1, 2→2x2, 3→3x3, ..., 8→8x8 } // Handle text wrapping if enabled (uses updated ctx.paperWidthChars) let textToEncode = n.content const wrap = n.style?.wrap || true if (wrap) { const maxWidth = n.style?.maxWidth || ctx.paperWidthChars || 32 const lines = wrapText(n.content, maxWidth, true) textToEncode = lines.join('\n') } // Apply per-node margin if specified if (n.style?.marginLeft !== undefined || n.style?.marginRight !== undefined) { textToEncode = applyNodeMargin( textToEncode, n.style?.marginLeft, n.style?.marginRight, ctx.paperWidthChars, ) } // Handle strikethrough if enabled if (n.style?.strikethrough) { const strikethroughLine = createStrikethroughLine(textToEncode, ctx.paperWidthChars, n.style?.align) const {bytes: strikeBytes, codepageCmd: strikeCmd} = encodeVietnamese( strikethroughLine, ctx.encoding, ctx.codepage, ) chunks.push(strikeCmd, strikeBytes, lf(1)) } // Encode text with universal Vietnamese encoder const {bytes, codepageCmd} = encodeVietnamese(textToEncode, ctx.encoding, ctx.codepage) chunks.push(codepageCmd, bytes) chunks.push(lf(1)) // Reset all formatting to normal state if (n.style?.bold) chunks.push(bold(false)) if (n.style?.underline) chunks.push(underline(false)) if (n.style?.doubleStrike) chunks.push(doubleStrike(false)) if (n.style?.reverse) chunks.push(reverse(false)) if (n.style?.size && n.style.size !== 1) { chunks.push(setCharSize(1, 1)) // Reset to normal size (1x1) } // Reset alignment to left (default) if (n.style?.align && n.style.align !== 'left') { chunks.push(align('left')) } // Reset font back to Font A if it was changed if (fontChanged && n.style?.font !== 'A') { chunks.push(selectFont('A')) ctx.currentFont = 'A' ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, 'A') console.log('[JS] Compiler.compileEscpos - FONT RESET: Back to Font A') } break } case 'line': { // Generate horizontal line with specified style let width = n.widthChars || ctx.paperWidthChars || 32 // Apply per-node margin if specified let lineText = createSeparator(width, n.style || 'solid', n.character) if (n.marginLeft !== undefined || n.marginRight !== undefined) { lineText = applyNodeMargin(lineText, n.marginLeft, n.marginRight, ctx.paperWidthChars) } // Line characters are ASCII only, no need for Vietnamese encoding const bytes = new TextEncoder().encode(lineText) chunks.push(bytes) chunks.push(lf(1)) break } case 'qr': { // Generate QR code with specified size and error correction // Set alignment (default: center for QR codes) const qrAlign = n.align || 'center' chunks.push(align(qrAlign)) const data = new TextEncoder().encode(n.content) for (const seq of qrNative(data, n.size ?? 6, n.errorLevel ?? 'M')) chunks.push(seq) chunks.push(lf(1)) // Reset alignment to left chunks.push(align('left')) break } case 'feed': { // Add line feeds (empty lines) chunks.push(lf(n.lines ?? 1)) break } case 'cut': { if (ctx.disableCutPaper === true) { console.log('[JS] Compiler.compileEscpos - CUT: Do nothing (disableCutPaper=true)') } else { if (n.partial === false) { chunks.push(cut('full')) console.log('[JS] Compiler.compileEscpos - CUT: Full cut') } else { chunks.push(feedAndCut(3)) console.log('[JS] Compiler.compileEscpos - CUT: Partial cut with feed') } } break } case 'barcode': { // Generate barcode with specified format and position // Set alignment (default: center for barcodes) const barcodeAlign = n.align || 'center' chunks.push(align(barcodeAlign)) chunks.push( barcode( n.content, n.format || 'CODE128', n.heightPx || 100, n.widthPx || 2, n.textPosition || 'below', ), ) chunks.push(lf(1)) // Reset alignment to left chunks.push(align('left')) break } case 'image': { // Images are handled by native module, not ESC/POS console.warn( '[JS] Compiler.compileEscpos - WARN: Image node skipped - use NativePrinter.printImage()', ) break } case 'raw': { // Insert raw ESC/POS bytes directly chunks.push(new Uint8Array(n.data)) break } case 'columns': { // Handle multi-column layout with style support // Determine the style to use (global or first column's style for font context) const globalStyle = n.style const firstColStyle = n.columns[0]?.style const effectiveStyle = firstColStyle || globalStyle // Apply font if specified (use first column's font or global font) let columnsFontChanged = false if (effectiveStyle?.font && effectiveStyle.font !== ctx.currentFont) { chunks.push(selectFont(effectiveStyle.font)) ctx.currentFont = effectiveStyle.font ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, effectiveStyle.font) columnsFontChanged = true } // Apply size if specified if (effectiveStyle?.size) { const size = Math.max(1, Math.min(8, effectiveStyle.size)) chunks.push(setCharSize(size, size)) } // Apply formatting if (effectiveStyle?.bold) chunks.push(bold(true)) if (effectiveStyle?.underline) chunks.push(underline(true)) const columnTexts = n.columns.map((col) => col.content) const columnWidths = n.columns.map((col) => col.width || Math.floor(100 / n.columns.length)) const columnAligns = n.columns.map((col) => col.align || 'left') // force wrap for columns to prevent overflow const hasWrap = true // n.columns.some((col) => col.style?.wrap) const formattedLines = formatColumns( columnTexts, columnWidths, ctx.paperWidthChars || 32, columnAligns, hasWrap, ) // Render all lines (supports multi-line wrapping) formattedLines.forEach((line) => { const {bytes, codepageCmd} = encodeVietnamese(line, ctx.encoding, ctx.codepage) chunks.push(codepageCmd, bytes) chunks.push(lf(1)) }) // Reset formatting if (effectiveStyle?.bold) chunks.push(bold(false)) if (effectiveStyle?.underline) chunks.push(underline(false)) if (effectiveStyle?.size && effectiveStyle.size !== 1) { chunks.push(setCharSize(1, 1)) } if (effectiveStyle?.align && effectiveStyle.align !== 'left') { chunks.push(align('left')) } if (columnsFontChanged && effectiveStyle?.font !== 'A') { chunks.push(selectFont('A')) ctx.currentFont = 'A' ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, 'A') } break } case 'table': { // Handle table with headers and rows const totalWidth = ctx.paperWidthChars || 32 const columnWidths = n.columnWidths || new Array(n.headers?.length || n.rows[0]?.length || 0).fill( Math.floor(100 / (n.headers?.length || n.rows[0]?.length || 1)), ) // Calculate number of vertical separators needed const numColumns = columnWidths.length const hasLeftBorder = !!n.border?.left const hasRightBorder = !!n.border?.right const hasColumnSeparators = !!n.border?.columns // Calculate total characters used by vertical borders let verticalCharsCount = 0 if (hasLeftBorder) verticalCharsCount += 1 if (hasRightBorder) verticalCharsCount += 1 if (hasColumnSeparators) verticalCharsCount += numColumns - 1 // Available width for content after subtracting vertical borders const availableWidth = totalWidth - verticalCharsCount // Calculate actual column widths from percentages using available width const actualWidths = columnWidths.map((w) => Math.floor((w / 100) * availableWidth)) // Adjust last column to use remaining space (to avoid rounding issues) const usedWidth = actualWidths.reduce((sum, w) => sum + w, 0) if (usedWidth < availableWidth && actualWidths.length > 0) { actualWidths[actualWidths.length - 1] += availableWidth - usedWidth } // Setup border characters based on character set const charSet = n.borderCharSet || 'ascii' // Default: ASCII (safe) const defaultChars = charSet === 'utf8' ? { horizontal: '─', vertical: '│', topLeft: '┌', topRight: '┐', topJunction: '┬', leftJunction: '├', rightJunction: '┤', cross: '┼', bottomLeft: '└', bottomRight: '┘', bottomJunction: '┴', } : { horizontal: '-', vertical: '|', topLeft: '+', topRight: '+', topJunction: '+', leftJunction: '+', rightJunction: '+', cross: '+', bottomLeft: '+', bottomRight: '+', bottomJunction: '+', } const borderChars = {...defaultChars, ...n.borderChars} // Helper: Get border style character const getBorderChar = ( style: boolean | string | undefined, defaultStyle: string = 'dashed', ): string => { if (!style) return borderChars.horizontal const actualStyle = typeof style === 'string' ? style : defaultStyle switch (actualStyle) { case 'solid': return '_' // Solid line uses underscore (same as { type: 'line' }) case 'dashed': return '-' // Dashed uses dash case 'double': return '=' // Double uses equals default: return borderChars.horizontal } } // Helper: Render row with vertical borders and per-cell styles const renderRow = (cells: TableCell[]) => { // Normalize all cells to objects const normalizedCells = cells.map(normalizeCell) // Get first cell's style for font/size (applies to entire row) const firstCellWithStyle = normalizedCells.find((cell) => cell.style) const rowStyle = firstCellWithStyle?.style // Apply row-level font/size if specified in first cell let rowFontChanged = false if (rowStyle?.font && rowStyle.font !== ctx.currentFont) { chunks.push(selectFont(rowStyle.font)) ctx.currentFont = rowStyle.font ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, rowStyle.font) rowFontChanged = true } if (rowStyle?.size) { const size = Math.max(1, Math.min(8, rowStyle.size)) chunks.push(setCharSize(size, size)) } if (rowStyle?.bold) chunks.push(bold(true)) if (rowStyle?.underline) chunks.push(underline(true)) // Check if any cell has strikethrough const hasStrikethrough = normalizedCells.some((cell) => cell.strikethrough) // If has strikethrough, render dash line first if (hasStrikethrough) { // Build dash line: only for cells with strikethrough, positioned above strikethrough text const dashLineParts: string[] = [] for (let i = 0; i < normalizedCells.length; i++) { const cell = normalizedCells[i] const cellWidth = actualWidths[i] const alignment = n.alignments?.[i] || 'left' if (cell.strikethrough) { // Create dash line positioned at strikethrough text location // Format: "actualText strikethroughText" → dash should be above strikethroughText const combinedText = `${cell.text} ${cell.strikethrough}` const strikethroughStartPos = cell.text.length + 1 // +1 for space const dashLine = createStrikethroughLine(cell.strikethrough, cellWidth, alignment) // Pad left to align dash with strikethrough text position const padding = ' '.repeat(strikethroughStartPos) const dashedCell = (padding + '-'.repeat(cell.strikethrough.length)).padEnd(cellWidth, ' ') dashLineParts.push(dashedCell) } else { dashLineParts.push(' '.repeat(cellWidth)) } } // Render dash line let dashOutput = dashLineParts.join('') if (n.border?.left || n.border?.right || n.border?.columns) { let bordered = '' if (n.border?.left) bordered += borderChars.vertical bordered += dashLineParts.join(n.border?.columns ? borderChars.vertical : '') if (n.border?.right) bordered += borderChars.vertical dashOutput = bordered } const {bytes: dashBytes, codepageCmd: dashCmd} = encodeVietnamese( dashOutput, ctx.encoding, ctx.codepage, ) chunks.push(dashCmd, dashBytes, lf(1)) } // Build display text: "actualText strikethroughText" for cells with strikethrough const cellTexts = normalizedCells.map((cell) => { if (cell.strikethrough) { return `${cell.text} ${cell.strikethrough}` } return cell.text }) // Format columns const lines = formatColumns(cellTexts, actualWidths, totalWidth, n.alignments, n.wrapCells || true) lines.forEach((line) => { let output = line // Add vertical borders if needed if (n.border?.left || n.border?.right || n.border?.columns) { let pos = 0 const parts: string[] = [] // Split line by column widths for (let i = 0; i < actualWidths.length; i++) { parts.push(line.substring(pos, pos + actualWidths[i])) pos += actualWidths[i] } // Build output with vertical separators output = '' if (n.border?.left) output += borderChars.vertical output += parts.join(n.border?.columns ? borderChars.vertical : '') if (n.border?.right) output += borderChars.vertical } const {bytes, codepageCmd} = encodeVietnamese(output, ctx.encoding, ctx.codepage) chunks.push(codepageCmd, bytes, lf(1)) }) // Reset row-level style if (rowStyle?.bold) chunks.push(bold(false)) if (rowStyle?.underline) chunks.push(underline(false)) if (rowStyle?.size && rowStyle.size !== 1) { chunks.push(setCharSize(1, 1)) } if (rowStyle?.align && rowStyle.align !== 'left') { chunks.push(align('left')) } if (rowFontChanged && rowStyle?.font !== 'A') { chunks.push(selectFont('A')) ctx.currentFont = 'A' ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, 'A') } } // Top border if (n.border?.top) { const borderLine = createTableBorder(totalWidth, actualWidths, { horizontal: getBorderChar(n.border.top, 'solid'), left: n.border?.left ? borderChars.topLeft : undefined, right: n.border?.right ? borderChars.topRight : undefined, junction: n.border?.columns ? borderChars.topJunction : undefined, }) const bytes = new TextEncoder().encode(borderLine) chunks.push(bytes, lf(1)) } // Headers if (n.headers && n.headers.length > 0) { // Apply header style let headerFontChanged = false if (n.headerStyle?.font && n.headerStyle.font !== ctx.currentFont) { chunks.push(selectFont(n.headerStyle.font)) ctx.currentFont = n.headerStyle.font ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, n.headerStyle.font) headerFontChanged = true } if (n.headerStyle?.size) { const size = Math.max(1, Math.min(8, n.headerStyle.size)) chunks.push(setCharSize(size, size)) } if (n.headerStyle?.bold) chunks.push(bold(true)) if (n.headerStyle?.underline) chunks.push(underline(true)) renderRow(n.headers) // Reset header style if (n.headerStyle?.bold) chunks.push(bold(false)) if (n.headerStyle?.underline) chunks.push(underline(false)) if (n.headerStyle?.size && n.headerStyle.size !== 1) { chunks.push(setCharSize(1, 1)) } if (n.headerStyle?.align && n.headerStyle.align !== 'left') { chunks.push(align('left')) } if (headerFontChanged && n.headerStyle?.font !== 'A') { chunks.push(selectFont('A')) ctx.currentFont = 'A' ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, 'A') } // Header separator if (n.border?.header) { const borderLine = createTableBorder(totalWidth, actualWidths, { horizontal: getBorderChar(n.border.header, 'solid'), left: n.border?.left ? borderChars.leftJunction : undefined, right: n.border?.right ? borderChars.rightJunction : undefined, junction: n.border?.columns ? borderChars.cross : undefined, }) const bytes = new TextEncoder().encode(borderLine) chunks.push(bytes, lf(1)) } } // Rows for (let i = 0; i < n.rows.length; i++) { // Apply cell style if specified let cellFontChanged = false if (n.cellStyle?.font && n.cellStyle.font !== ctx.currentFont) { chunks.push(selectFont(n.cellStyle.font)) ctx.currentFont = n.cellStyle.font ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, n.cellStyle.font) cellFontChanged = true } if (n.cellStyle?.size) { const size = Math.max(1, Math.min(8, n.cellStyle.size)) chunks.push(setCharSize(size, size)) } if (n.cellStyle?.bold) chunks.push(bold(true)) if (n.cellStyle?.underline) chunks.push(underline(true)) renderRow(n.rows[i]) if (n.cellStyle?.bold) chunks.push(bold(false)) if (n.cellStyle?.underline) chunks.push(underline(false)) if (n.cellStyle?.size && n.cellStyle.size !== 1) { chunks.push(setCharSize(1, 1)) } if (n.cellStyle?.align && n.cellStyle.align !== 'left') { chunks.push(align('left')) } if (cellFontChanged && n.cellStyle?.font !== 'A') { chunks.push(selectFont('A')) ctx.currentFont = 'A' ctx.paperWidthChars = paperToTextWidth(ctx.paperWidthMm, 'A') } // Between rows separator if (n.border?.between && i < n.rows.length - 1) { const borderLine = createTableBorder(totalWidth, actualWidths, { horizontal: getBorderChar(n.border.between, 'dashed'), left: n.border?.left ? borderChars.leftJunction : undefined, right: n.border?.right ? borderChars.rightJunction : undefined, junction: n.border?.columns ? borderChars.cross : undefined, }) const bytes = new TextEncoder().encode(borderLine) chunks.push(bytes, lf(1)) } } // Bottom border if (n.border?.bottom) { const borderLine = createTableBorder(totalWidth, actualWidths, { horizontal: getBorderChar(n.border.bottom, 'solid'), left: n.border?.left ? borderChars.bottomLeft : undefined, right: n.border?.right ? borderChars.bottomRight : undefined, junction: n.border?.columns ? borderChars.bottomJunction : undefined, }) const bytes = new TextEncoder().encode(borderLine) chunks.push(bytes, lf(1)) } break } case 'spacer': { // Handle dynamic spacing const lines = n.height || 1 if (n.fill) { // Fill with character (ASCII only) const fillLine = n.fill.repeat(ctx.paperWidthChars || 32) const bytes = new TextEncoder().encode(fillLine) for (let i = 0; i < lines; i++) { chunks.push(bytes) chunks.push(lf(1)) } } else { // Just add empty lines chunks.push(lf(lines)) } break } default: { console.warn('[JS] Compiler.compileEscpos - WARN: Unknown node type:', (n as any).type) break } } } const result = concatBytes(...chunks) console.log('[JS] Compiler.compileEscpos - SUCCESS: Generated', result.length, 'bytes') return result }