/** * Text Formatting Utilities for Thermal Printers * Provides functions to format text for different paper widths and alignments */ /** * Pad text to the left with spaces */ export function padLeft(text: string, width: number, char: string = ' '): string { if (text.length >= width) return text.substring(0, width) return text.padStart(width, char) } /** * Pad text to the right with spaces */ export function padRight(text: string, width: number, char: string = ' '): string { if (text.length >= width) return text.substring(0, width) return text.padEnd(width, char) } /** * Center text within a given width */ export function padCenter(text: string, width: number, char: string = ' '): string { if (text.length >= width) return text.substring(0, width) const padding = width - text.length const leftPad = Math.floor(padding / 2) const rightPad = padding - leftPad return char.repeat(leftPad) + text + char.repeat(rightPad) } /** * Truncate text if it exceeds max width */ export function truncate(text: string, maxWidth: number, suffix: string = '...'): string { if (text.length <= maxWidth) return text if (maxWidth <= suffix.length) return text.substring(0, maxWidth) return text.substring(0, maxWidth - suffix.length) + suffix } /** * Format a line with left and right aligned text * @param left Left-aligned text * @param right Right-aligned text * @param totalWidth Total line width in characters */ export function formatLine(left: string, right: string, totalWidth: number): string { console.log('[JS] Formatters.formatLine - START') console.log('[JS] Formatters.formatLine - PARAMS:', { left: left?.length || 0, right: right?.length || 0, totalWidth, }) const leftText = left || '' const rightText = right || '' const space = totalWidth - leftText.length - rightText.length if (space < 0) { // If text is too long, truncate left text const maxLeftWidth = totalWidth - rightText.length - 1 const truncatedLeft = truncate(leftText, maxLeftWidth) return truncatedLeft + ' ' + rightText } const result = leftText + ' '.repeat(space) + rightText console.log('[JS] Formatters.formatLine - SUCCESS') return result } /** * Format multiple columns with specified widths * @param columns Array of column texts * @param widths Array of column widths (can be percentages or fixed chars) * @param totalWidth Total line width in characters * @param alignments Array of alignments for each column * @param wrapEnabled Enable text wrapping for long cells (default: false) * @returns Array of lines (single line if no wrapping, multiple if wrapping enabled) */ export function formatColumns( columns: string[], widths: number[], totalWidth: number, alignments?: Array<'left' | 'center' | 'right'>, wrapEnabled: boolean = false, ): string[] { // Calculate actual widths const actualWidths = calculateColumnWidths(widths, totalWidth) if (!wrapEnabled) { // Old behavior: single line, truncate if too long const formattedColumns = columns.map((text, i) => { const width = actualWidths[i] const alignment = alignments?.[i] || 'left' // Truncate if text exceeds width const truncated = text.length > width ? text.substring(0, width) : text switch (alignment) { case 'center': return padCenter(truncated, width) case 'right': return padLeft(truncated, width) default: return padRight(truncated, width) } }) return [formattedColumns.join('')] } // NEW: Wrap long text into multiple lines const wrappedColumns: string[][] = columns.map((text, i) => { const width = actualWidths[i] if (text.length <= width) return [text] return wrapText(text, width, true) // Use existing wrapText() }) // Find max number of lines needed const maxLines = Math.max(...wrappedColumns.map((col) => col.length)) // Build output lines const outputLines: string[] = [] for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { const lineParts = wrappedColumns.map((col, colIdx) => { const text = col[lineIdx] || '' // Empty string if column has fewer lines const width = actualWidths[colIdx] const alignment = alignments?.[colIdx] || 'left' switch (alignment) { case 'center': return padCenter(text, width) case 'right': return padLeft(text, width) default: return padRight(text, width) } }) outputLines.push(lineParts.join('')) } return outputLines } /** * Calculate actual column widths from percentages or fixed values */ export function calculateColumnWidths(widths: number[], totalWidth: number): number[] { // Check if widths are percentages (sum to ~100) or fixed widths const sum = widths.reduce((a, b) => a + b, 0) const isPercentage = sum <= 100 && sum >= 90 if (isPercentage) { // Convert percentages to character counts const calculatedWidths = widths.map((w) => Math.floor((w / 100) * totalWidth)) // Distribute remaining space to last column (to handle rounding) const usedWidth = calculatedWidths.reduce((a, b) => a + b, 0) if (usedWidth < totalWidth && calculatedWidths.length > 0) { calculatedWidths[calculatedWidths.length - 1] += totalWidth - usedWidth } return calculatedWidths } else { // Use as fixed widths, scale if necessary if (sum > totalWidth) { const scale = totalWidth / sum return widths.map((w) => Math.floor(w * scale)) } return widths } } /** * Wrap text into multiple lines * @param text Text to wrap * @param maxWidth Maximum width per line * @param preserveWords If true, avoid breaking words */ export function wrapText(text: string, maxWidth: number, preserveWords: boolean = true): string[] { if (text.length <= maxWidth) return [text] const lines: string[] = [] if (preserveWords) { const words = text.split(' ') let currentLine = '' for (const word of words) { if (currentLine.length + word.length + 1 <= maxWidth) { currentLine = currentLine ? `${currentLine} ${word}` : word } else { if (currentLine) lines.push(currentLine) // If word is longer than maxWidth, break it if (word.length > maxWidth) { const brokenWords = breakWord(word, maxWidth) lines.push(...brokenWords.slice(0, -1)) currentLine = brokenWords[brokenWords.length - 1] } else { currentLine = word } } } if (currentLine) lines.push(currentLine) } else { // Simple break by character count for (let i = 0; i < text.length; i += maxWidth) { lines.push(text.substring(i, i + maxWidth)) } } console.log('[JS] Formatters.wrapText - SUCCESS: Wrapped into', lines.length, 'lines') return lines } /** * Break a word into multiple lines */ function breakWord(word: string, maxWidth: number): string[] { const lines: string[] = [] for (let i = 0; i < word.length; i += maxWidth) { lines.push(word.substring(i, i + maxWidth)) } return lines } /** * Format a price with thousand separators and currency * @param amount Price amount * @param currency Currency symbol (default: 'đ') * @param thousandSep Thousand separator (default: '.') */ export function formatPrice(amount: number, currency: string = 'đ', thousandSep: string = '.'): string { const formatted = amount.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, thousandSep) return currency ? `${formatted}${currency}` : formatted } /** * Format date/time for receipt * @param date Date object * @param format Format type: 'date', 'time', 'datetime' */ export function formatDateTime(date: Date, format: 'date' | 'time' | 'datetime' = 'datetime'): string { const pad = (n: number) => n.toString().padStart(2, '0') const day = pad(date.getDate()) const month = pad(date.getMonth() + 1) const year = date.getFullYear() const hours = pad(date.getHours()) const minutes = pad(date.getMinutes()) switch (format) { case 'date': return `${day}/${month}/${year}` case 'time': return `${hours}:${minutes}` default: return `${day}/${month}/${year} ${hours}:${minutes}` } } /** * Create a line separator * @param width Line width * @param style Separator style * @param char Custom character for separator */ export function createSeparator( width: number, style: 'solid' | 'dashed' | 'double' | 'equals' = 'solid', char?: string, ): string { console.log('[JS] Formatters.createSeparator - PROCESS:', {width, style}) if (char) return char.repeat(width) // ASCII-safe defaults with distinct characters switch (style) { case 'solid': return '_'.repeat(width) // Use underscore for solid (distinct from dashed) case 'dashed': return '-'.repeat(width) // Use dash for dashed case 'double': return '='.repeat(width) // Use equals for double case 'equals': return '='.repeat(width) default: return '-'.repeat(width) } } /** * Create horizontal table border line with junctions * @param width Total width * @param columnWidths Array of column widths * @param borderChars Border characters to use * @returns Border line string */ export function createTableBorder( width: number, columnWidths: number[], borderChars: { horizontal: string left?: string right?: string junction?: string }, ): string { const {horizontal, left, right, junction} = borderChars // If no junctions needed, return simple line if (!left && !right && !junction) { return horizontal.repeat(width) } // Build line with junctions at column boundaries let line = left || '' for (let i = 0; i < columnWidths.length; i++) { line += horizontal.repeat(columnWidths[i]) if (i < columnWidths.length - 1 && junction) { line += junction } } line += right || '' return line } /** * Format table data with borders * @param headers Table headers * @param rows Table rows * @param widths Column widths * @param totalWidth Total table width * @deprecated Use TableNode with compile.ts instead for full border support */ export function formatTable( headers: string[], rows: string[][], widths: number[], totalWidth: number, alignments?: Array<'left' | 'center' | 'right'>, ): string[] { const lines: string[] = [] // Add top border lines.push(createSeparator(totalWidth, 'solid')) // Add headers if (headers.length > 0) { const headerLines = formatColumns(headers, widths, totalWidth, alignments, false) lines.push(...headerLines) lines.push(createSeparator(totalWidth, 'solid')) } // Add rows for (const row of rows) { const rowLines = formatColumns(row, widths, totalWidth, alignments, false) lines.push(...rowLines) } // Add bottom border lines.push(createSeparator(totalWidth, 'solid')) return lines } /** * Create a product line for receipt * @param name Product name * @param qty Quantity * @param price Unit price * @param total Total price * @param totalWidth Total line width */ export function formatProductLine( name: string, qty: number, price: number, total: number, totalWidth: number, ): string { console.log('[JS] Formatters.formatProductLine - START') console.log('[JS] Formatters.formatProductLine - PARAMS:', {name, qty, price, total, totalWidth}) // Calculate column widths based on total width const widths = totalWidth === 32 ? [14, 6, 6, 6] // 58mm paper : [20, 8, 10, 10] // 80mm paper const columns = [name, formatPrice(price), qty.toString(), formatPrice(total)] const alignments: Array<'left' | 'center' | 'right'> = ['left', 'right', 'center', 'right'] const lines = formatColumns(columns, widths, totalWidth, alignments, false) return lines[0] || '' // Return first line (no wrapping for product line) } /** * Create a summary line (e.g., "Total: 100.000đ") * @param label Label text * @param value Value text or number * @param totalWidth Total line width * @param bold Whether to make it bold (add markers for printer) */ export function formatSummaryLine(label: string, value: string | number, totalWidth: number): string { const valueText = typeof value === 'number' ? formatPrice(value) : value const line = formatLine(label, valueText, totalWidth) // Note: Bold markers would be handled by the printer compiler return line } /** * Create strikethrough line for text * Generates a dash line matching the text length * @param text Text to create strikethrough for * @param width Available width (if text should be aligned) * @param align Alignment of text (affects strikethrough alignment) * @returns Strikethrough line string */ export function createStrikethroughLine( text: string, width?: number, align?: 'left' | 'center' | 'right', ): string { const strikethroughLine = '-'.repeat(text.length) if (!width || width <= text.length) { return strikethroughLine } // Apply same alignment as text switch (align) { case 'center': return padCenter(strikethroughLine, width, ' ') case 'right': return padLeft(strikethroughLine, width, ' ') default: return padRight(strikethroughLine, width, ' ') } }