# edited by glg import os from typing import Dict, List from PySide6.QtGui import QFont, QPainter from PySide6.QtPrintSupport import QPrinter from pypos.core.utils.path_utils import get_app_data_resource_dir try: import qrcode QRCODE_AVAILABLE = True except ImportError: QRCODE_AVAILABLE = False class PrinterRawPainterRenderService: def __init__(self, controller): self.controller = controller def build_renderer(self, items: List[Dict], payment: Dict, wifi_code: str, qr_data: str, cols: int, setting: Dict, transaksi_data_dict: Dict, width_pt: float = None, printer: QPrinter = None): controller = self.controller from PySide6.QtCore import Qt, QRect def render_with_painter(painter: QPainter): if printer: # pageRect() = printable area (CRITICAL: ini yang benar untuk thermal printer) page_rect = printer.pageRect(QPrinter.DevicePixel) # Use printable page width - INI yang benar, bukan paperRect! page_width = page_rect.width() page_height = page_rect.height() controller.log_debug(f"[ThermalFix] Printable page width: {page_width}px") controller.log_debug(f"[ThermalFix] Page height: {page_height}px") elif width_pt: # Fallback: convert pt to pixels page_width = int(width_pt * (printer.logicalDpiX() / 72.0) if printer else width_pt * 2.83) page_height = 3000 else: page_width = 450 page_height = 3000 # FIX: Minimize margins untuk thermal printer (maximize usable width) margin_left = 3 # Minimal margin (kurangi dari 5 ke 3) margin_right = 3 # Minimal margin (kurangi dari 5 ke 3) margin_top = 5 margin_bottom = 5 # X start position = left margin x_start = margin_left # Ini adalah ACTUAL width yang available untuk content rendering available_width = page_width - margin_right - x_start controller.log_debug(f"[ThermalFix] Page width: {page_width}px, Margins L/R: {margin_left}/{margin_right}px") controller.log_debug(f"[ThermalFix] X start: {x_start}px, Available width: {available_width}px") # Font setup - ukuran diperkecil untuk thermal printer 58mm font_normal = QFont("DejaVu Sans Mono", 5) # Kurangi dari 6 ke 5 font_bold = QFont("DejaVu Sans Mono", 6) # Kurangi dari 7 ke 6 font_bold.setBold(True) font_small = QFont("DejaVu Sans Mono", 4) # Kurangi dari 5 ke 4 painter.setFont(font_normal) # Get font metrics untuk line height yang tepat fm = painter.fontMetrics() line_height = fm.height() + 2 # Tambah 2px spacing y = margin_top + 5 # Start position dengan margin top # === LOGO (CENTER) === logo_path = setting.get("logo", "") if logo_path: full_logo_path = os.path.join(get_app_data_resource_dir(), "logo", logo_path) if os.path.exists(full_logo_path): from PySide6.QtGui import QPixmap pixmap = QPixmap(full_logo_path) if not pixmap.isNull(): scaled = pixmap.scaled(75, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation) x_center = x_start + (available_width - scaled.width()) // 2 painter.drawPixmap(x_center, y, scaled) y += scaled.height() + 10 # === HEADER (CENTER ALIGN) === for i in range(1, 4): header_text = setting.get(f"header{i}", "") if header_text: if i == 1: painter.setFont(font_bold) fm = painter.fontMetrics() lh = fm.height() + 2 else: painter.setFont(font_normal) fm = painter.fontMetrics() lh = fm.height() + 2 rect = QRect(x_start, y, available_width, lh) painter.drawText(rect, Qt.AlignHCenter | Qt.AlignTop, header_text) y += lh painter.setFont(font_normal) fm = painter.fontMetrics() line_height = fm.height() + 2 def _rule_line(): fm_local = painter.fontMetrics() dash_w = max(1, fm_local.horizontalAdvance("-")) count = max(1, int(available_width / dash_w)) return "-" * count # Separator rect = QRect(x_start, y, available_width, line_height) painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, _rule_line()) y += line_height # === INVOICE LABEL (BOLD CENTER) === painter.setFont(font_bold) fm_bold = painter.fontMetrics() lh_bold = fm_bold.height() + 2 rect = QRect(x_start, y, available_width, lh_bold) painter.drawText(rect, Qt.AlignHCenter | Qt.AlignTop, "INVOICE") y += lh_bold # Reset to normal font painter.setFont(font_normal) fm = painter.fontMetrics() line_height = fm.height() + 2 # === TRANSACTION INFO (LEFT LABEL, RIGHT VALUE) === inv_no = transaksi_data_dict.get("nomer", "-") dtime = transaksi_data_dict.get("dtime", "-") kasir = transaksi_data_dict.get("oleh_nama", "-") customer = transaksi_data_dict.get("customers_nama", "Umum") def _wrap_text_by_width(text: str, max_width: int): lines = [] current = "" for ch in str(text or ""): test = current + ch if current and fm.horizontalAdvance(test) > max_width: lines.append(current) current = ch else: current = test if current: lines.append(current) return lines or [""] # Helper function for two-column layout def draw_two_column(label: str, value: str): nonlocal y, x_start, available_width, painter fm = painter.fontMetrics() label_width = int(available_width * 0.35) gap = 6 value_width = max(1, available_width - label_width - gap) value_text = str(value or "") if fm.horizontalAdvance(value_text) <= value_width: rect_label = QRect(x_start, y, label_width, line_height) painter.drawText(rect_label, Qt.AlignLeft | Qt.AlignTop, label) rect_value = QRect(x_start + label_width + gap, y, value_width, line_height) painter.drawText(rect_value, Qt.AlignRight | Qt.AlignTop, value_text) y += line_height return rect_label = QRect(x_start, y, available_width, line_height) painter.drawText(rect_label, Qt.AlignLeft | Qt.AlignTop, f"{label}:") y += line_height for part in _wrap_text_by_width(value_text, available_width): rect_value = QRect(x_start, y, available_width, line_height) painter.drawText(rect_value, Qt.AlignLeft | Qt.AlignTop, part) y += line_height draw_two_column('No. Inv', inv_no) draw_two_column('Tanggal', dtime) draw_two_column('Kasir', kasir) draw_two_column('Customer', customer) # Separator rect = QRect(x_start, y, available_width, line_height) painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, _rule_line()) y += line_height # === ITEMS === for item in items: name = item.get("name", "Item") qty = item.get("qty", 1) price = item.get("price", 0) disc = item.get("disc", 0) is_free = item.get("free", False) subtotal = (price * qty) - disc if is_free: subtotal = 0 # Item name - left align with truncation fm = painter.fontMetrics() elided_name = fm.elidedText(name, Qt.ElideRight, available_width) rect = QRect(x_start, y, available_width, line_height) painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, elided_name) y += line_height # Qty x Price - left, Subtotal - right (same line) qty_price = f"{qty} x {price:,}" rect_left = QRect(x_start, y, int(available_width * 0.6), line_height) painter.drawText(rect_left, Qt.AlignLeft | Qt.AlignTop, qty_price) if disc > 0: # Show discount on same line - gunakan available_width rect_right = QRect(x_start, y, available_width, line_height) painter.drawText(rect_right, Qt.AlignRight | Qt.AlignTop, f'-{disc:,}') y += line_height # Show subtotal after discount rect_right = QRect(x_start, y, available_width, line_height) painter.drawText(rect_right, Qt.AlignRight | Qt.AlignTop, f'{subtotal:,}') y += line_height elif is_free: rect_right = QRect(x_start, y, available_width, line_height) painter.drawText(rect_right, Qt.AlignRight | Qt.AlignTop, 'FREE') y += line_height else: rect_right = QRect(x_start, y, available_width, line_height) painter.drawText(rect_right, Qt.AlignRight | Qt.AlignTop, f'{subtotal:,}') y += line_height # Separator rect = QRect(x_start, y, available_width, line_height) painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, _rule_line()) y += line_height # === TOTAL BREAKDOWN === # Calculate breakdown diskon_persen = transaksi_data_dict.get("diskon_persen", 0) ppn_nominal = transaksi_data_dict.get("ppn_persen", 0) # Ini sebenarnya nominal bukan persen ppn_mode = controller._resolve_ppn_mode(transaksi_data_dict) nilai_total = transaksi_data_dict.get("transaksi_nilai", 0) # Total setelah diskon + ppn bulat = transaksi_data_dict.get("transaksi_bulat", nilai_total) # Grand total final (untuk referensi) # Hitung subtotal SEBELUM diskon item (qty x price, tanpa kurangi disc) subtotal_sebelum_diskon_item = sum( (item.get("price", 0) * item.get("qty", 0)) if not item.get("free", False) else 0 for item in items ) # Hitung total diskon per item total_diskon_item = sum( item.get("disc", 0) if not item.get("free", False) else 0 for item in items ) # Subtotal SETELAH diskon item (qty x price - disc per item) subtotal_setelah_diskon_item = sum( ((item.get("price", 0) * item.get("qty", 0)) - item.get("disc", 0)) if not item.get("free", False) else 0 for item in items ) # Diskon tambahan (diskon global/diskon persen) diskon_tambahan_nominal = 0 if diskon_persen > 0: diskon_tambahan_nominal = int(subtotal_setelah_diskon_item * diskon_persen / 100) # Total setelah semua diskon (sebelum ppn) total_setelah_semua_diskon = subtotal_setelah_diskon_item - diskon_tambahan_nominal # GRAND TOTAL = TOTAL + PPN grand_total_final = total_setelah_semua_diskon + ppn_nominal ppn_label = "PPN" if ppn_mode == "include": grand_total_final = total_setelah_semua_diskon ppn_label = "Termasuk PPN" painter.setFont(font_normal) fm_normal = painter.fontMetrics() line_height_normal = fm_normal.height() + 2 # Helper function for total breakdown def draw_total_line(label: str, value: str, bold: bool = False): nonlocal y, x_start, available_width, painter if bold: painter.setFont(font_bold) fm_temp = painter.fontMetrics() lh_temp = fm_temp.height() + 2 else: painter.setFont(font_normal) fm_temp = painter.fontMetrics() lh_temp = fm_temp.height() + 2 # Label on left (50% of available width) label_width = int(available_width * 0.5) rect_label = QRect(x_start, y, label_width, lh_temp) painter.drawText(rect_label, Qt.AlignLeft | Qt.AlignTop, label) # CRITICAL FIX: Truncate value if too long value_max_width = int(available_width * 0.50) elided_value = fm_temp.elidedText(value, Qt.ElideRight, value_max_width) # Value on right - gunakan available_width dengan AlignRight rect_value = QRect(x_start, y, available_width, lh_temp) painter.drawText(rect_value, Qt.AlignRight | Qt.AlignTop, elided_value) y += lh_temp # Subtotal (sebelum diskon item) draw_total_line('Subtotal', f'Rp {subtotal_sebelum_diskon_item:,}', False) # Diskon per item (jika ada) if total_diskon_item > 0: draw_total_line('Diskon Item', f'-Rp {total_diskon_item:,}', False) # Diskon tambahan/global (jika ada) if diskon_tambahan_nominal > 0: draw_total_line(f'Diskon Tambahan ({diskon_persen}%)', f'-Rp {diskon_tambahan_nominal:,}', False) # TOTAL (setelah semua diskon, sebelum ppn) draw_total_line('TOTAL', f'Rp {total_setelah_semua_diskon:,}', True) # PPN (jika ada) if ppn_nominal > 0: draw_total_line(ppn_label, f'Rp {ppn_nominal:,}', False) # GRAND TOTAL (final) = TOTAL + PPN draw_total_line('GRAND TOTAL', f'Rp {grand_total_final:,}', True) point_transaksi = controller._resolve_point_transaksi_value(transaksi_data_dict) if point_transaksi > 0: draw_total_line('POINT DIPEROLEH', f'{point_transaksi:,}', False) # edited by glg free_items_summary = transaksi_data_dict.get("free_items_summary", []) or [] if free_items_summary: painter.setFont(font_normal) fm_free = painter.fontMetrics() lh_free = fm_free.height() + 2 rect = QRect(x_start, y, available_width, lh_free) painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, "FREE ITEM:") y += lh_free for free_item in free_items_summary: nama = str(free_item.get("nama", "-") or "-") try: qty = int(free_item.get("qty", 0) or 0) except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError): qty = 0 if qty <= 0: continue line = f"- {qty} x {nama}" elided_line = fm_free.elidedText(line, Qt.ElideRight, available_width) rect = QRect(x_start, y, available_width, lh_free) painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, elided_line) y += lh_free painter.setFont(font_normal) line_height = fm.height() + 2 # Reset to normal line height # Separator rect = QRect(x_start, y, available_width, line_height) painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, _rule_line()) y += line_height # === PAYMENT (TWO COLUMN - LABEL LEFT, VALUE RIGHT) === painter.setFont(font_normal) fm_payment = painter.fontMetrics() line_height_payment = fm_payment.height() + 2 # Helper function for payment two-column def draw_payment_column(label: str, value: str): nonlocal y, x_start, available_width, painter # Label on left (40% of available width) label_width = int(available_width * 0.4) rect_label = QRect(x_start, y, label_width, line_height_payment) painter.drawText(rect_label, Qt.AlignLeft | Qt.AlignTop, label) # CRITICAL FIX: Truncate value if too long fm_pay = painter.fontMetrics() value_max_width = int(available_width * 0.55) elided_value = fm_pay.elidedText(value, Qt.ElideRight, value_max_width) # Value on right - gunakan available_width dengan AlignRight rect_value = QRect(x_start, y, available_width, line_height_payment) painter.drawText(rect_value, Qt.AlignRight | Qt.AlignTop, elided_value) y += line_height_payment # Multi-payment support payment_list = payment.get("payment_list", []) if payment_list and len(payment_list) > 0: # Multi-payment for pay_item in payment_list: method_name = pay_item.get("method", "Tunai") amount = pay_item.get("amount", 0) draw_payment_column(f"Bayar {method_name}:", f"Rp {amount:,}") else: # Single payment method = payment.get("method", "Tunai") jumlah_dibayar = payment.get("jumlah_dibayar", 0) kembalian = payment.get("kembalian", 0) draw_payment_column("Bayar:", method) # Jumlah dibayar dan kembalian (untuk semua method) if jumlah_dibayar > 0: draw_payment_column("Dibayar:", f"Rp {jumlah_dibayar:,}") if kembalian > 0: draw_payment_column("Kembali:", f"Rp {kembalian:,}") # Separator rect = QRect(x_start, y, available_width, line_height_payment) painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, _rule_line()) y += line_height_payment + 5 # === FOOTER (CENTER ALIGN) === painter.setFont(font_small) fm_small = painter.fontMetrics() line_height_small = fm_small.height() + 2 for i in range(1, 4): footer_text = setting.get(f"footer{i}", "") if footer_text: # Calculate required height with word wrap rect_temp = QRect(x_start, 0, available_width, 1000) bounding_rect = painter.boundingRect(rect_temp, Qt.AlignHCenter | Qt.AlignTop | Qt.TextWordWrap, footer_text) rect = QRect(x_start, y, available_width, bounding_rect.height()) painter.drawText(rect, Qt.AlignHCenter | Qt.AlignTop | Qt.TextWordWrap, footer_text) y += bounding_rect.height() + 2 if wifi_code and wifi_code != "-": rect = QRect(x_start, y, available_width, line_height_small) painter.drawText(rect, Qt.AlignHCenter | Qt.AlignTop, f"WiFi: {wifi_code}") y += line_height_small # === QR CODE (CENTER ALIGN) === if qr_data and QRCODE_AVAILABLE: try: import tempfile from PySide6.QtGui import QPixmap qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=3, border=2) qr.add_data(qr_data) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmp: img.save(tmp.name) pixmap = QPixmap(tmp.name) if not pixmap.isNull(): qr_size = 120 scaled = pixmap.scaled(qr_size, qr_size, Qt.KeepAspectRatio) x_center = x_start + (available_width - scaled.width()) // 2 painter.drawPixmap(x_center, y, scaled) y += scaled.height() + 5 # Draw QR URL with word wrap rect = QRect(x_start, y, available_width, line_height_small * 3) # Allow 3 lines painter.drawText(rect, Qt.AlignHCenter | Qt.AlignTop | Qt.TextWordWrap, qr_data) except (TypeError, ValueError, KeyError, AttributeError, RuntimeError, OSError, LookupError, ArithmeticError, ImportError) as e: controller.log_warning(f"[QR Error] {e}") return render_with_painter