/** @odoo-module **/ /** * HTML to Image Converter * * Converts HTML receipt elements to images for thermal printer graphics mode. * Uses canvas to render HTML and convert to bitmap format. * * FIXED: Improved rendering to preserve exact HTML layout, use 100% paper width, * and properly render images, borders, backgrounds, and text alignment. * The improved method uses getBoundingClientRect() to calculate actual element * positions and renders them with full styling preservation. */ export class HtmlToImageConverter { constructor() { // Default to 58mm paper width // 58mm paper: 384 pixels (48mm printable * 8 dots/mm) // 80mm paper: 576 pixels (72mm printable * 8 dots/mm) this.paperWidthMm = 58; this.paperWidth = 464; // Default for 58mm (full width) this.dpi = 203; // Default thermal printer DPI (can be 203 or 304) this.scale = 2; // Higher scale for better quality } /** * Convert HTML element to canvas * Uses a simple but effective approach: render visible HTML to canvas * * @param {HTMLElement} element - HTML element to convert * @returns {Promise} Canvas with rendered HTML */ async htmlToCanvas(element) { // Clone the element to avoid modifying the original const clone = element.cloneNode(true); // Apply receipt styling to clone for proper rendering clone.style.width = `${this.paperWidth}px`; clone.style.maxWidth = `${this.paperWidth}px`; clone.style.minWidth = `${this.paperWidth}px`; clone.style.boxSizing = 'border-box'; clone.style.margin = '0'; clone.style.padding = '0'; clone.style.backgroundColor = 'white'; clone.style.color = 'black'; clone.style.overflow = 'visible'; // Scale all fonts by 150% (3x) to match test print readability // Test print uses ESC/POS text mode which has larger fonts const fontScale = 1.2; // Create a temporary container to measure height // Container must be large enough to hold the scaled content const container = document.createElement('div'); container.style.position = 'fixed'; container.style.left = '-9999px'; // Move off-screen instead of using opacity container.style.top = '0'; container.style.width = `${this.paperWidth}px`; container.style.maxWidth = `${this.paperWidth}px`; container.style.minWidth = `${this.paperWidth}px`; container.style.backgroundColor = 'white'; container.style.overflow = 'visible'; container.style.zIndex = '-1000'; container.style.boxSizing = 'border-box'; container.appendChild(clone); document.body.appendChild(container); const allElements = clone.querySelectorAll('*'); allElements.forEach(element => { const style = window.getComputedStyle(element); const currentFontSize = parseFloat(style.fontSize); if (currentFontSize > 0) { const newFontSize = currentFontSize * fontScale; element.style.fontSize = `${newFontSize}px`; } // Also reduce padding to prevent overflow and cropping const paddingLeft = parseFloat(style.paddingLeft) || 0; const paddingRight = parseFloat(style.paddingRight) || 0; // If padding is excessive, reduce it if (paddingLeft > 8) { element.style.paddingLeft = '4px'; } if (paddingRight > 8) { element.style.paddingRight = '4px'; } }); // Also scale the root element font size const rootFontSize = parseFloat(window.getComputedStyle(clone).fontSize); if (rootFontSize > 0) { clone.style.fontSize = `${rootFontSize * fontScale}px`; } // Reduce root padding to prevent cropping clone.style.paddingLeft = '4px'; clone.style.paddingRight = '4px'; // Create a temporary container to measure height // Container must be large enough to hold the scaled content // Duplicate container declaration removed container.style.position = 'fixed'; container.style.left = '-9999px'; // Move off-screen instead of using opacity container.style.top = '0'; container.style.width = `${this.paperWidth}px`; container.style.maxWidth = `${this.paperWidth}px`; container.style.minWidth = `${this.paperWidth}px`; container.style.backgroundColor = 'white'; container.style.overflow = 'visible'; container.style.zIndex = '-1000'; container.style.boxSizing = 'border-box'; container.appendChild(clone); document.body.appendChild(container); try { // Wait for layout to settle, fonts to load, and images to load await new Promise(resolve => setTimeout(resolve, 200)); // Load all images in the clone const images = clone.querySelectorAll('img'); await Promise.all(Array.from(images).map(img => { return new Promise((resolve) => { if (img.complete) { resolve(); } else { img.onload = resolve; img.onerror = resolve; // Continue even if image fails // Timeout after 2 seconds setTimeout(resolve, 2000); } }); })); // Get the actual rendered dimensions const rect = clone.getBoundingClientRect(); const width = this.paperWidth; const height = Math.ceil(rect.height); // Create canvas with exact dimensions const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d', { alpha: false, willReadFrequently: false }); // Fill with white background ctx.fillStyle = 'white'; ctx.fillRect(0, 0, width, height); // Render the DOM to canvas using improved method // Content is already at the correct width, no scaling needed await this._renderDomToCanvasImproved(clone, ctx, 0, 0, width, height); return canvas; } finally { // Clean up document.body.removeChild(container); } } /** * Render DOM element to canvas - Improved method that preserves layout * Recursively renders elements with proper positioning, styling, and images * * @private * @param {HTMLElement} element - Element to render * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} offsetX - X offset for rendering * @param {number} offsetY - Y offset for rendering * @param {number} canvasWidth - Canvas width * @param {number} canvasHeight - Canvas height * @returns {Promise} Height used */ async _renderDomToCanvasImproved(element, ctx, offsetX, offsetY, canvasWidth, canvasHeight) { // Background is already filled, no need to fill again // Get the bounding rect of the root element to calculate offsets const rootRect = element.getBoundingClientRect(); // Render element recursively with proper offset calculation await this._renderElement(element, ctx, -rootRect.left, -rootRect.top, rootRect); return canvasHeight; } /** * Recursively render an element and its children * * @private * @param {HTMLElement} element - Element to render * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} offsetX - X offset * @param {number} offsetY - Y offset * @param {DOMRect} rootRect - Root element bounding rect for reference */ async _renderElement(element, ctx, offsetX, offsetY, rootRect) { if (!element || element.nodeType !== Node.ELEMENT_NODE) { return; } const style = window.getComputedStyle(element); // Skip hidden elements if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { return; } const rect = element.getBoundingClientRect(); const x = rect.left + offsetX; const y = rect.top + offsetY; // Render background const bgColor = style.backgroundColor; if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') { ctx.fillStyle = bgColor; ctx.fillRect(x, y, rect.width, rect.height); } // Render borders this._renderBorders(ctx, x, y, rect.width, rect.height, style); // Render images if (element.tagName === 'IMG') { await this._renderImage(element, ctx, x, y, rect.width, rect.height); } // Render children (text and elements) if (element.childNodes.length > 0) { // Check if this is a specialized text-only element (optimization + wrapping handling) // We keep this optimization for simple blocks, but improve the check // Actually, to fix mixed content, we should just iterate childNodes. // But we need to handle wrapping for long text nodes. // For now, let's assume standard recursion is safer for mixed content. const hasOnlyText = Array.from(element.childNodes).every( node => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') ); // Use the block text renderer mainly for simple text blocks which might need wrapping logic // that _renderText provides (it handles word wrapping based on width). // However, relying ONLY on this means mixed content fails. if (hasOnlyText) { this._renderText(element, ctx, x, y, rect.width, rect.height, style); } else { // Mixed content or complex structure for (const node of element.childNodes) { if (node.nodeType === Node.ELEMENT_NODE) { await this._renderElement(node, ctx, offsetX, offsetY, rootRect); } else if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent.trim(); if (text) { // Text node in mixed content // We need its exact position const range = document.createRange(); range.selectNode(node); const textRect = range.getBoundingClientRect(); // Calculate relative position const textX = textRect.left + offsetX; const textY = textRect.top + offsetY; // Draw the text node using parent's style // Note: Text nodes don't have padding/margin themselves effectively (handled by layout) this._drawTextNode(ctx, text, textX, textY, textRect.width, style); } } } } } } /** * Draw a single text node * * @private * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {string} text - Text to draw * @param {number} x - X position * @param {number} y - Y position * @param {number} width - Node width * @param {CSSStyleDeclaration} style - Computed style of parent */ _drawTextNode(ctx, text, x, y, width, style) { // Set font const fontSize = parseFloat(style.fontSize) || 12; const fontWeight = style.fontWeight; const fontFamily = style.fontFamily.split(',')[0].replace(/['"]/g, '') || 'monospace'; const isBold = fontWeight === 'bold' || parseInt(fontWeight) >= 600; ctx.font = `${isBold ? 'bold' : 'normal'} ${fontSize}px ${fontFamily}`; ctx.fillStyle = style.color || 'black'; ctx.textBaseline = 'top'; // Align to top of rect // Handle alignment - though for a text node range rect, the rect SHOULD already be aligned by layout // So we can arguably just draw at x. // However, range rects can be tricky. // Simple draw for now - rely on DOM layout for positioning ctx.textAlign = 'left'; // Text nodes usually don't need wrapping if they are part of a laid-out line // (the browser already wrapped them into lines, and if it spans lines, we might get a big rect) // If it spans lines, this simple fillText might be issue, but for missing words like "Total", it's fine. ctx.fillText(text, x, y); } /** * Render text content of an element * * @private * @param {HTMLElement} element - Element containing text * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} x - X position * @param {number} y - Y position * @param {number} width - Element width * @param {number} height - Element height * @param {CSSStyleDeclaration} style - Computed style */ _renderText(element, ctx, x, y, width, height, style) { const text = element.textContent.trim(); if (!text) return; // Set font const fontSize = parseFloat(style.fontSize) || 12; const fontWeight = style.fontWeight; const fontFamily = style.fontFamily.split(',')[0].replace(/['"]/g, '') || 'monospace'; const isBold = fontWeight === 'bold' || parseInt(fontWeight) >= 600; ctx.font = `${isBold ? 'bold' : 'normal'} ${fontSize}px ${fontFamily}`; ctx.fillStyle = style.color || 'black'; ctx.textBaseline = 'top'; // Handle text alignment const textAlign = style.textAlign || 'left'; const paddingLeft = parseFloat(style.paddingLeft || 0); const paddingRight = parseFloat(style.paddingRight || 0); const paddingTop = parseFloat(style.paddingTop || 0); let textX = x + paddingLeft; if (textAlign === 'center') { ctx.textAlign = 'center'; textX = x + width / 2; } else if (textAlign === 'right') { ctx.textAlign = 'right'; textX = x + width - paddingRight; } else { ctx.textAlign = 'left'; } const textY = y + paddingTop; // Word wrap if needed const maxWidth = width - paddingLeft - paddingRight; const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.4; this._wrapAndDrawText(ctx, text, textX, textY, maxWidth, lineHeight); // Reset text align ctx.textAlign = 'left'; } /** * Wrap and draw text with proper line breaks * * @private * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {string} text - Text to draw * @param {number} x - X position * @param {number} y - Y position * @param {number} maxWidth - Maximum width * @param {number} lineHeight - Line height */ _wrapAndDrawText(ctx, text, x, y, maxWidth, lineHeight) { const words = text.split(' '); let line = ''; let currentY = y; for (let i = 0; i < words.length; i++) { const testLine = line + (line ? ' ' : '') + words[i]; const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && line) { ctx.fillText(line, x, currentY); line = words[i]; currentY += lineHeight; } else { line = testLine; } } if (line) { ctx.fillText(line, x, currentY); } } /** * Render borders of an element * * @private * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} x - X position * @param {number} y - Y position * @param {number} width - Element width * @param {number} height - Element height * @param {CSSStyleDeclaration} style - Computed style */ _renderBorders(ctx, x, y, width, height, style) { const borderTop = parseFloat(style.borderTopWidth) || 0; const borderRight = parseFloat(style.borderRightWidth) || 0; const borderBottom = parseFloat(style.borderBottomWidth) || 0; const borderLeft = parseFloat(style.borderLeftWidth) || 0; if (borderTop > 0) { ctx.strokeStyle = style.borderTopColor || 'black'; ctx.lineWidth = borderTop; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + width, y); ctx.stroke(); } if (borderRight > 0) { ctx.strokeStyle = style.borderRightColor || 'black'; ctx.lineWidth = borderRight; ctx.beginPath(); ctx.moveTo(x + width, y); ctx.lineTo(x + width, y + height); ctx.stroke(); } if (borderBottom > 0) { ctx.strokeStyle = style.borderBottomColor || 'black'; ctx.lineWidth = borderBottom; ctx.beginPath(); ctx.moveTo(x, y + height); ctx.lineTo(x + width, y + height); ctx.stroke(); } if (borderLeft > 0) { ctx.strokeStyle = style.borderLeftColor || 'black'; ctx.lineWidth = borderLeft; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + height); ctx.stroke(); } } /** * Render an image element * * @private * @param {HTMLImageElement} img - Image element * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} x - X position * @param {number} y - Y position * @param {number} width - Display width * @param {number} height - Display height */ async _renderImage(img, ctx, x, y, width, height) { try { if (img.complete && img.naturalWidth > 0) { ctx.drawImage(img, x, y, width, height); } } catch (error) { console.warn('[HtmlToImage] Failed to render image:', error); } } /** * Render DOM element to canvas - Simple fallback method * Extracts text content and renders it line by line with proper formatting * * @private * @param {HTMLElement} element - Element to render * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} width - Canvas width * @param {number} height - Canvas height */ async _renderDomToCanvas(element, ctx, width, height) { // Set default styles ctx.fillStyle = 'white'; ctx.fillRect(0, 0, width, height); ctx.fillStyle = 'black'; ctx.textBaseline = 'top'; ctx.font = '12px monospace'; const padding = 10; const lineHeight = 16; const maxWidth = width - (padding * 2); let y = padding; // Helper function to wrap text const wrapText = (text, maxWidth) => { const words = text.split(' '); const lines = []; let currentLine = ''; for (const word of words) { const testLine = currentLine + (currentLine ? ' ' : '') + word; const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && currentLine) { lines.push(currentLine); currentLine = word; } else { currentLine = testLine; } } if (currentLine) { lines.push(currentLine); } return lines; }; // Helper to draw text with alignment const drawText = (text, align = 'left', bold = false) => { if (!text || !text.trim()) return; ctx.font = `${bold ? 'bold' : 'normal'} 12px monospace`; const lines = wrapText(text, maxWidth); for (const line of lines) { let x = padding; const textWidth = ctx.measureText(line).width; if (align === 'center') { x = (width - textWidth) / 2; } else if (align === 'right') { x = width - textWidth - padding; } ctx.fillText(line, x, y); y += lineHeight; } }; // Helper to draw a line const drawLine = () => { ctx.beginPath(); ctx.moveTo(padding, y); ctx.lineTo(width - padding, y); ctx.strokeStyle = 'black'; ctx.lineWidth = 1; ctx.stroke(); y += lineHeight; }; // Extract and render content recursively const processElement = (el) => { if (!el) return; const style = window.getComputedStyle(el); const display = style.display; // Skip hidden elements if (display === 'none' || style.visibility === 'hidden') { return; } const tagName = el.tagName; const textAlign = style.textAlign || 'left'; const fontWeight = style.fontWeight; const isBold = fontWeight === 'bold' || parseInt(fontWeight) >= 600; // Handle special elements if (tagName === 'BR') { y += lineHeight; return; } if (tagName === 'HR') { drawLine(); return; } // Check if element has only text content (no child elements) const hasOnlyText = Array.from(el.childNodes).every( node => node.nodeType === Node.TEXT_NODE ); if (hasOnlyText) { const text = el.textContent.trim(); if (text) { drawText(text, textAlign, isBold); } } else { // Process children for (const child of el.childNodes) { if (child.nodeType === Node.TEXT_NODE) { const text = child.textContent.trim(); if (text) { drawText(text, textAlign, isBold); } } else if (child.nodeType === Node.ELEMENT_NODE) { processElement(child); } } } // Add spacing after block elements if (display === 'block' || tagName === 'DIV' || tagName === 'P' || tagName === 'TABLE') { y += lineHeight / 2; } }; // Start processing try { processElement(element); } catch (error) { console.error('[HtmlToImage] Error during rendering:', error); // Ultimate fallback - just print all text const allText = element.textContent || ''; const lines = allText.split('\n').filter(l => l.trim()); y = padding; for (const line of lines) { drawText(line.trim()); } } } /** * Convert canvas to monochrome bitmap (OPTIMIZED FOR SPEED) * * @param {HTMLCanvasElement} canvas - Canvas to convert * @returns {Uint8Array} Bitmap data */ canvasToBitmap(canvas) { const startTime = performance.now(); const ctx = canvas.getContext('2d', { willReadFrequently: true }); const width = canvas.width; const height = canvas.height; // Get image data const imageData = ctx.getImageData(0, 0, width, height); const pixels = imageData.data; // Convert to monochrome bitmap // Each byte represents 8 pixels (1 bit per pixel) const bytesPerLine = Math.ceil(width / 8); const bitmapData = new Uint8Array(bytesPerLine * height); // Optimized conversion using lookup table and bitwise operations // Pre-calculate grayscale weights for faster conversion const rWeight = 0.299; const gWeight = 0.587; const bWeight = 0.114; let byteIndex = 0; let currentByte = 0; let bitPosition = 7; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const pixelIndex = (y * width + x) * 4; // Fast grayscale conversion using weighted average const gray = pixels[pixelIndex] * rWeight + pixels[pixelIndex + 1] * gWeight + pixels[pixelIndex + 2] * bWeight; // Threshold to black or white if (gray < 128) { currentByte |= (1 << bitPosition); } bitPosition--; if (bitPosition < 0) { bitmapData[byteIndex++] = currentByte; currentByte = 0; bitPosition = 7; } } // Handle remaining bits at end of line if (bitPosition !== 7) { bitmapData[byteIndex++] = currentByte; currentByte = 0; bitPosition = 7; } } const endTime = performance.now(); return { data: bitmapData, width: width, height: height, bytesPerLine: bytesPerLine }; } /** * Convert HTML element to bitmap * * @param {HTMLElement} element - HTML element to convert * @returns {Promise} Bitmap data with dimensions */ async htmlToBitmap(element) { try { // Convert HTML to canvas const canvas = await this.htmlToCanvas(element); // Convert canvas to bitmap const bitmap = this.canvasToBitmap(canvas); return bitmap; } catch (error) { console.error('[HtmlToImage] Conversion failed:', error); throw error; } } /** * Set paper width in millimeters and DPI * * @param {number} widthMm - Paper width in millimeters (58 or 80) * @param {number} dpi - Printer DPI (203 or 304), defaults to 203 */ setPaperWidth(widthMm, dpi = 203) { this.paperWidthMm = widthMm; this.dpi = dpi; // Calculate dots per mm based on DPI // 203 DPI = 8 dots/mm (203 / 25.4) // 304 DPI = 12 dots/mm (304 / 25.4) const dotsPerMm = Math.round(dpi / 25.4); // Calculate pixel width based on PRINTABLE width (essential for thermal printers) // 58mm Paper -> ~48mm Printable // 80mm Paper -> ~72mm Printable let printableWidthMm; if (widthMm === 58) { printableWidthMm = 48; // Standard printable width for 58mm } else if (widthMm === 80) { printableWidthMm = 72; // Standard printable width for 80mm } else { // Custom width: subtract ~10mm margin printableWidthMm = Math.max(widthMm - 10, widthMm * 0.8); } this.paperWidth = Math.floor(printableWidthMm * dotsPerMm); } } export default HtmlToImageConverter;