""" وحدة توليد الملفات والمخططات - PDF عبر ReportLab - Word عبر python-docx - Excel عبر openpyxl - مخططات عبر matplotlib كل الملفات تُحفظ مؤقتاً وتُرسل كمرفقات """ import asyncio import io import logging import os import tempfile import uuid from typing import List, Optional, Tuple logger = logging.getLogger(__name__) # ضبط matplotlib لـ headless import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.font_manager as fm # محاولة تسجيل خطوط عربية إن وجدت try: for fp in [ "/usr/share/fonts/truetype/chinese/NotoSansSC-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/lxgw-wenkai/LXGWWenKai-Regular.ttf", ]: if os.path.exists(fp): fm.fontManager.addfont(fp) plt.rcParams["font.sans-serif"] = ["Noto Sans SC", "DejaVu Sans", "Liberation Sans"] plt.rcParams["axes.unicode_minus"] = False except Exception as e: logger.warning(f"Font setup warning: {e}") # ===================== PDF ===================== def create_pdf(content: str, title: str = "مستند") -> str: """إنشاء PDF من نص - يعيد مسار الملف""" try: from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer from reportlab.lib.units import cm from reportlab.lib.enums import TA_RIGHT from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont except ImportError as e: raise RuntimeError("reportlab غير مثبت") from e # محاولة تسجيل خط عربي arabic_font_name = "Helvetica" try: arabic_font_path = None for p in [ "/usr/share/fonts/truetype/lxgw-wenkai/LXGWWenKai-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/freefont/FreeSans.ttf", ]: if os.path.exists(p): arabic_font_path = p break if arabic_font_path: pdfmetrics.registerFont(TTFont("ArabicFont", arabic_font_path)) arabic_font_name = "ArabicFont" except Exception: pass output_path = os.path.join(tempfile.gettempdir(), f"{title[:30]}_{uuid.uuid4().hex[:6]}.pdf") doc = SimpleDocTemplate( output_path, pagesize=A4, leftMargin=2*cm, rightMargin=2*cm, topMargin=2*cm, bottomMargin=2*cm, ) styles = getSampleStyleSheet() title_style = ParagraphStyle( "ArabicTitle", parent=styles["Title"], fontName=arabic_font_name, fontSize=20, alignment=TA_RIGHT, spaceAfter=20, ) body_style = ParagraphStyle( "ArabicBody", parent=styles["Normal"], fontName=arabic_font_name, fontSize=12, alignment=TA_RIGHT, leading=18, spaceAfter=10, ) story = [] story.append(Paragraph(title, title_style)) story.append(Spacer(1, 1*cm)) # تقسيم المحتوى لفقرات for line in content.split("\n"): if line.strip(): # escape XML safe = line.replace("&", "&").replace("<", "<").replace(">", ">") story.append(Paragraph(safe, body_style)) else: story.append(Spacer(1, 0.3*cm)) doc.build(story) return output_path # ===================== DOCX ===================== def create_docx(content: str, title: str = "مستند") -> str: """إنشاء Word DOCX من نص""" try: from docx import Document from docx.shared import Pt, Inches, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH except ImportError as e: raise RuntimeError("python-docx غير مثبت") from e output_path = os.path.join(tempfile.gettempdir(), f"{title[:30]}_{uuid.uuid4().hex[:6]}.docx") doc = Document() # عنوان title_para = doc.add_heading(title, level=1) title_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT # محتوى for line in content.split("\n"): if line.strip(): p = doc.add_paragraph(line) p.alignment = WD_ALIGN_PARAGRAPH.RIGHT for run in p.runs: run.font.size = Pt(12) doc.save(output_path) return output_path # ===================== XLSX ===================== def create_xlsx(data: List[List], sheet_name: str = "Sheet1", title: str = "بيانات") -> str: """إنشاء Excel من بيانات (list of lists)""" try: from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter except ImportError as e: raise RuntimeError("openpyxl غير مثبت") from e output_path = os.path.join(tempfile.gettempdir(), f"{title[:30]}_{uuid.uuid4().hex[:6]}.xlsx") wb = Workbook() ws = wb.active ws.title = sheet_name[:31] # حد Excel لاسم الورقة if not data: ws.append(["لا توجد بيانات"]) else: for row in data: ws.append(row) # تنسيق الصف الأول (headers) if len(data) > 0: header_font = Font(bold=True, color="FFFFFF", size=11) header_fill = PatternFill("solid", fgColor="4F81BD") thin_border = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin"), ) for cell in ws[1]: cell.font = header_font cell.fill = header_fill cell.alignment = Alignment(horizontal="center", vertical="center") cell.border = thin_border # ضبط عرض الأعمدة تلقائياً for col in ws.columns: max_len = 0 col_letter = get_column_letter(col[0].column) for cell in col: try: cell_len = len(str(cell.value)) if cell.value else 0 if cell_len > max_len: max_len = cell_len except Exception: pass ws.column_dimensions[col_letter].width = min(max_len + 2, 50) wb.save(output_path) return output_path # ===================== Charts (matplotlib) ===================== def create_chart( chart_type: str, data: dict, title: str = "مخطط", xlabel: str = "", ylabel: str = "", ) -> str: """ إنشاء مخطط بصورة PNG. chart_type: bar, line, pie, scatter, histogram data: dict حسب نوع المخطط - bar/line: {"labels": [...], "values": [...]} - pie: {"labels": [...], "values": [...]} - scatter: {"x": [...], "y": [...]} - histogram: {"values": [...]} """ output_path = os.path.join(tempfile.gettempdir(), f"chart_{uuid.uuid4().hex[:6]}.png") fig, ax = plt.subplots(figsize=(10, 6), constrained_layout=True) try: if chart_type == "bar": labels = data.get("labels", []) values = data.get("values", []) ax.bar(labels, values, color="steelblue", edgecolor="navy") elif chart_type == "line": labels = data.get("labels", []) values = data.get("values", []) ax.plot(labels, values, marker="o", color="crimson", linewidth=2) ax.grid(True, alpha=0.3) elif chart_type == "pie": labels = data.get("labels", []) values = data.get("values", []) ax.pie(values, labels=labels, autopct="%1.1f%%", startangle=90) ax.axis("equal") elif chart_type == "scatter": x = data.get("x", []) y = data.get("y", []) ax.scatter(x, y, color="darkorange", alpha=0.7, edgecolors="black") ax.grid(True, alpha=0.3) elif chart_type == "histogram": values = data.get("values", []) ax.hist(values, bins=20, color="mediumpurple", edgecolor="indigo") else: raise ValueError(f"نوع مخطط غير معروف: {chart_type}") if title: ax.set_title(title, fontsize=14, fontweight="bold") if xlabel: ax.set_xlabel(xlabel, fontsize=11) if ylabel: ax.set_ylabel(ylabel, fontsize=11) # تدوير labels المحور السيني إن طالت if chart_type in ("bar", "line") and data.get("labels"): if len(data["labels"]) > 5: plt.setp(ax.get_xticklabels(), rotation=30, ha="right") fig.savefig(output_path, dpi=120) finally: plt.close(fig) return output_path # ===================== مساعد ===================== def cleanup_file(path: str): """حذف ملف مؤقت بأمان""" try: if path and os.path.exists(path): os.remove(path) except Exception as e: logger.warning(f"Could not delete {path}: {e}") async def run_in_executor(func, *args, **kwargs): """تشغيل دالة متزامنة في thread pool""" return await asyncio.to_thread(func, *args, **kwargs)