""" Mimbar AI — PDF Render Server Playwright + Chromium headless → PDF 100% akurat Deploy ke Hugging Face Spaces (Docker, free tier CPU) """ import asyncio import base64 import logging import os import time from contextlib import asynccontextmanager from typing import Optional from fastapi import FastAPI, HTTPException, Request, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from playwright.async_api import async_playwright, Browser, Playwright from pydantic import BaseModel, Field # ── API Key (set di HF Space: Settings → Variables and secrets) ────────────── # Kalau tidak di-set → server terbuka (dev mode) _API_KEY = os.getenv("PDF_API_KEY", "") # ── Logging ────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" ) log = logging.getLogger(__name__) # ── Global state ───────────────────────────────────────────────────────────── _pw: Optional[Playwright] = None _browser: Optional[Browser] = None _browser_lock = asyncio.Lock() # Semaphore: max concurrent render (jaga RAM free tier ~16GB) # Tiap Chromium page ~150-300MB, aman 4 concurrent _render_sem = asyncio.Semaphore(4) # ── Lifespan: buka/tutup browser sekali saja ───────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): global _pw, _browser log.info("🚀 Starting Playwright + Chromium...") try: _pw = await async_playwright().start() _browser = await _pw.chromium.launch( headless=True, args=[ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", # penting di Docker! "--disable-gpu", "--no-zygote", "--single-process", # hemat RAM di free tier "--disable-extensions", "--disable-background-networking", "--disable-default-apps", "--mute-audio", "--no-first-run", ] ) log.info("✅ Chromium ready!") except Exception as e: log.error(f"❌ Gagal launch Chromium: {e}") raise yield # Shutdown log.info("🛑 Shutting down Chromium...") if _browser: await _browser.close() if _pw: await _pw.stop() # ── App ─────────────────────────────────────────────────────────────────────── app = FastAPI( title="Mimbar AI — PDF Render Server", version="1.0.0", lifespan=lifespan ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ── Request model ───────────────────────────────────────────────────────────── VALID_PAPER_SIZES = {"A4", "A5", "B5", "A6", "Legal", "Letter"} class PDFRequest(BaseModel): html: str = Field(..., description="Full HTML string lengkap dengan CSS inline") paperSize: str = Field(default="A4", description="Ukuran kertas: A4, A5, B5, dst") waitMs: int = Field(default=1500, ge=500, le=15000, description="Waktu tunggu setelah load (ms)") scale: float = Field(default=1.0, ge=0.1, le=2.0, description="Scale factor halaman") timeoutMs: int = Field(default=60000, ge=10000, le=300000, description="Max timeout render (ms), naikan untuk dokumen besar") # ── Helper: restart browser kalau crash ────────────────────────────────────── async def get_browser() -> Browser: global _browser, _pw async with _browser_lock: if _browser is None or not _browser.is_connected(): log.warning("⚠️ Browser disconnected, restarting...") try: if _browser: await _browser.close() except Exception: pass _browser = await _pw.chromium.launch( headless=True, args=[ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--no-zygote", "--single-process", "--disable-extensions", "--no-first-run", ] ) log.info("✅ Browser restarted!") return _browser # ── Endpoint: render PDF ────────────────────────────────────────────────────── @app.post("/render-pdf") async def render_pdf( req: PDFRequest, request: Request, x_api_key: str = Header(default="") ): # Validasi API key (kalau _API_KEY di-set di HF Secrets) if _API_KEY and x_api_key != _API_KEY: log.warning(f"⛔ Akses ditolak dari {request.client.host} — API key salah") raise HTTPException(status_code=401, detail="Unauthorized: API key salah atau tidak ada") # Validasi paper size paper = req.paperSize.upper() if req.paperSize.upper() in VALID_PAPER_SIZES else "A4" # Cek HTML tidak kosong if not req.html or len(req.html.strip()) < 50: raise HTTPException(status_code=400, detail="HTML terlalu pendek / kosong") html_size_mb = len(req.html.encode("utf-8")) / (1024 * 1024) # Flexible: tidak ada hard limit ukuran # Auto-naikan wait & timeout sesuai besarnya dokumen if html_size_mb > 50: log.warning(f"⚠️ HTML sangat besar: {html_size_mb:.1f}MB") auto_wait = req.waitMs auto_timeout = req.timeoutMs if html_size_mb > 20: auto_wait = max(auto_wait, 5000) auto_timeout = max(auto_timeout, 180_000) elif html_size_mb > 10: auto_wait = max(auto_wait, 3000) auto_timeout = max(auto_timeout, 120_000) elif html_size_mb > 5: auto_wait = max(auto_wait, 2000) auto_timeout = max(auto_timeout, 90_000) t_start = time.time() log.info(f"📄 Render PDF [{paper}] size={html_size_mb:.2f}MB wait={auto_wait}ms timeout={auto_timeout}ms") # Antri kalau semaphore penuh async with _render_sem: page = None try: browser = await get_browser() # Buka context baru (isolated) tiap request context = await browser.new_context( viewport={"width": 1240, "height": 1754}, # A4-ish device_scale_factor=1, ) page = await context.new_page() # Set timeout dinamis sesuai ukuran dokumen page.set_default_timeout(auto_timeout) # Load HTML langsung sebagai content (bukan URL) await page.set_content(req.html, wait_until="domcontentloaded") # Tunggu font & gambar selesai load try: networkidle_timeout = min(auto_timeout // 2, 30_000) await page.wait_for_load_state("networkidle", timeout=networkidle_timeout) except Exception: log.warning("networkidle timeout, lanjut render...") # Tunggu tambahan untuk font custom (Arabic, cover image, dll) await page.wait_for_timeout(auto_wait) # Paksa semua font loaded via JS try: await page.evaluate("""async () => { if (document.fonts && document.fonts.ready) { await document.fonts.ready; } }""") except Exception: pass # ── Render PDF ── # print_background=True → background warna/gambar ikut semua! pdf_bytes = await page.pdf( format=paper, print_background=True, # ← KUNCI UTAMA margin={ "top": "0", "right": "0", "bottom": "0", "left": "0" }, scale=req.scale, prefer_css_page_size=True, # hormat @page CSS ) await context.close() elapsed = time.time() - t_start pdf_size_kb = len(pdf_bytes) / 1024 log.info(f"✅ PDF selesai: {pdf_size_kb:.0f}KB dalam {elapsed:.2f}s") # Return base64 agar mudah di-handle frontend pdf_b64 = base64.b64encode(pdf_bytes).decode("utf-8") return JSONResponse({ "success": True, "pdf": pdf_b64, "size_kb": round(pdf_size_kb, 1), "elapsed_s": round(elapsed, 2), "paper": paper, }) except HTTPException: raise except Exception as e: elapsed = time.time() - t_start log.error(f"❌ Render gagal setelah {elapsed:.2f}s: {e}") if page: try: await page.context.close() except Exception: pass raise HTTPException(status_code=500, detail=f"Render gagal: {str(e)}") # ── Endpoint: health check ──────────────────────────────────────────────────── @app.get("/health") async def health(): browser_ok = _browser is not None and _browser.is_connected() return { "status": "ok" if browser_ok else "degraded", "browser": "connected" if browser_ok else "disconnected", "slots_available": _render_sem._value, } # ── Endpoint: warmup (ping biar Space tidak tidur) ─────────────────────────── @app.get("/ping") async def ping(): return {"pong": True, "ts": int(time.time())} # ── Root ────────────────────────────────────────────────────────────────────── @app.get("/") async def root(): return { "name": "Mimbar AI PDF Render Server", "version": "1.0.0", "endpoints": { "render": "POST /render-pdf", "health": "GET /health", "ping": "GET /ping", } }