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

Binary file not shown.

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

4
renderer/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
output/
*.pdf
*.html

331
renderer/package-lock.json generated Normal file
View 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
View 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
View 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 };

View File

@@ -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
View 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
View 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: '&#9823;', label: 'Header', desc: 'Name, contact, title', color: '#2c3e50', defaultW: 12, defaultH: 8 },
ProfessionalSummary: { icon: '&#9998;', label: 'Summary', desc: 'Professional summary', color: '#3498db', defaultW: 12, defaultH: 5 },
WorkExperience: { icon: '&#9881;', label: 'Work Experience', desc: 'Job history list', color: '#27ae60', defaultW: 8, defaultH: 20, config: { showAchievements: true, showSkillsUsed: false, maxItems: null } },
SkillsList: { icon: '&#9733;', 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: '&#9961;', label: 'Education', desc: 'Degrees & institutions', color: '#8e44ad', defaultW: 4, defaultH: 10, config: { showDates: true, showField: true, showGrade: false } },
Certifications: { icon: '&#9989;', label: 'Certifications', desc: 'Professional certs', color: '#e74c3c', defaultW: 4, defaultH: 8, config: { showDates: true, showIssuer: true } },
CustomText: { icon: '&#128221;', label: 'Custom Text', desc: 'Free-form text block', color: '#95a5a6', defaultW: 12, defaultH: 5, config: { title: '', content: '' } },
Footer: { icon: '&#9881;', 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&times;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}')">&#9881;</button>
<button class="block-config-btn" onclick="deleteBlockFromGrid('${block.blockId}')">&times;</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');
}
}

View File

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