diff --git a/TESTING_SCENARIOS.md b/TESTING_SCENARIOS.md deleted file mode 100644 index 015b29a..0000000 --- a/TESTING_SCENARIOS.md +++ /dev/null @@ -1,170 +0,0 @@ -# Testing Scenarios - -## Current Status - -The code is working! It correctly detects when no Bluetooth printer is configured and attempts to fall back to standard print. - -## Enhanced Logging - -Added comprehensive logging to track: -1. When `originalPrintHtml` is called -2. What it returns -3. Any errors that occur -4. Connection status checks -5. Reconnection attempts - -## Test Scenarios - -### Scenario 1: No Bluetooth Printer Configured ✅ -**Current behavior:** -``` -[BluetoothPrint] printHtml() called -[BluetoothPrint] Web Bluetooth API available -[BluetoothPrint] No Bluetooth printer configured, using standard print -[BluetoothPrint] Calling originalPrintHtml with: [element] -[BluetoothPrint] originalPrintHtml returned: [result] -``` - -**Expected:** Browser print dialog should open -**If not working:** Check the console for errors from `originalPrintHtml` - -### Scenario 2: Bluetooth Printer Configured but Not Connected -**Steps:** -1. Connect to printer once (saves config) -2. Disconnect printer or turn it off -3. Try to print receipt - -**Expected logs:** -``` -[BluetoothPrint] Bluetooth printer configured: RPP02N -[BluetoothPrint] Current connection status: disconnected -[BluetoothPrint] Printer not connected, attempting to reconnect... -[BluetoothPrint] Reconnection failed: [error] -[BluetoothPrint] Falling back to standard print -``` - -**Expected:** Browser print dialog should open - -### Scenario 3: Bluetooth Printer Connected ✅ -**Steps:** -1. Click Bluetooth icon -2. Connect to RPP02N -3. Make a sale and print - -**Expected logs:** -``` -[BluetoothPrint] Bluetooth printer configured: RPP02N -[BluetoothPrint] Current connection status: connected -[BluetoothPrint] Attempting bluetooth print... -[BluetoothPrint] Starting bluetooth print from HTML... -[BluetoothPrint] Parsing receipt data from HTML... -[BluetoothPrint] Parsed X lines from HTML -[BluetoothPrint] Generating ESC/POS commands... -[BluetoothPrint] Generated XXX bytes of ESC/POS data -[BluetoothPrint] Sending to printer... -[BluetoothPrint] Print completed successfully -``` - -**Expected:** Receipt prints to Bluetooth printer - -### Scenario 4: Bluetooth Print Fails Mid-Process -**What happens:** Connection drops during print - -**Expected logs:** -``` -[BluetoothPrint] Attempting bluetooth print... -[BluetoothPrint] Bluetooth print failed: [error] -[BluetoothPrint] Falling back to standard print after error -[BluetoothPrint] Fallback print returned: [result] -``` - -**Expected:** Browser print dialog opens as fallback - -## What to Test Now - -### Test 1: Connect Bluetooth Printer -1. Update module: `./odoo-bin -u pos_bluetooth_thermal_printer -d your_database` -2. Clear browser cache -3. Click Bluetooth icon in POS -4. Scan and connect to RPP02N -5. Make a sale and print receipt -6. **Check console logs** -7. **Verify receipt prints to Bluetooth printer** - -### Test 2: Standard Print Fallback -1. Disconnect Bluetooth printer (turn off or unpair) -2. Make a sale and print receipt -3. **Check console logs** - should show reconnection attempt -4. **Verify browser print dialog opens** - -## Debugging Standard Print Issue - -If standard print (browser dialog) doesn't open, check: - -### 1. Check Console for Errors -Look for: -``` -[BluetoothPrint] Error calling originalPrintHtml: [error] -[BluetoothPrint] Standard print failed: [error] -[BluetoothPrint] Fallback print also failed: [error] -``` - -### 2. Check originalPrintHtml Return Value -Look for: -``` -[BluetoothPrint] originalPrintHtml returned: [value] -``` - -If it returns `undefined` or `null`, the original method might not be opening the print dialog. - -### 3. Possible Issues - -**Issue A: Original method doesn't exist** -- `originalPrintHtml` might be `undefined` -- Check if `PosPrinterService.prototype.printHtml` exists before patching - -**Issue B: Original method needs different context** -- Might need to pass additional parameters -- Might need different `this` binding - -**Issue C: Browser blocks print dialog** -- Some browsers block print dialogs not triggered by user action -- Might need user interaction first - -### 4. Alternative Fallback - -If `originalPrintHtml` doesn't work, we can implement a direct print fallback: - -```javascript -// Direct browser print as last resort -const printWindow = window.open('', '_blank'); -printWindow.document.write(el.outerHTML); -printWindow.document.close(); -printWindow.print(); -``` - -## Expected Results After Update - -### With Bluetooth Printer Connected: -✅ Receipt prints to Bluetooth printer automatically -✅ No browser print dialog -✅ Console shows successful print process - -### Without Bluetooth Printer: -✅ Console shows "No Bluetooth printer configured" -✅ Falls back to standard print -✅ Browser print dialog opens (or should open) - -### With Bluetooth Error: -✅ Console shows error details -✅ Falls back to standard print -✅ Sale completes successfully - -## Next Steps - -1. **Update module** -2. **Test with Bluetooth printer connected** - Should print! -3. **Test without Bluetooth printer** - Check if standard print works -4. **Share console logs** if any issues - -The Bluetooth printing should work now when printer is connected! 🎉 diff --git a/static/src/js/bluetooth_printer_config.js b/static/src/js/bluetooth_printer_config.js index f29c4c1..b970a73 100644 --- a/static/src/js/bluetooth_printer_config.js +++ b/static/src/js/bluetooth_printer_config.js @@ -39,6 +39,7 @@ export class BluetoothPrinterConfig extends Component { characterSet: 'CP437', paperWidth: 48, paperWidthMm: 58, // Paper width in millimeters (58mm or 80mm) + dpi: 203, // Printer DPI (203 or 304) autoReconnect: true, timeout: 10000, @@ -83,6 +84,7 @@ export class BluetoothPrinterConfig extends Component { this.state.characterSet = config.settings.characterSet || 'CP437'; this.state.paperWidth = config.settings.paperWidth || 48; this.state.paperWidthMm = config.settings.paperWidthMm || 58; + this.state.dpi = config.settings.dpi || 203; this.state.autoReconnect = config.settings.autoReconnect !== false; this.state.timeout = config.settings.timeout || 10000; } @@ -287,6 +289,15 @@ export class BluetoothPrinterConfig extends Component { this._saveConfiguration(); } + /** + * Handle DPI change + * @param {Event} event - Change event + */ + onDpiChange(event) { + this.state.dpi = parseInt(event.target.value, 10); + this._saveConfiguration(); + } + /** * Handle auto-reconnect toggle * @param {Event} event - Change event @@ -325,6 +336,7 @@ export class BluetoothPrinterConfig extends Component { characterSet: this.state.characterSet, paperWidth: this.state.paperWidth, paperWidthMm: this.state.paperWidthMm, + dpi: this.state.dpi, autoReconnect: this.state.autoReconnect, timeout: this.state.timeout } diff --git a/static/src/js/html_to_image.js b/static/src/js/html_to_image.js index 38f5d3d..833484f 100644 --- a/static/src/js/html_to_image.js +++ b/static/src/js/html_to_image.js @@ -5,6 +5,11 @@ * * 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 { @@ -13,8 +18,8 @@ export class HtmlToImageConverter { // 58mm paper: 384 pixels (48mm printable * 8 dots/mm) // 80mm paper: 576 pixels (72mm printable * 8 dots/mm) this.paperWidthMm = 58; - this.paperWidth = 384; // Default for 58mm - this.dpi = 203; // Typical thermal printer DPI + 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 } @@ -37,42 +42,100 @@ export class HtmlToImageConverter { clone.style.maxWidth = `${this.paperWidth}px`; clone.style.minWidth = `${this.paperWidth}px`; clone.style.boxSizing = 'border-box'; - clone.style.padding = '10px'; + clone.style.margin = '0'; + clone.style.padding = '0'; clone.style.backgroundColor = 'white'; clone.style.color = 'black'; - clone.style.fontFamily = 'monospace, Courier, "Courier New"'; - clone.style.fontSize = '12px'; - clone.style.lineHeight = '1.4'; + clone.style.overflow = 'visible'; + + // Scale all fonts by 100% (2x) to match test print readability + // Test print uses ESC/POS text mode which has larger fonts + const fontScale = 2.0; + console.log('[HtmlToImage] Scaling fonts by', fontScale, 'x to match test print readability'); + + 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'; + + console.log('[HtmlToImage] Fonts scaled 2x, padding reduced, rendering at paper width:', this.paperWidth, 'px'); // 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 = '0'; + 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.opacity = '0'; + container.style.boxSizing = 'border-box'; + container.appendChild(clone); document.body.appendChild(container); try { - // Wait for layout to settle and fonts to load - await new Promise(resolve => setTimeout(resolve, 100)); + // 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 = container.getBoundingClientRect(); + const rect = clone.getBoundingClientRect(); const width = this.paperWidth; - const height = Math.max(Math.ceil(rect.height), 100); + const height = Math.ceil(rect.height); - console.log('[HtmlToImage] Measured dimensions:', width, 'x', height); + console.log('[HtmlToImage] Rendered dimensions:', rect.width, 'x', rect.height); + console.log('[HtmlToImage] Final canvas size:', width, 'x', height); console.log('[HtmlToImage] Container rect:', rect); console.log('[HtmlToImage] Receipt HTML preview (first 1000 chars):', clone.outerHTML.substring(0, 1000)); console.log('[HtmlToImage] Receipt text content:', clone.textContent.substring(0, 500)); - // Create canvas + // Create canvas with exact dimensions const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; @@ -82,9 +145,10 @@ export class HtmlToImageConverter { ctx.fillStyle = 'white'; ctx.fillRect(0, 0, width, height); - // Render the DOM to canvas manually + // Render the DOM to canvas using improved method + // Content is already at the correct width, no scaling needed console.log('[HtmlToImage] Rendering DOM to canvas...'); - await this._renderDomToCanvas(clone, ctx, width, height); + await this._renderDomToCanvasImproved(clone, ctx, 0, 0, width, height); console.log('[HtmlToImage] Canvas rendering complete'); return canvas; @@ -96,7 +160,258 @@ export class HtmlToImageConverter { } /** - * Render DOM element to canvas - Simple and reliable approach + * 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) { + console.log('[HtmlToImage] Rendering DOM to canvas (improved method)...'); + + // 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); + + console.log('[HtmlToImage] Rendering complete'); + 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 text content + if (element.childNodes.length > 0) { + const hasOnlyText = Array.from(element.childNodes).every( + node => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') + ); + + if (hasOnlyText) { + this._renderText(element, ctx, x, y, rect.width, rect.height, style); + } else { + // Render children recursively + for (const child of element.children) { + await this._renderElement(child, ctx, offsetX, offsetY, rootRect); + } + } + } + } + + /** + * 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 @@ -106,7 +421,7 @@ export class HtmlToImageConverter { * @param {number} height - Canvas height */ async _renderDomToCanvas(element, ctx, width, height) { - console.log('[HtmlToImage] Rendering DOM to canvas (simple method)...'); + console.log('[HtmlToImage] Rendering DOM to canvas (simple fallback method)...'); // Set default styles ctx.fillStyle = 'white'; @@ -356,28 +671,34 @@ export class HtmlToImageConverter { } /** - * Set paper width in millimeters + * 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) { + setPaperWidth(widthMm, dpi = 203) { this.paperWidthMm = widthMm; + this.dpi = dpi; - // Calculate pixel width based on paper size - // Thermal printers: 8 dots per mm - // Account for margins (5mm on each side) + // 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 paper size and DPI + // Use FULL width without margins for maximum usage if (widthMm === 58) { - // 58mm paper: 48mm printable width = 384 pixels - this.paperWidth = 384; + // 58mm paper at selected DPI + this.paperWidth = widthMm * dotsPerMm; } else if (widthMm === 80) { - // 80mm paper: 72mm printable width = 576 pixels - this.paperWidth = 576; + // 80mm paper at selected DPI + this.paperWidth = widthMm * dotsPerMm; } else { - // Custom width: use full width minus 10mm margins - this.paperWidth = (widthMm - 10) * 8; + // Custom width: use full width + this.paperWidth = widthMm * dotsPerMm; } - console.log('[HtmlToImage] Setting paper width to', widthMm, 'mm (', this.paperWidth, 'pixels)'); + console.log('[HtmlToImage] Setting paper width to', widthMm, 'mm at', dpi, 'DPI (', dotsPerMm, 'dots/mm,', this.paperWidth, 'pixels)'); } } diff --git a/static/src/js/pos_receipt_printer.js b/static/src/js/pos_receipt_printer.js index c70ebd5..0f424ed 100644 --- a/static/src/js/pos_receipt_printer.js +++ b/static/src/js/pos_receipt_printer.js @@ -324,12 +324,14 @@ patch(PosPrinterService.prototype, { const converter = new HtmlToImageConverter(); console.log('[BluetoothPrint] HtmlToImageConverter created successfully'); - // Get paper width from configuration + // Get paper width and DPI from configuration const paperWidthMm = config?.settings?.paperWidthMm || 58; + const dpi = config?.settings?.dpi || 203; console.log('[BluetoothPrint] Using paper width:', paperWidthMm, 'mm'); - console.log('[BluetoothPrint] Setting paper width on converter...'); - converter.setPaperWidth(paperWidthMm); - console.log('[BluetoothPrint] Paper width set successfully'); + console.log('[BluetoothPrint] Using printer DPI:', dpi); + console.log('[BluetoothPrint] Setting paper width and DPI on converter...'); + converter.setPaperWidth(paperWidthMm, dpi); + console.log('[BluetoothPrint] Paper width and DPI set successfully'); console.log('[BluetoothPrint] Converting HTML to bitmap...'); const bitmap = await converter.htmlToBitmap(el); diff --git a/static/src/xml/bluetooth_printer_config.xml b/static/src/xml/bluetooth_printer_config.xml index 6240afa..a3d1959 100644 --- a/static/src/xml/bluetooth_printer_config.xml +++ b/static/src/xml/bluetooth_printer_config.xml @@ -127,6 +127,21 @@ + +
+ + + + Select your printer's resolution. Most thermal printers use 203 DPI. Use 304 DPI for higher quality printers. + +
+