import io import os from datetime import datetime, timedelta from reportlab.lib.pagesizes import letter from reportlab.lib import colors from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont import arabic_reshaper from bidi.algorithm import get_display from app.models.order import Order, OrderStatus from app.models.settings import StoreSettings from sqlalchemy.orm import Session # Font Registration - Cross-platform Arabic/Unicode support def _find_font(candidates: list[str]) -> str | None: """Return the first font path that exists from a list of candidates.""" for path in candidates: if os.path.exists(path): return path return None FONT_PATH = _find_font([ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (if copied) "../frontend/public/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (direct from frontend) "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces "C:\\Windows\\Fonts\\arial.ttf", # Fallback Windows "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Fallback Linux "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", ]) FONT_BOLD_PATH = _find_font([ "fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (Cocon Next Arabic has one weight usually, or we use the same) "/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces "C:\\Windows\\Fonts\\arialbd.ttf", # Fallback Windows "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Fallback Linux "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf", ]) if FONT_PATH: pdfmetrics.registerFont(TTFont('Arial', FONT_PATH)) if FONT_BOLD_PATH: pdfmetrics.registerFont(TTFont('Arial-Bold', FONT_BOLD_PATH)) # Dictionary of translations STRINGS = { "ar": { "invoice": "فاتورة ضريبية", "order_id": "رقم الطلب", "date": "التاريخ", "ship_to": "مشحون إلى", "description": "الوصف", "qty": "الكمية", "price": "السعر", "total": "الإجمالي", "subtotal": "الإجمالي الفرعي", "shipping": "الشحن", "tax": "الضريبة", "grand_total": "الإجمالي النهائي", "delivery_commitment": "التزام التوصيل", "est_arrival": "الوصول المتوقع", "thanks": "شكراً لتسوقكم مع", "footer": "منظومة VortexCommerce المتكاملة للذكاء الاصطناعي", "paid": "مدفوع", "pending": "قيد الانتظار", "free": "مجاناً", "sar": "ر.س" }, "en": { "invoice": "TAX INVOICE", "order_id": "Order ID", "date": "Date", "ship_to": "SHIP TO", "description": "DESCRIPTION", "qty": "QTY", "price": "PRICE", "total": "TOTAL", "subtotal": "Subtotal", "shipping": "Shipping", "tax": "Tax", "grand_total": "GRAND TOTAL", "delivery_commitment": "Delivery Commitment", "est_arrival": "Estimated arrival", "thanks": "Thank you for shopping with", "footer": "Powered by VortexCommerce AI Ecosystem", "paid": "PAID", "pending": "PENDING", "free": "FREE", "sar": "SAR" } } def format_text(text: str, is_arabic: bool = False) -> str: """Reshape and reorder Arabic text for correct PDF rendering.""" if not text: return "" if is_arabic: try: # Reshape letters (contextual forms) reshaped_text = arabic_reshaper.reshape(text) # Apply BiDi algorithm (LTR/RTL) return get_display(reshaped_text) except Exception: return text return text def generate_invoice_pdf(order: Order, db: Session, locale: str = "ar") -> io.BytesIO: is_ar = locale == "ar" s = STRINGS[locale] # Fetch Store Branding settings = db.query(StoreSettings).first() store_name = settings.store_name if settings else "VortexCommerce" # Robust Color Parsing def get_color(hex_str, default): try: if not hex_str or not isinstance(hex_str, str): return default if not hex_str.startswith("#"): hex_str = f"#{hex_str}" return colors.HexColor(hex_str) except: return default primary_color = get_color(settings.primary_color if settings else None, colors.HexColor("#046c4e")) secondary_color = get_color(settings.secondary_color if settings else None, colors.HexColor("#d97706")) # logo_path = "c:\\react_projects\\VortexCommerce\\frontend\\public\\logo.png" # Note: We use relative path if possible or full path logo_path = os.path.join("..", "frontend", "public", "logo.png") if not os.path.exists(logo_path): # Alternative search logo_path = os.path.join("frontend", "public", "logo.png") buffer = io.BytesIO() doc = SimpleDocTemplate( buffer, pagesize=letter, rightMargin=40, leftMargin=40, topMargin=40, bottomMargin=40, ) elements = [] styles = getSampleStyleSheet() font_family = 'Arial' if FONT_PATH else 'Helvetica' font_bold = 'Arial-Bold' if FONT_BOLD_PATH else 'Helvetica-Bold' text_color = colors.HexColor("#1e293b") muted_color = colors.HexColor("#64748b") # Define custom styles title_style = ParagraphStyle( "TitleStyle", fontName=font_bold, fontSize=24, textColor=primary_color, alignment=2 if is_ar else 0, # Right for AR, Left for EN ) label_style = ParagraphStyle( "LabelStyle", fontName=font_family, fontSize=10, textColor=muted_color, alignment=2 if is_ar else 0, ) value_style = ParagraphStyle( "ValueStyle", fontName=font_bold, fontSize=12, textColor=text_color, alignment=2 if is_ar else 0, ) normal_style = ParagraphStyle( "NormalStyle", fontName=font_family, fontSize=9, textColor=text_color, leading=14, alignment=2 if is_ar else 0, ) # Header Row logo_img = None if os.path.exists(logo_path): try: logo_img = Image(logo_path, 1.2*inch, 1.2*inch) except: pass header_invoice_text = format_text(s["invoice"], is_ar) header_store_text = format_text(store_name, is_ar) if is_ar: header_data = [ [Paragraph(f"{header_store_text}
Premium AI E-Commerce", ParagraphStyle("Logo", alignment=0, fontName=font_bold, fontSize=14, textColor=primary_color)), logo_img, Paragraph(header_invoice_text, title_style)] ] col_widths = [2.5*inch, 1.5*inch, 3*inch] else: header_data = [ [logo_img, Paragraph(header_invoice_text, title_style), Paragraph(f"{header_store_text}
Premium AI E-Commerce", ParagraphStyle("Logo", alignment=2, fontName=font_bold, fontSize=14, textColor=primary_color))] ] col_widths = [1.5*inch, 2.5*inch, 3*inch] header_table = Table(header_data, colWidths=col_widths) header_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE')])) elements.append(header_table) elements.append(Spacer(1, 10)) elements.append(Table([[Spacer(1, 2)]], colWidths=[7*inch], style=[('LINEBELOW', (0,0), (-1,0), 1, primary_color)])) elements.append(Spacer(1, 20)) # Order Info Section is_paid = order.status in [OrderStatus.PROCESSING, OrderStatus.PREPARING, OrderStatus.SHIPPED, OrderStatus.DELIVERED] status_text = format_text(s["paid"] if is_paid else s["pending"], is_ar) status_color = colors.HexColor("#10b981") if is_paid else colors.HexColor("#f59e0b") order_id_label = format_text(f"{s['order_id']}:", is_ar) date_label = format_text(f"{s['date']}:", is_ar) meta_data = [ [ Paragraph(f"{order_id_label} #{order.id:05d}
{date_label} {order.created_at.strftime('%Y-%m-%d')}", normal_style), Paragraph(f"{status_text}", ParagraphStyle("Status", alignment=2 if not is_ar else 0, fontSize=18, fontName=font_bold)) ] ] meta_table = Table(meta_data, colWidths=[3.5*inch, 3.5*inch]) elements.append(meta_table) elements.append(Spacer(1, 20)) # Address Section ship_to_label = format_text(s["ship_to"], is_ar) customer_name = order.user.name if order.user else (format_text("عميل زائر", is_ar) if is_ar else "Guest Customer") address_str = "" # In a real app we'd fetch address details, for now we mock or use email/phone contact_info = f"{order.guest_email or ''} | {order.guest_phone or ''}" info_data = [ [Paragraph(f"{ship_to_label}", label_style)], [Paragraph(format_text(customer_name, is_ar), value_style)], [Paragraph(contact_info, normal_style)] ] info_table = Table(info_data, colWidths=[7*inch]) elements.append(info_table) elements.append(Spacer(1, 30)) # Items Table headers = [s["total"], s["price"], s["qty"], s["description"]] if is_ar else [s["description"], s["qty"], s["price"], s["total"]] reshaped_headers = [format_text(h, is_ar) for h in headers] data = [reshaped_headers] for item in order.items: prod_name = (item.product.name_ar if is_ar and item.product.name_ar else item.product.name_en) if item.product else "Product" if item.variant_label: prod_name = f"{prod_name} - {item.variant_label}" prod_name = format_text(prod_name, is_ar) item_total = f"{item.quantity * item.price:,.2f}" item_price = f"{item.price:,.2f}" if is_ar: data.append([item_total, item_price, str(item.quantity), Paragraph(prod_name, normal_style)]) else: data.append([Paragraph(prod_name, normal_style), str(item.quantity), item_price, item_total]) col_widths = [1.2*inch, 1.2*inch, 0.8*inch, 3.8*inch] if is_ar else [3.8*inch, 0.8*inch, 1.2*inch, 1.2*inch] table = Table(data, colWidths=col_widths) t_style = [ ('BACKGROUND', (0, 0), (-1, 0), primary_color), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('FONTNAME', (0, 0), (-1, 0), font_bold), ('FONTSIZE', (0, 0), (-1, 0), 10), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), ('TOPPADDING', (0, 0), (-1, 0), 12), ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")), ('FONTNAME', (0, 1), (-1, -1), font_family), ('FONTSIZE', (0, 1), (-1, -1), 9), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ] # Adjust alignment for descriptions desc_idx = 3 if is_ar else 0 t_style.append(('ALIGN', (desc_idx, 1), (desc_idx, -1), 'RIGHT' if is_ar else 'LEFT')) table.setStyle(TableStyle(t_style)) elements.append(table) elements.append(Spacer(1, 20)) # Totals Summary subtotal_val = order.total_price - order.tax - order.shipping_cost shipping_text = format_text(s["free"], is_ar) if order.shipping_cost == 0 else f"{order.shipping_cost:,.2f}" summary_data = [] summary_labels = [s["subtotal"], s["shipping"], s["tax"]] summary_values = [f"{subtotal_val:,.2f}", shipping_text, f"{order.tax:,.2f}"] for label, val in zip(summary_labels, summary_values): lbl = format_text(f"{label}:", is_ar) if is_ar: summary_data.append([val, Paragraph(lbl, normal_style)]) else: summary_data.append([Paragraph(lbl, normal_style), val]) # Grand Total Row gt_label = format_text(f"{s['grand_total']}:", is_ar) gt_val = f"{order.total_price:,.2f} {format_text(s['sar'], is_ar)}" if is_ar: summary_data.append([Paragraph(f"{gt_val}", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)), Paragraph(f"{gt_label}", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))]) else: summary_data.append([Paragraph(f"{gt_label}", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)), Paragraph(f"{gt_val}", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))]) summary_table = Table(summary_data, colWidths=[1.5*inch, 1.5*inch] if is_ar else [1.5*inch, 1.5*inch]) summary_table.setStyle(TableStyle([ ('ALIGN', (0,0), (-1,-1), 'RIGHT' if is_ar else 'LEFT'), ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ])) outer_summary = Table([[Spacer(1,1), summary_table]], colWidths=[4*inch, 3*inch] if not is_ar else [3*inch, 4*inch]) elements.append(outer_summary) # Footer elements.append(Spacer(1, 60)) thanks_text = format_text(f"{s['thanks']} {store_name}!", is_ar) footer_text = format_text(s["footer"], is_ar) elements.append(Paragraph(f"{thanks_text}", ParagraphStyle("Thanks", alignment=1, fontName=font_bold, fontSize=14, textColor=secondary_color))) elements.append(Paragraph(footer_text, ParagraphStyle("Footer", alignment=1, fontName=font_family, fontSize=8, textColor=muted_color, spaceBefore=5))) # Build PDF doc.build(elements) buffer.seek(0) return buffer