Spaces:
Running
Running
| """ | |
| 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 βββββββββββββββββββββββββββββββββ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) βββββββββββββββββββββββββββ | |
| async def ping(): | |
| return {"pong": True, "ts": int(time.time())} | |
| # ββ Root ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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", | |
| } | |
| } | |