feat: A4 Gridstack.js template designer + Puppeteer PDF renderer
This commit is contained in:
Binary file not shown.
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
|
||||
# ============================================================
|
||||
|
||||
4
renderer/.gitignore
vendored
Normal file
4
renderer/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
output/
|
||||
*.pdf
|
||||
*.html
|
||||
331
renderer/package-lock.json
generated
Normal file
331
renderer/package-lock.json
generated
Normal file
@@ -0,0 +1,331 @@
|
||||
{
|
||||
"name": "renderer",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "renderer",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"puppeteer": "^25.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz",
|
||||
"integrity": "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.7.6",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"browsers": "lib/main-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"proxy-agent": ">=8.0.1",
|
||||
"yauzl": "^2.10.0 || ^3.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"proxy-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"yauzl": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
|
||||
"integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1638949",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz",
|
||||
"integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/modern-tar": {
|
||||
"version": "0.7.6",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz",
|
||||
"integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer": {
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.3.0.tgz",
|
||||
"integrity": "sha512-O1tx8S315aw8eI99HZ5ZNcVEzJ9+jKF//eO5UvfZ3cXJ6okZ5sX3Y50u7DJaM+ewEK4LqXP068tBhfRaWikj+g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.0.6",
|
||||
"chromium-bidi": "16.0.1",
|
||||
"devtools-protocol": "0.0.1638949",
|
||||
"lilconfig": "^3.1.3",
|
||||
"puppeteer-core": "25.3.0",
|
||||
"typed-query-selector": "^2.12.2"
|
||||
},
|
||||
"bin": {
|
||||
"puppeteer": "lib/puppeteer/node/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.3.0.tgz",
|
||||
"integrity": "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.0.6",
|
||||
"chromium-bidi": "16.0.1",
|
||||
"devtools-protocol": "0.0.1638949",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.2",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
|
||||
"integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
renderer/package.json
Normal file
15
renderer/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "renderer",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"puppeteer": "^25.3.0"
|
||||
}
|
||||
}
|
||||
641
renderer/render.js
Normal file
641
renderer/render.js
Normal file
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* CV Template Renderer - Node.js + Puppeteer
|
||||
*
|
||||
* Merges a JSON CV Template (layout positions) with User CV Data (actual candidate info)
|
||||
* and renders a pixel-perfect A4 PDF using absolute CSS positioning.
|
||||
*/
|
||||
|
||||
const puppeteer = require('puppeteer');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ============================================================
|
||||
// CSS GENERATION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Generate CSS for the A4 canvas and all positioned blocks.
|
||||
* Gridstack uses a 12-column grid. We convert x,y,w,h into
|
||||
* absolute pixel positions on a 794x1123 canvas.
|
||||
*/
|
||||
function generateCanvasCSS(template) {
|
||||
const { canvas, blocks } = template;
|
||||
const colWidth = canvas.width / canvas.columns;
|
||||
const rowHeight = 10; // Gridstack default cell height in px (we use compact mode)
|
||||
|
||||
let css = `
|
||||
/* A4 Canvas */
|
||||
.cv-page {
|
||||
width: ${canvas.width}px;
|
||||
height: ${canvas.height}px;
|
||||
background: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
color: #1a1a1a;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Each block is absolutely positioned using the grid coordinates */
|
||||
.cv-block {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Block type-specific styling */
|
||||
.cv-block-PersonalDetails {
|
||||
border-bottom: 2px solid #2c3e50;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.cv-block-PersonalDetails .cv-name {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.cv-block-PersonalDetails .cv-title {
|
||||
font-size: 15px;
|
||||
color: #555;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.cv-block-PersonalDetails .cv-contact {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cv-block-PersonalDetails .cv-contact span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.cv-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Work Experience */
|
||||
.cv-block-WorkExperience .exp-entry {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cv-block-WorkExperience .exp-role {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cv-block-WorkExperience .exp-company {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.cv-block-WorkExperience .exp-date {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cv-block-WorkExperience .exp-desc {
|
||||
font-size: 12px;
|
||||
color: #444;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cv-block-WorkExperience .exp-achievements {
|
||||
margin: 4px 0 0 16px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.cv-block-WorkExperience .exp-achievements li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Education */
|
||||
.cv-block-Education .edu-entry {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cv-block-Education .edu-degree {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cv-block-Education .edu-school {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.cv-block-Education .edu-date {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Skills */
|
||||
.cv-block-SkillsList .skill-group {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cv-block-SkillsList .skill-cat {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cv-block-SkillsList .skill-items {
|
||||
font-size: 12px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.cv-block-SkillsList .skill-item {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.cv-block-SkillsList .skill-item::after {
|
||||
content: " • ";
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.cv-block-SkillsList .skill-item:last-child::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.cv-block-SkillsList .skill-years {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Certifications */
|
||||
.cv-block-Certifications .cert-entry {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cv-block-Certifications .cert-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cv-block-Certifications .cert-issuer {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* Summary */
|
||||
.cv-block-ProfessionalSummary {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
/* Custom Text */
|
||||
.cv-block-CustomText {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.cv-block-Footer {
|
||||
font-size: 10px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Two-column layouts */
|
||||
.cv-sidebar {
|
||||
background: #f8f9fa;
|
||||
border-left: 2px solid #2c3e50;
|
||||
}
|
||||
|
||||
/* Print reset */
|
||||
@media print {
|
||||
body { margin: 0; padding: 0; }
|
||||
@page { size: A4; margin: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
// Generate position CSS for each block
|
||||
for (const block of blocks) {
|
||||
const left = block.x * colWidth;
|
||||
const width = block.w * colWidth;
|
||||
const top = block.y * rowHeight;
|
||||
const height = block.h * rowHeight;
|
||||
|
||||
css += `
|
||||
/* Block: ${block.blockId} (${block.type}) */
|
||||
#block-${block.blockId} {
|
||||
left: ${left}px;
|
||||
top: ${top}px;
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HTML GENERATION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Build the HTML content for a single block by injecting CV data
|
||||
* into the block based on its type.
|
||||
*/
|
||||
function renderBlockHTML(block, cvData, options) {
|
||||
const { type, blockId } = block;
|
||||
const opts = options || {};
|
||||
const genDate = opts.generationDate || new Date().toISOString().split('T')[0];
|
||||
|
||||
// Helper: format date range
|
||||
const fmtDate = (d) => {
|
||||
if (!d) return '';
|
||||
try {
|
||||
const date = new Date(d);
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||
} catch { return d; }
|
||||
};
|
||||
|
||||
// Helper: calculate years between dates
|
||||
const calcYears = (start, end, refDate) => {
|
||||
if (!start) return 0;
|
||||
const endDate = end ? new Date(end) : new Date(refDate);
|
||||
const startDate = new Date(start);
|
||||
const diff = (endDate - startDate) / (365.25 * 24 * 3600 * 1000);
|
||||
return Math.round(diff * 10) / 10;
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'PersonalDetails':
|
||||
const c = cvData.candidate || cvData;
|
||||
const contactParts = [];
|
||||
if (c.email) contactParts.push(`<span>📧 ${c.email}</span>`);
|
||||
if (c.phone) contactParts.push(`<span>📞 ${c.phone}</span>`);
|
||||
if (c.address) contactParts.push(`<span>📍 ${c.address}</span>`);
|
||||
if (c.linkedin) contactParts.push(`<span>🔗 ${c.linkedin}</span>`);
|
||||
if (c.github) contactParts.push(`<span>💻 ${c.github}</span>`);
|
||||
if (c.website) contactParts.push(`<span>🌐 ${c.website}</span>`);
|
||||
return `
|
||||
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
||||
<h1 class="cv-name">${c.first_name || ''} ${c.last_name || ''}</h1>
|
||||
${c.summary ? `<div class="cv-title">${c.summary.split('.')[0]}.</div>` : ''}
|
||||
<div class="cv-contact">${contactParts.join('')}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'ProfessionalSummary':
|
||||
const summary = cvData.candidate?.summary || cvData.summary || '';
|
||||
return `
|
||||
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
||||
<div class="cv-section-title">${block.title || 'Professional Summary'}</div>
|
||||
<p>${summary}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'WorkExperience':
|
||||
const experiences = cvData.experience || [];
|
||||
const showAch = block.config?.showAchievements !== false;
|
||||
const showSkills = block.config?.showSkillsUsed === true;
|
||||
const maxItems = block.config?.maxItems;
|
||||
let expHTML = experiences.slice(0, maxItems || experiences.length).map(e => {
|
||||
const ach = Array.isArray(e.achievements) ? e.achievements :
|
||||
(typeof e.achievements === 'string' ? JSON.parse(e.achievements || '[]') : []);
|
||||
const skillsUsed = Array.isArray(e.skills_used) ? e.skills_used :
|
||||
(typeof e.skills_used === 'string' ? JSON.parse(e.skills_used || '[]') : []);
|
||||
const startStr = fmtDate(e.start_date);
|
||||
const endStr = e.end_date ? fmtDate(e.end_date) : 'Present';
|
||||
return `
|
||||
<div class="exp-entry">
|
||||
<div class="exp-role">${e.position || ''}</div>
|
||||
<div class="exp-company">${e.company || ''}${e.location ? ' — ' + e.location : ''}</div>
|
||||
<div class="exp-date">${startStr} — ${endStr}</div>
|
||||
${e.description ? `<div class="exp-desc">${e.description}</div>` : ''}
|
||||
${showAch && ach.length ? `<ul class="exp-achievements">${ach.map(a => `<li>${a}</li>`).join('')}</ul>` : ''}
|
||||
${showSkills && skillsUsed.length ? `<div class="exp-desc"><em>Skills: ${skillsUsed.join(', ')}</em></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
return `
|
||||
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
||||
<div class="cv-section-title">${block.title || 'Work Experience'}</div>
|
||||
${expHTML || '<p style="color:#999">No experience data</p>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'Education':
|
||||
const educations = cvData.education || [];
|
||||
let eduHTML = educations.map(e => `
|
||||
<div class="edu-entry">
|
||||
<div class="edu-degree">${e.degree || ''}${e.field_of_study ? ' in ' + e.field_of_study : ''}</div>
|
||||
<div class="edu-school">${e.institution || ''}</div>
|
||||
<div class="edu-date">${e.start_date ? fmtDate(e.start_date) : ''} — ${e.end_date ? fmtDate(e.end_date) : ''}</div>
|
||||
${e.grade ? `<div class="edu-school">Grade: ${e.grade}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
return `
|
||||
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
||||
<div class="cv-section-title">${block.title || 'Education'}</div>
|
||||
${eduHTML || '<p style="color:#999">No education data</p>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'SkillsList':
|
||||
const skills = cvData.skills || [];
|
||||
const groupByCat = block.config?.groupByCategory !== false;
|
||||
const showYears = block.config?.showYears === true;
|
||||
const showProf = block.config?.showProficiency === true;
|
||||
|
||||
if (groupByCat) {
|
||||
const groups = {};
|
||||
for (const s of skills) {
|
||||
const cat = s.skill_category || 'Other';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(s);
|
||||
}
|
||||
let skillsHTML = Object.entries(groups).map(([cat, items]) => `
|
||||
<div class="skill-group">
|
||||
<div class="skill-cat">${cat}</div>
|
||||
<div class="skill-items">
|
||||
${items.map(s => {
|
||||
let txt = `<span class="skill-item">${s.skill_name}`;
|
||||
if (showProf && s.proficiency) txt += ` (${s.proficiency})`;
|
||||
if (showYears && s.years_experience) txt += ` <span class="skill-years">${s.years_experience}y</span>`;
|
||||
txt += '</span>';
|
||||
return txt;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
return `
|
||||
<div id="block-${blockId}" class="cv-block cv-block-${type} ${block.config?.sidebar ? 'cv-sidebar' : ''}">
|
||||
<div class="cv-section-title">${block.title || 'Skills'}</div>
|
||||
${skillsHTML || '<p style="color:#999">No skills data</p>'}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
let skillsHTML = skills.map(s => {
|
||||
let txt = `<span class="skill-item">${s.skill_name}`;
|
||||
if (showProf && s.proficiency) txt += ` (${s.proficiency})`;
|
||||
if (showYears && s.years_experience) txt += ` <span class="skill-years">${s.years_experience}y</span>`;
|
||||
txt += '</span>';
|
||||
return txt;
|
||||
}).join('');
|
||||
return `
|
||||
<div id="block-${blockId}" class="cv-block cv-block-${type} ${block.config?.sidebar ? 'cv-sidebar' : ''}">
|
||||
<div class="cv-section-title">${block.title || 'Skills'}</div>
|
||||
<div class="skill-items">${skillsHTML}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
case 'Certifications':
|
||||
const certs = cvData.certifications || [];
|
||||
let certHTML = certs.map(c => `
|
||||
<div class="cert-entry">
|
||||
<div class="cert-name">${c.name || ''}</div>
|
||||
${c.issuer ? `<div class="cert-issuer">${c.issuer}</div>` : ''}
|
||||
${c.issue_date ? `<div class="cert-issuer">${fmtDate(c.issue_date)}${c.expiry_date ? ' — ' + fmtDate(c.expiry_date) : ''}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
return `
|
||||
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
||||
<div class="cv-section-title">${block.title || 'Certifications'}</div>
|
||||
${certHTML || '<p style="color:#999">No certifications</p>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'CustomText':
|
||||
return `
|
||||
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
||||
${block.config?.title ? `<div class="cv-section-title">${block.config.title}</div>` : ''}
|
||||
<div>${block.config?.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'Footer':
|
||||
return `
|
||||
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
||||
${block.config?.content || 'Generated on ' + genDate}
|
||||
</div>
|
||||
`;
|
||||
|
||||
default:
|
||||
return `<div id="block-${blockId}" class="cv-block">Unknown block type: ${type}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FULL HTML PAGE ASSEMBLY
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Build a complete HTML document from a template + CV data.
|
||||
*/
|
||||
function buildHTML(template, cvData, options) {
|
||||
const css = generateCanvasCSS(template);
|
||||
const blocksHTML = template.blocks.map(block => renderBlockHTML(block, cvData, options)).join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CV - ${cvData.candidate?.first_name || ''} ${cvData.candidate?.last_name || ''}</title>
|
||||
<style>${css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cv-page" id="cv-page">
|
||||
${blocksHTML}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PUPPETEER PDF RENDERER
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render a CV Template + CV Data to a pixel-perfect A4 PDF.
|
||||
*
|
||||
* @param {Object} template - The JSON CV Template (layout definition)
|
||||
* @param {Object} cvData - The candidate's CV data
|
||||
* @param {Object} options - { generationDate, outputDir }
|
||||
* @returns {Promise<{html: string, pdfPath: string}>}
|
||||
*/
|
||||
async function renderToPDF(template, cvData, options = {}) {
|
||||
const html = buildHTML(template, cvData, options);
|
||||
const outputDir = options.outputDir || path.join(__dirname, 'output');
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const candidateName = `${cvData.candidate?.first_name || 'candidate'}_${cvData.candidate?.last_name || ''}`.trim().replace(/\s+/g, '_');
|
||||
const templateName = (template.templateName || 'cv').replace(/\s+/g, '_');
|
||||
const timestamp = Date.now();
|
||||
const htmlPath = path.join(outputDir, `${candidateName}_${templateName}_${timestamp}.html`);
|
||||
const pdfPath = path.join(outputDir, `${candidateName}_${templateName}_${timestamp}.pdf`);
|
||||
|
||||
// Save HTML for debugging
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
|
||||
// Auto-detect puppeteer chrome path
|
||||
const chromePaths = [
|
||||
path.join(process.env.HOME || '/root', '.cache', 'puppeteer', 'chrome', 'linux-150.0.7871.24', 'chrome-linux64', 'chrome'),
|
||||
];
|
||||
// Also search for any chrome version in the puppeteer cache
|
||||
const pptrCache = path.join(process.env.HOME || '/root', '.cache', 'puppeteer', 'chrome');
|
||||
if (fs.existsSync(pptrCache)) {
|
||||
for (const dir of fs.readdirSync(pptrCache)) {
|
||||
const candidate = path.join(pptrCache, dir, 'chrome-linux64', 'chrome');
|
||||
if (fs.existsSync(candidate)) { chromePaths.unshift(candidate); break; }
|
||||
}
|
||||
}
|
||||
const chromePath = chromePaths.find(p => fs.existsSync(p));
|
||||
|
||||
// Launch Puppeteer and render
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||
executablePath: chromePath,
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set the viewport to exact A4 size at 96 DPI
|
||||
await page.setViewport({
|
||||
width: template.canvas.width,
|
||||
height: template.canvas.height,
|
||||
deviceScaleFactor: 2, // Retina quality
|
||||
});
|
||||
|
||||
// Load the HTML
|
||||
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
|
||||
// Wait a bit for any font loading
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
// Generate PDF with exact A4 dimensions, no margins
|
||||
await page.pdf({
|
||||
path: pdfPath,
|
||||
width: `${template.canvas.width}px`,
|
||||
height: `${template.canvas.height}px`,
|
||||
printBackground: true,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
preferCSSPageSize: false,
|
||||
});
|
||||
|
||||
return { html, htmlPath, pdfPath };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CLI ENTRY POINT (for testing)
|
||||
// ============================================================
|
||||
|
||||
if (require.main === module) {
|
||||
// Example template
|
||||
const exampleTemplate = {
|
||||
templateId: 'template_modern_01',
|
||||
templateName: 'Modern Two-Column Layout',
|
||||
canvas: { width: 794, height: 1123, columns: 12 },
|
||||
blocks: [
|
||||
{ blockId: 'header', type: 'PersonalDetails', x: 0, y: 0, w: 12, h: 10 },
|
||||
{ blockId: 'summary', type: 'ProfessionalSummary', x: 0, y: 10, w: 12, h: 5 },
|
||||
{ blockId: 'experience', type: 'WorkExperience', x: 0, y: 15, w: 8, h: 50, config: { showAchievements: true } },
|
||||
{ blockId: 'skills', type: 'SkillsList', x: 8, y: 15, w: 4, h: 30, config: { groupByCategory: true, showYears: true, sidebar: true } },
|
||||
{ blockId: 'education', type: 'Education', x: 8, y: 45, w: 4, h: 20 },
|
||||
{ blockId: 'footer', type: 'Footer', x: 0, y: 100, w: 12, h: 5, config: { content: 'Confidential — Generated by CV Application' } }
|
||||
]
|
||||
};
|
||||
|
||||
// Example CV data
|
||||
const exampleCVData = {
|
||||
candidate: {
|
||||
first_name: 'John',
|
||||
last_name: 'Smith',
|
||||
email: 'john.smith@email.com',
|
||||
phone: '+1-555-123-4567',
|
||||
address: 'Cape Town, South Africa',
|
||||
linkedin: 'linkedin.com/in/johnsmith',
|
||||
summary: 'Experienced software engineer with 8+ years in full-stack development specializing in .NET technologies and cloud architecture.'
|
||||
},
|
||||
experience: [
|
||||
{
|
||||
position: 'Senior Software Engineer',
|
||||
company: 'TechCorp International',
|
||||
location: 'Cape Town',
|
||||
start_date: '2022-01-01',
|
||||
end_date: null,
|
||||
description: 'Led a team building cloud-native microservices on Azure.',
|
||||
achievements: ['Reduced deployment time by 70%', 'Architected event-driven system with Azure Service Bus'],
|
||||
skills_used: ['C#', 'Azure', 'Docker', 'Kubernetes']
|
||||
},
|
||||
{
|
||||
position: 'Software Engineer',
|
||||
company: 'DataSoft Solutions',
|
||||
location: 'Johannesburg',
|
||||
start_date: '2019-03-01',
|
||||
end_date: '2021-12-01',
|
||||
description: 'Developed ASP.NET Core APIs serving 100k+ daily requests.',
|
||||
achievements: ['Built React admin dashboard', 'Implemented Entity Framework data layer'],
|
||||
skills_used: ['C#', 'ASP.NET Core', 'React', 'TypeScript']
|
||||
}
|
||||
],
|
||||
education: [
|
||||
{ degree: 'BSc Computer Science', field_of_study: 'Computer Science', institution: 'University of Cape Town', start_date: '2014-01-01', end_date: '2017-12-01', grade: 'First Class Honours' }
|
||||
],
|
||||
skills: [
|
||||
{ skill_name: 'C# / .NET', skill_category: 'Programming', proficiency: 'Expert', years_experience: 8.5 },
|
||||
{ skill_name: 'Azure Cloud', skill_category: 'Cloud', proficiency: 'Advanced', years_experience: 6.5 },
|
||||
{ skill_name: 'React / TypeScript', skill_category: 'Frontend', proficiency: 'Advanced', years_experience: 5.5 },
|
||||
{ skill_name: 'SQL Server', skill_category: 'Database', proficiency: 'Expert', years_experience: 8.5 },
|
||||
{ skill_name: 'Docker / Kubernetes', skill_category: 'DevOps', proficiency: 'Advanced', years_experience: 4.5 }
|
||||
],
|
||||
certifications: [
|
||||
{ name: 'Azure Developer Associate', issuer: 'Microsoft', issue_date: '2021-06-01' },
|
||||
{ name: 'CKAD', issuer: 'CNCF', issue_date: '2022-03-01' }
|
||||
]
|
||||
};
|
||||
|
||||
renderToPDF(exampleTemplate, exampleCVData, { generationDate: '2026-07-15' })
|
||||
.then(result => {
|
||||
console.log('PDF generated successfully:');
|
||||
console.log(' HTML:', result.htmlPath);
|
||||
console.log(' PDF:', result.pdfPath);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Render failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { renderToPDF, buildHTML, generateCanvasCSS };
|
||||
@@ -222,7 +222,8 @@ async function loadTemplates() {
|
||||
<div class="text-muted text-sm mt-8">${t.description || ''}</div>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
<button class="btn btn-sm btn-outline" onclick="showBuilder('${t.id}')">Edit in Builder</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="showDesigner('${t.id}')">A4 Designer</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="showBuilder('${t.id}')">Visual Builder</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteTemplate('${t.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -541,6 +542,7 @@ async function viewGeneratedCV(id) {
|
||||
<textarea id="cv-edit-content" style="min-height:500px;font-family:monospace;font-size:13px">${content}</textarea>
|
||||
<div class="flex gap-8 mt-16">
|
||||
<button class="btn btn-green" onclick="saveGeneratedCV('${id}')">Save</button>
|
||||
<button class="btn btn-outline" onclick="downloadGenPDF('${id}')">Download PDF</button>
|
||||
<select id="cv-status-select" class="btn btn-outline">
|
||||
<option value="draft" ${data.status === 'draft' ? 'selected' : ''}>Draft</option>
|
||||
<option value="reviewed" ${data.status === 'reviewed' ? 'selected' : ''}>Reviewed</option>
|
||||
@@ -576,6 +578,19 @@ async function deleteGeneratedCV(id) {
|
||||
loadGeneratedCVs();
|
||||
}
|
||||
|
||||
async function downloadGenPDF(genId) {
|
||||
toast('Rendering PDF...');
|
||||
try {
|
||||
const result = await api('/api/generated-cvs/' + genId + '/render-pdf', 'POST');
|
||||
if (result.pdf_url) {
|
||||
window.open(result.pdf_url, '_blank');
|
||||
toast('PDF generated');
|
||||
}
|
||||
} catch(e) {
|
||||
toast('PDF render failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHAT
|
||||
// ============================================================
|
||||
|
||||
211
static/designer.css
Normal file
211
static/designer.css
Normal file
@@ -0,0 +1,211 @@
|
||||
/* A4 Template Designer with Gridstack.js */
|
||||
@import url('https://cdn.jsdelivr.net/npm/gridstack@11.1.3/gridstack.min.css');
|
||||
|
||||
/* Designer layout */
|
||||
.designer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 16px;
|
||||
min-height: 700px;
|
||||
}
|
||||
|
||||
/* Sidebar palette */
|
||||
.designer-palette {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.designer-palette h3 {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.palette-block {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: grab;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
.palette-block:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(91,141,239,0.08);
|
||||
}
|
||||
.palette-block:active { cursor: grabbing; }
|
||||
.palette-block .icon {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
color: white;
|
||||
}
|
||||
.palette-block .label { flex: 1; }
|
||||
.palette-block .desc { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
/* A4 Canvas container */
|
||||
.designer-canvas-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
max-height: 800px;
|
||||
padding: 20px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* The actual A4 page */
|
||||
.a4-page {
|
||||
width: 794px;
|
||||
height: 1123px;
|
||||
background: white;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Gridstack overrides for the A4 canvas */
|
||||
.a4-page .grid-stack {
|
||||
background: transparent;
|
||||
width: 794px;
|
||||
height: 1123px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.a4-page .grid-stack-item {
|
||||
border: 1px dashed #5b8def;
|
||||
background: rgba(91,141,239,0.05);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.a4-page .grid-stack-item:hover {
|
||||
border-style: solid;
|
||||
background: rgba(91,141,239,0.1);
|
||||
}
|
||||
|
||||
.a4-page .grid-stack-item-content {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
font-family: sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.a4-page .grid-stack-item .block-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #5b8def;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.a4-page .grid-stack-item .block-content {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
max-height: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.a4-page .grid-stack-item .block-config-btn {
|
||||
background: rgba(91,141,239,0.2);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
color: #5b8def;
|
||||
}
|
||||
|
||||
/* Gridstack resize handle styling */
|
||||
.a4-page .grid-stack-item .grid-stack-item-handle {
|
||||
cursor: grab;
|
||||
background: rgba(91,141,239,0.1);
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Block type colors */
|
||||
.block-PersonalDetails { border-left: 3px solid #2c3e50 !important; }
|
||||
.block-WorkExperience { border-left: 3px solid #27ae60 !important; }
|
||||
.block-SkillsList { border-left: 3px solid #e67e22 !important; }
|
||||
.block-Education { border-left: 3px solid #8e44ad !important; }
|
||||
.block-Certifications { border-left: 3px solid #e74c3c !important; }
|
||||
.block-ProfessionalSummary { border-left: 3px solid #3498db !important; }
|
||||
.block-CustomText { border-left: 3px solid #95a5a6 !important; }
|
||||
.block-Footer { border-left: 3px solid #bdc3c7 !important; }
|
||||
|
||||
/* Designer toolbar */
|
||||
.designer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.designer-toolbar input {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
/* Config panel */
|
||||
.block-config-panel {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
width: 320px;
|
||||
z-index: 1100;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
.block-config-panel.active { display: block; }
|
||||
.block-config-panel h3 {
|
||||
font-size: 15px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.block-config-panel .form-group { margin-bottom: 12px; }
|
||||
.block-config-panel label { font-size: 12px; }
|
||||
|
||||
/* Preview modal */
|
||||
.cv-pdf-preview {
|
||||
width: 100%;
|
||||
min-height: 600px;
|
||||
background: white;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.cv-pdf-preview iframe {
|
||||
width: 100%;
|
||||
height: 700px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
442
static/designer.js
Normal file
442
static/designer.js
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* A4 Template Designer using Gridstack.js
|
||||
*
|
||||
* Features:
|
||||
* - Drag-and-drop block placement on an exact A4 (794x1123px) canvas
|
||||
* - 12-column grid with row height of 10px for fine positioning
|
||||
* - Blocks can be moved and resized within canvas boundaries
|
||||
* - Save exports the clean JSON Template Schema
|
||||
* - Live preview with real candidate data
|
||||
* - PDF rendering via the Puppeteer backend
|
||||
*/
|
||||
|
||||
// Block type definitions (palette items)
|
||||
const BLOCK_TYPES = {
|
||||
PersonalDetails: { icon: '♟', label: 'Header', desc: 'Name, contact, title', color: '#2c3e50', defaultW: 12, defaultH: 8 },
|
||||
ProfessionalSummary: { icon: '✎', label: 'Summary', desc: 'Professional summary', color: '#3498db', defaultW: 12, defaultH: 5 },
|
||||
WorkExperience: { icon: '⚙', label: 'Work Experience', desc: 'Job history list', color: '#27ae60', defaultW: 8, defaultH: 20, config: { showAchievements: true, showSkillsUsed: false, maxItems: null } },
|
||||
SkillsList: { icon: '★', label: 'Skills', desc: 'Skills by category', color: '#e67e22', defaultW: 4, defaultH: 15, config: { groupByCategory: true, showYears: true, showProficiency: false, sidebar: true, maxItems: null } },
|
||||
Education: { icon: '⛩', label: 'Education', desc: 'Degrees & institutions', color: '#8e44ad', defaultW: 4, defaultH: 10, config: { showDates: true, showField: true, showGrade: false } },
|
||||
Certifications: { icon: '✅', label: 'Certifications', desc: 'Professional certs', color: '#e74c3c', defaultW: 4, defaultH: 8, config: { showDates: true, showIssuer: true } },
|
||||
CustomText: { icon: '📝', label: 'Custom Text', desc: 'Free-form text block', color: '#95a5a6', defaultW: 12, defaultH: 5, config: { title: '', content: '' } },
|
||||
Footer: { icon: '⚙', label: 'Footer', desc: 'Footer text', color: '#bdc3c7', defaultW: 12, defaultH: 3, config: { content: 'Generated by CV Application' } }
|
||||
};
|
||||
|
||||
// Designer state
|
||||
let designerGrid = null;
|
||||
let designerBlocks = [];
|
||||
let editingTemplateId = null;
|
||||
let blockCounter = 0;
|
||||
|
||||
// ============================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================
|
||||
function showDesigner(templateId = null) {
|
||||
editingTemplateId = templateId;
|
||||
designerBlocks = [];
|
||||
blockCounter = 0;
|
||||
|
||||
// Load template if editing
|
||||
let loadPromise = Promise.resolve();
|
||||
let templateName = 'New Template';
|
||||
let templateDesc = '';
|
||||
|
||||
if (templateId) {
|
||||
loadPromise = api('/api/templates/' + templateId).then(t => {
|
||||
const structure = typeof t.template_structure === 'string'
|
||||
? JSON.parse(t.template_structure)
|
||||
: t.template_structure;
|
||||
if (structure && structure.blocks) {
|
||||
designerBlocks = structure.blocks.map(b => ({...b}));
|
||||
blockCounter = designerBlocks.length;
|
||||
}
|
||||
templateName = t.name || templateName;
|
||||
templateDesc = t.description || '';
|
||||
});
|
||||
}
|
||||
|
||||
loadPromise.then(() => {
|
||||
const body = `
|
||||
<div class="designer-toolbar">
|
||||
<input type="text" id="designer-name" placeholder="Template name" value="${templateName}">
|
||||
<input type="text" id="designer-desc" placeholder="Description" value="${templateDesc}" style="max-width:200px">
|
||||
<button class="btn btn-sm" onclick="saveDesignerTemplate()">Save Template</button>
|
||||
${templateId ? '<button class="btn btn-sm btn-green" onclick="saveDesignerTemplate(true)">Update</button>' : ''}
|
||||
<button class="btn btn-sm btn-outline" onclick="previewDesignerPDF()">Preview PDF</button>
|
||||
<span class="text-muted text-sm" style="margin-left:auto">A4: 794×1123px · 12 columns</span>
|
||||
</div>
|
||||
<div class="designer-layout">
|
||||
<div class="designer-palette">
|
||||
<h3>Template Blocks</h3>
|
||||
<div id="designer-palette-list"></div>
|
||||
</div>
|
||||
<div class="designer-canvas-area">
|
||||
<div class="a4-page" id="a4-page">
|
||||
<div class="grid-stack" id="designer-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block-config-panel" id="block-config-panel"></div>
|
||||
`;
|
||||
showModal(body, 'A4 Template Designer', 'large');
|
||||
|
||||
// Load Gridstack CSS dynamically
|
||||
if (!document.getElementById('gridstack-css')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'gridstack-css';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://cdn.jsdelivr.net/npm/gridstack@11.1.3/gridstack.min.css';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Load Gridstack JS dynamically
|
||||
if (typeof GridStack === 'undefined') {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/gridstack@11.1.3/gridstack-all-jq.js';
|
||||
script.onload = () => initGridstack();
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
initGridstack();
|
||||
}
|
||||
|
||||
renderDesignerPalette();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GRIDSTACK INITIALIZATION
|
||||
// ============================================================
|
||||
function initGridstack() {
|
||||
// Calculate grid options for A4
|
||||
// 794px width / 12 columns = ~66.17px per column
|
||||
// Use 10px row height for fine vertical control (1123/10 = ~112 rows)
|
||||
const cellHeight = 10;
|
||||
const numCols = 12;
|
||||
const numRows = Math.floor(1123 / cellHeight); // ~112 rows
|
||||
|
||||
designerGrid = GridStack.init({
|
||||
column: numCols,
|
||||
cellHeight: cellHeight + 'px',
|
||||
maxRow: numRows,
|
||||
minRow: numRows,
|
||||
staticGrid: false,
|
||||
draggable: { handle: '.grid-stack-item-handle' },
|
||||
resizable: { handles: 'e, se, s, sw, w' },
|
||||
float: true, // Allow blocks to float (not collapse)
|
||||
margin: 0,
|
||||
disableOneColumnMode: true,
|
||||
}, '#designer-grid');
|
||||
|
||||
// Add existing blocks (if editing a template)
|
||||
for (const block of designerBlocks) {
|
||||
addBlockToGrid(block, false);
|
||||
}
|
||||
|
||||
// Listen for grid changes
|
||||
designerGrid.on('change', (event, items) => {
|
||||
for (const item of items) {
|
||||
updateBlockFromGridItem(item);
|
||||
}
|
||||
});
|
||||
|
||||
designerGrid.on('removed', (event, items) => {
|
||||
for (const item of items) {
|
||||
removeBlock(item.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up palette drag-to-canvas
|
||||
setupPaletteDrag();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PALETTE
|
||||
// ============================================================
|
||||
function renderDesignerPalette() {
|
||||
const el = document.getElementById('designer-palette-list');
|
||||
if (!el) return;
|
||||
el.innerHTML = Object.entries(BLOCK_TYPES).map(([type, info]) => `
|
||||
<div class="palette-block" data-type="${type}" onclick="addBlockFromPalette('${type}')">
|
||||
<div class="icon" style="background:${info.color}">${info.icon}</div>
|
||||
<div>
|
||||
<div class="label">${info.label}</div>
|
||||
<div class="desc">${info.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setupPaletteDrag() {
|
||||
// Gridstack native widget drop from sidebar
|
||||
if (designerGrid) {
|
||||
designerGrid.removeDraggable('.palette-block');
|
||||
document.querySelectorAll('.palette-block').forEach(el => {
|
||||
designerGrid.draggable(el, {
|
||||
appendTo: '#a4-page',
|
||||
helper: 'clone',
|
||||
revert: 'invalid',
|
||||
handle: 'div',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BLOCK MANAGEMENT
|
||||
// ============================================================
|
||||
function addBlockFromPalette(type) {
|
||||
const info = BLOCK_TYPES[type];
|
||||
if (!info) return;
|
||||
|
||||
blockCounter++;
|
||||
const block = {
|
||||
blockId: `block-${blockCounter}`,
|
||||
type: type,
|
||||
title: info.label,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: info.defaultW,
|
||||
h: info.defaultH,
|
||||
config: { ...(info.config || {}) }
|
||||
};
|
||||
designerBlocks.push(block);
|
||||
addBlockToGrid(block, true);
|
||||
}
|
||||
|
||||
function addBlockToGrid(block, isNew) {
|
||||
if (!designerGrid) return;
|
||||
|
||||
const info = BLOCK_TYPES[block.type] || BLOCK_TYPES.CustomText;
|
||||
const widgetHTML = `
|
||||
<div class="block-label">
|
||||
<span>${info.icon} ${block.title || info.label}</span>
|
||||
<span>
|
||||
<button class="block-config-btn" onclick="showBlockConfig('${block.blockId}')">⚙</button>
|
||||
<button class="block-config-btn" onclick="deleteBlockFromGrid('${block.blockId}')">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="block-content">${getBlockPreviewText(block)}</div>
|
||||
`;
|
||||
|
||||
const widget = designerGrid.addWidget({
|
||||
id: block.blockId,
|
||||
x: block.x,
|
||||
y: block.y,
|
||||
w: block.w,
|
||||
h: block.h,
|
||||
content: widgetHTML,
|
||||
});
|
||||
|
||||
// Add type class for color coding
|
||||
const el = document.querySelector(`[gs-id="${block.blockId}"]`);
|
||||
if (el) el.classList.add(`block-${block.type}`);
|
||||
}
|
||||
|
||||
function getBlockPreviewText(block) {
|
||||
switch (block.type) {
|
||||
case 'PersonalDetails': return '<strong>John Smith</strong><br>john@email.com | +1-555-123-4567<br>Cape Town, SA';
|
||||
case 'ProfessionalSummary': return 'Experienced software engineer with 8+ years...';
|
||||
case 'WorkExperience': return 'Senior Engineer — TechCorp (2022-Present)<br>Software Engineer — DataSoft (2019-2021)';
|
||||
case 'SkillsList': return '<strong>Programming:</strong> C#, React, Python<br><strong>Cloud:</strong> Azure, Docker';
|
||||
case 'Education': return 'BSc Computer Science<br>University of Cape Town';
|
||||
case 'Certifications': return 'Azure Developer Associate<br>Certified Kubernetes App Developer';
|
||||
case 'CustomText': return block.config?.content || 'Custom text block';
|
||||
case 'Footer': return block.config?.content || 'Generated by CV Application';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function updateBlockFromGridItem(item) {
|
||||
const block = designerBlocks.find(b => b.blockId === item.id);
|
||||
if (block) {
|
||||
block.x = item.x;
|
||||
block.y = item.y;
|
||||
block.w = item.w;
|
||||
block.h = item.h;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteBlockFromGrid(blockId) {
|
||||
if (!designerGrid) return;
|
||||
const el = document.querySelector(`[gs-id="${blockId}"]`);
|
||||
if (el) designerGrid.removeWidget(el);
|
||||
removeBlock(blockId);
|
||||
}
|
||||
|
||||
function removeBlock(blockId) {
|
||||
designerBlocks = designerBlocks.filter(b => b.blockId !== blockId);
|
||||
const panel = document.getElementById('block-config-panel');
|
||||
if (panel) panel.classList.remove('active');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BLOCK CONFIGURATION PANEL
|
||||
// ============================================================
|
||||
function showBlockConfig(blockId) {
|
||||
const block = designerBlocks.find(b => b.blockId === blockId);
|
||||
if (!block) return;
|
||||
|
||||
const panel = document.getElementById('block-config-panel');
|
||||
const info = BLOCK_TYPES[block.type];
|
||||
const st = block.config || {};
|
||||
|
||||
let configFields = '';
|
||||
|
||||
// Common: title
|
||||
configFields += `
|
||||
<div class="form-group">
|
||||
<label>Section Title (shown in CV)</label>
|
||||
<input type="text" value="${block.title || ''}" onchange="updateBlockConfig('${blockId}','title',this.value)">
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Type-specific config
|
||||
if (block.type === 'WorkExperience') {
|
||||
configFields += `
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showAchievements!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showAchievements',this.checked)"> Show achievements</label></div>
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showSkillsUsed?'checked':''} onchange="updateBlockConfig('${blockId}','config.showSkillsUsed',this.checked)"> Show skills used</label></div>
|
||||
<div class="form-group"><label>Max items (blank = all)</label><input type="number" value="${st.maxItems||''}" placeholder="all" onchange="updateBlockConfig('${blockId}','config.maxItems',this.value?parseInt(this.value):null)"></div>
|
||||
`;
|
||||
} else if (block.type === 'SkillsList') {
|
||||
configFields += `
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.groupByCategory!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.groupByCategory',this.checked)"> Group by category</label></div>
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showYears?'checked':''} onchange="updateBlockConfig('${blockId}','config.showYears',this.checked)"> Show years of experience</label></div>
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showProficiency?'checked':''} onchange="updateBlockConfig('${blockId}','config.showProficiency',this.checked)"> Show proficiency level</label></div>
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.sidebar?'checked':''} onchange="updateBlockConfig('${blockId}','config.sidebar',this.checked)"> Sidebar styling (shaded background)</label></div>
|
||||
<div class="form-group"><label>Max items (blank = all)</label><input type="number" value="${st.maxItems||''}" placeholder="all" onchange="updateBlockConfig('${blockId}','config.maxItems',this.value?parseInt(this.value):null)"></div>
|
||||
`;
|
||||
} else if (block.type === 'Education') {
|
||||
configFields += `
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showDates!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showDates',this.checked)"> Show dates</label></div>
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showField!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showField',this.checked)"> Show field of study</label></div>
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showGrade?'checked':''} onchange="updateBlockConfig('${blockId}','config.showGrade',this.checked)"> Show grade</label></div>
|
||||
`;
|
||||
} else if (block.type === 'Certifications') {
|
||||
configFields += `
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showDates!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showDates',this.checked)"> Show dates</label></div>
|
||||
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showIssuer!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showIssuer',this.checked)"> Show issuer</label></div>
|
||||
`;
|
||||
} else if (block.type === 'CustomText' || block.type === 'Footer') {
|
||||
configFields += `
|
||||
<div class="form-group"><label>Content</label><textarea style="min-height:80px" onchange="updateBlockConfig('${blockId}','config.content',this.value)">${st.content||''}</textarea></div>
|
||||
`;
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<h3>${info.icon} ${info.label} Config</h3>
|
||||
<p class="text-muted text-sm" style="margin-bottom:12px">Block ID: ${blockId}</p>
|
||||
${configFields}
|
||||
<button class="btn btn-sm btn-outline w-full" onclick="document.getElementById('block-config-panel').classList.remove('active')">Close</button>
|
||||
`;
|
||||
panel.classList.add('active');
|
||||
}
|
||||
|
||||
function updateBlockConfig(blockId, keyPath, value) {
|
||||
const block = designerBlocks.find(b => b.blockId === blockId);
|
||||
if (!block) return;
|
||||
|
||||
const keys = keyPath.split('.');
|
||||
let obj = block;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!obj[keys[i]]) obj[keys[i]] = {};
|
||||
obj = obj[keys[i]];
|
||||
}
|
||||
obj[keys[keys.length - 1]] = value;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SERIALIZE / SAVE
|
||||
// ============================================================
|
||||
function getTemplateSchema() {
|
||||
// Read current positions from grid
|
||||
if (designerGrid) {
|
||||
const items = designerGrid.save(false); // save without DOM content
|
||||
for (const item of items) {
|
||||
const block = designerBlocks.find(b => b.blockId === item.id);
|
||||
if (block) {
|
||||
block.x = item.x;
|
||||
block.y = item.y;
|
||||
block.w = item.w;
|
||||
block.h = item.h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
templateId: editingTemplateId || `template_${Date.now()}`,
|
||||
templateName: document.getElementById('designer-name')?.value || 'Untitled',
|
||||
canvas: {
|
||||
width: 794,
|
||||
height: 1123,
|
||||
columns: 12
|
||||
},
|
||||
blocks: designerBlocks.map(b => ({
|
||||
blockId: b.blockId,
|
||||
type: b.type,
|
||||
title: b.title,
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
w: b.w,
|
||||
h: b.h,
|
||||
config: b.config || {}
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async function saveDesignerTemplate(isUpdate = false) {
|
||||
const schema = getTemplateSchema();
|
||||
const name = document.getElementById('designer-name')?.value || 'Untitled Template';
|
||||
const desc = document.getElementById('designer-desc')?.value || '';
|
||||
|
||||
try {
|
||||
if (isUpdate && editingTemplateId) {
|
||||
await api('/api/templates/' + editingTemplateId, 'PUT', {
|
||||
name, description: desc, template_structure: schema, styling: ''
|
||||
});
|
||||
toast('Template updated');
|
||||
} else {
|
||||
await api('/api/templates', 'POST', {
|
||||
name, description: desc, template_structure: schema, styling: '',
|
||||
created_by: 'manual'
|
||||
});
|
||||
toast('Template saved');
|
||||
}
|
||||
closeModal();
|
||||
loadTemplates();
|
||||
} catch(e) {
|
||||
toast('Save failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PDF PREVIEW
|
||||
// ============================================================
|
||||
async function previewDesignerPDF() {
|
||||
const schema = getTemplateSchema();
|
||||
|
||||
// Get first candidate for preview
|
||||
const candidatesData = await api('/api/candidates?limit=1');
|
||||
if (!candidatesData.candidates.length) {
|
||||
toast('Upload a CV first to preview with real data', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateData = await api('/api/candidates/' + candidatesData.candidates[0].id);
|
||||
|
||||
toast('Rendering PDF...');
|
||||
|
||||
try {
|
||||
const result = await api('/api/render-pdf', 'POST', {
|
||||
template: schema,
|
||||
cv_data: candidateData
|
||||
});
|
||||
|
||||
if (result.pdf_url) {
|
||||
// Open PDF in new tab
|
||||
window.open(result.pdf_url, '_blank');
|
||||
toast('PDF generated');
|
||||
}
|
||||
} catch(e) {
|
||||
toast('PDF render failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>CV Application</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/builder.css">
|
||||
<link rel="stylesheet" href="/static/designer.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -65,7 +66,8 @@
|
||||
<div id="page-templates" class="page">
|
||||
<h2 style="margin-bottom:20px">CV Templates</h2>
|
||||
<div class="flex mb-16 gap-8">
|
||||
<button class="btn" onclick="showBuilder()">+ Visual Builder</button>
|
||||
<button class="btn" onclick="showDesigner()">+ A4 Designer</button>
|
||||
<button class="btn btn-outline" onclick="showBuilder()">+ Visual Builder</button>
|
||||
<button class="btn btn-outline" onclick="showTemplateModal()">+ Manual (JSON)</button>
|
||||
<button class="btn btn-outline" onclick="showTemplateGenModal()">Generate with AI</button>
|
||||
</div>
|
||||
@@ -108,5 +110,6 @@
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/builder.js"></script>
|
||||
<script src="/static/designer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user