feat: A4 Gridstack.js template designer + Puppeteer PDF renderer

This commit is contained in:
root
2026-07-16 07:57:16 +00:00
parent 56abeb9e4d
commit 70735502f2
10 changed files with 1802 additions and 3 deletions

139
main.py
View File

@@ -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
# ============================================================