Pdf / main.py
wave101828228's picture
Upload 4 files
575d71e verified
Raw
History Blame Contribute Delete
10.9 kB
"""
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",
}
}