781 lines
27 KiB
JavaScript
781 lines
27 KiB
JavaScript
/** @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<HTMLCanvasElement>} 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<number>} 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<Object>} 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;
|