feat: A4 Gridstack.js template designer + Puppeteer PDF renderer
This commit is contained in:
139
main.py
139
main.py
@@ -2,9 +2,11 @@
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import subprocess
|
||||
import asyncio
|
||||
from datetime import date, datetime
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pathlib import Path
|
||||
@@ -689,6 +691,141 @@ def _parse_date(d):
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PDF RENDERING (Puppeteer)
|
||||
# ============================================================
|
||||
|
||||
@app.post("/api/render-pdf")
|
||||
async def render_pdf(request: Request):
|
||||
"""Render a CV template + candidate data to PDF using the Puppeteer renderer.
|
||||
|
||||
Body: {
|
||||
"template": {JSON CV Template schema},
|
||||
"cv_data": {candidate data},
|
||||
"generation_date": "YYYY-MM-DD" (optional)
|
||||
}
|
||||
Returns: {"pdf_url": "/api/pdf/<filename>", "html_url": "/api/html/<filename>"}
|
||||
"""
|
||||
data = await request.json()
|
||||
template = data.get("template", {})
|
||||
cv_data = data.get("cv_data", {})
|
||||
gen_date = data.get("generation_date", date.today().isoformat())
|
||||
|
||||
return await _do_render_pdf(template, cv_data, gen_date)
|
||||
|
||||
|
||||
async def _do_render_pdf(template, cv_data, gen_date):
|
||||
"""Core PDF rendering logic — calls the Node Puppeteer renderer."""
|
||||
output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "renderer", "output")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
template_path = os.path.join(output_dir, f"template_{uuid.uuid4().hex[:8]}.json")
|
||||
data_path = os.path.join(output_dir, f"cvdata_{uuid.uuid4().hex[:8]}.json")
|
||||
|
||||
with open(template_path, "w") as f:
|
||||
json.dump(template, f)
|
||||
with open(data_path, "w") as f:
|
||||
json.dump(cv_data, f, default=str)
|
||||
|
||||
# Create a Node script that calls the renderer
|
||||
script = f"""
|
||||
const {{ renderToPDF }} = require('/root/workspace/cv-app/renderer/render.js');
|
||||
const template = require('{template_path}');
|
||||
const cvData = require('{data_path}');
|
||||
renderToPDF(template, cvData, {{ generationDate: '{gen_date}' }})
|
||||
.then(r => console.log(JSON.stringify({{ htmlPath: r.htmlPath, pdfPath: r.pdfPath }})))
|
||||
.catch(e => {{ console.error(e.message); process.exit(1); }});
|
||||
"""
|
||||
script_path = os.path.join(output_dir, f"render_{uuid.uuid4().hex[:8]}.js")
|
||||
with open(script_path, "w") as f:
|
||||
f.write(script)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["node", script_path],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
cwd="/root/workspace/cv-app/renderer"
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(500, f"Render failed: {result.stderr[:500]}")
|
||||
|
||||
output = json.loads(result.stdout.strip())
|
||||
pdf_filename = os.path.basename(output["pdfPath"])
|
||||
html_filename = os.path.basename(output["htmlPath"])
|
||||
|
||||
return {
|
||||
"pdf_url": f"/api/pdf/{pdf_filename}",
|
||||
"html_url": f"/api/html/{html_filename}",
|
||||
"pdf_path": output["pdfPath"]
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(500, "PDF render timed out")
|
||||
finally:
|
||||
# Clean up temp files
|
||||
for p in [template_path, data_path, script_path]:
|
||||
try: os.unlink(p)
|
||||
except: pass
|
||||
|
||||
|
||||
@app.get("/api/pdf/{filename}")
|
||||
async def serve_pdf(filename: str):
|
||||
"""Serve a generated PDF file."""
|
||||
pdf_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "renderer", "output", filename)
|
||||
if not os.path.exists(pdf_path):
|
||||
raise HTTPException(404, "PDF not found")
|
||||
return FileResponse(pdf_path, media_type="application/pdf", filename=filename)
|
||||
|
||||
|
||||
@app.get("/api/html/{filename}")
|
||||
async def serve_html(filename: str):
|
||||
"""Serve a generated HTML file."""
|
||||
html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "renderer", "output", filename)
|
||||
if not os.path.exists(html_path):
|
||||
raise HTTPException(404, "HTML not found")
|
||||
return FileResponse(html_path, media_type="text/html")
|
||||
|
||||
|
||||
@app.post("/api/generated-cvs/{gen_id}/render-pdf")
|
||||
async def render_generated_cv_pdf(gen_id: str):
|
||||
"""Render an existing generated CV to PDF using its stored template + data."""
|
||||
row = db.query("""
|
||||
SELECT gc.*, c.first_name, c.last_name
|
||||
FROM generated_cvs gc
|
||||
LEFT JOIN candidates c ON gc.candidate_id = c.id
|
||||
WHERE gc.id = %s
|
||||
""", (gen_id,), fetch='one')
|
||||
if not row:
|
||||
raise HTTPException(404, "Generated CV not found")
|
||||
|
||||
# Get the template if one was used
|
||||
template_schema = {"canvas": {"width": 794, "height": 1123, "columns": 12}, "blocks": []}
|
||||
if row.get("template_id"):
|
||||
tmpl = db.query("SELECT * FROM cv_templates WHERE id = %s", (str(row["template_id"]),), fetch='one')
|
||||
if tmpl:
|
||||
tstruct = tmpl["template_structure"]
|
||||
if isinstance(tstruct, str):
|
||||
tstruct = json.loads(tstruct)
|
||||
template_schema = tstruct
|
||||
|
||||
# If no blocks in template, build a default layout
|
||||
if not template_schema.get("blocks"):
|
||||
template_schema["blocks"] = [
|
||||
{"blockId": "header", "type": "PersonalDetails", "title": "Header", "x": 0, "y": 0, "w": 12, "h": 8},
|
||||
{"blockId": "summary", "type": "ProfessionalSummary", "title": "Summary", "x": 0, "y": 8, "w": 12, "h": 5},
|
||||
{"blockId": "experience", "type": "WorkExperience", "title": "Experience", "x": 0, "y": 13, "w": 8, "h": 40, "config": {"showAchievements": True}},
|
||||
{"blockId": "skills", "type": "SkillsList", "title": "Skills", "x": 8, "y": 13, "w": 4, "h": 25, "config": {"groupByCategory": True, "showYears": True, "sidebar": True}},
|
||||
{"blockId": "education", "type": "Education", "title": "Education", "x": 8, "y": 38, "w": 4, "h": 15},
|
||||
{"blockId": "footer", "type": "Footer", "title": "Footer", "x": 0, "y": 105, "w": 12, "h": 3, "config": {"content": f"Generated {row['generation_date']}"}}
|
||||
]
|
||||
|
||||
# Get full candidate data
|
||||
full_data = await get_candidate(str(row["candidate_id"]))
|
||||
|
||||
# Call the render logic directly
|
||||
return await _do_render_pdf(template_schema, full_data, str(row["generation_date"]))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SERVE FRONTEND
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user