From 70735502f2a7c0cba4b867996a19919a748c3b10 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 07:57:16 +0000 Subject: [PATCH] feat: A4 Gridstack.js template designer + Puppeteer PDF renderer --- __pycache__/main.cpython-312.pyc | Bin 39659 -> 48074 bytes main.py | 139 ++++++- renderer/.gitignore | 4 + renderer/package-lock.json | 331 ++++++++++++++++ renderer/package.json | 15 + renderer/render.js | 641 +++++++++++++++++++++++++++++++ static/app.js | 17 +- static/designer.css | 211 ++++++++++ static/designer.js | 442 +++++++++++++++++++++ static/index.html | 5 +- 10 files changed, 1802 insertions(+), 3 deletions(-) create mode 100644 renderer/.gitignore create mode 100644 renderer/package-lock.json create mode 100644 renderer/package.json create mode 100644 renderer/render.js create mode 100644 static/designer.css create mode 100644 static/designer.js diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index 090c6d62fac8d7a96eaf5a288ea2fc36c6e4d5e9..7faad21f42accd7b774acd13e3390311ebae2f5d 100644 GIT binary patch delta 12977 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 Qiw9Zp+V4HC#y9N$16pPop8x;= delta 6096 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 EA5OjRNB{r; diff --git a/main.py b/main.py index 508dd05..55990b4 100644 --- a/main.py +++ b/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/", "html_url": "/api/html/"} + """ + 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 # ============================================================ diff --git a/renderer/.gitignore b/renderer/.gitignore new file mode 100644 index 0000000..2218a2e --- /dev/null +++ b/renderer/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +output/ +*.pdf +*.html \ No newline at end of file diff --git a/renderer/package-lock.json b/renderer/package-lock.json new file mode 100644 index 0000000..7b5a118 --- /dev/null +++ b/renderer/package-lock.json @@ -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" + } + } + } +} diff --git a/renderer/package.json b/renderer/package.json new file mode 100644 index 0000000..10d9ad1 --- /dev/null +++ b/renderer/package.json @@ -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" + } +} diff --git a/renderer/render.js b/renderer/render.js new file mode 100644 index 0000000..fe6f7b1 --- /dev/null +++ b/renderer/render.js @@ -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(`๐Ÿ“ง ${c.email}`); + if (c.phone) contactParts.push(`๐Ÿ“ž ${c.phone}`); + if (c.address) contactParts.push(`๐Ÿ“ ${c.address}`); + if (c.linkedin) contactParts.push(`๐Ÿ”— ${c.linkedin}`); + if (c.github) contactParts.push(`๐Ÿ’ป ${c.github}`); + if (c.website) contactParts.push(`๐ŸŒ ${c.website}`); + return ` +
+

${c.first_name || ''} ${c.last_name || ''}

+ ${c.summary ? `
${c.summary.split('.')[0]}.
` : ''} +
${contactParts.join('')}
+
+ `; + + case 'ProfessionalSummary': + const summary = cvData.candidate?.summary || cvData.summary || ''; + return ` +
+
${block.title || 'Professional Summary'}
+

${summary}

+
+ `; + + 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 ` +
+
${e.position || ''}
+
${e.company || ''}${e.location ? ' โ€” ' + e.location : ''}
+
${startStr} โ€” ${endStr}
+ ${e.description ? `
${e.description}
` : ''} + ${showAch && ach.length ? `
    ${ach.map(a => `
  • ${a}
  • `).join('')}
` : ''} + ${showSkills && skillsUsed.length ? `
Skills: ${skillsUsed.join(', ')}
` : ''} +
+ `; + }).join(''); + return ` +
+
${block.title || 'Work Experience'}
+ ${expHTML || '

No experience data

'} +
+ `; + + case 'Education': + const educations = cvData.education || []; + let eduHTML = educations.map(e => ` +
+
${e.degree || ''}${e.field_of_study ? ' in ' + e.field_of_study : ''}
+
${e.institution || ''}
+
${e.start_date ? fmtDate(e.start_date) : ''} โ€” ${e.end_date ? fmtDate(e.end_date) : ''}
+ ${e.grade ? `
Grade: ${e.grade}
` : ''} +
+ `).join(''); + return ` +
+
${block.title || 'Education'}
+ ${eduHTML || '

No education data

'} +
+ `; + + 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]) => ` +
+
${cat}
+
+ ${items.map(s => { + let txt = `${s.skill_name}`; + if (showProf && s.proficiency) txt += ` (${s.proficiency})`; + if (showYears && s.years_experience) txt += ` ${s.years_experience}y`; + txt += ''; + return txt; + }).join('')} +
+
+ `).join(''); + return ` +
+
${block.title || 'Skills'}
+ ${skillsHTML || '

No skills data

'} +
+ `; + } else { + let skillsHTML = skills.map(s => { + let txt = `${s.skill_name}`; + if (showProf && s.proficiency) txt += ` (${s.proficiency})`; + if (showYears && s.years_experience) txt += ` ${s.years_experience}y`; + txt += ''; + return txt; + }).join(''); + return ` +
+
${block.title || 'Skills'}
+
${skillsHTML}
+
+ `; + } + + case 'Certifications': + const certs = cvData.certifications || []; + let certHTML = certs.map(c => ` +
+
${c.name || ''}
+ ${c.issuer ? `
${c.issuer}
` : ''} + ${c.issue_date ? `
${fmtDate(c.issue_date)}${c.expiry_date ? ' โ€” ' + fmtDate(c.expiry_date) : ''}
` : ''} +
+ `).join(''); + return ` +
+
${block.title || 'Certifications'}
+ ${certHTML || '

No certifications

'} +
+ `; + + case 'CustomText': + return ` +
+ ${block.config?.title ? `
${block.config.title}
` : ''} +
${block.config?.content || ''}
+
+ `; + + case 'Footer': + return ` +
+ ${block.config?.content || 'Generated on ' + genDate} +
+ `; + + default: + return `
Unknown block type: ${type}
`; + } +} + +// ============================================================ +// 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 ` + + + + + CV - ${cvData.candidate?.first_name || ''} ${cvData.candidate?.last_name || ''} + + + +
+ ${blocksHTML} +
+ +`; +} + +// ============================================================ +// 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 }; \ No newline at end of file diff --git a/static/app.js b/static/app.js index 5113c01..074989a 100644 --- a/static/app.js +++ b/static/app.js @@ -222,7 +222,8 @@ async function loadTemplates() {
${t.description || ''}
- + +
@@ -541,6 +542,7 @@ async function viewGeneratedCV(id) {
+ + + + ${templateId ? '' : ''} + + A4: 794×1123px ยท 12 columns +
+
+
+

Template Blocks

+
+
+
+
+
+
+
+
+
+ `; + 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]) => ` +
+
${info.icon}
+
+
${info.label}
+
${info.desc}
+
+
+ `).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 = ` +
+ ${info.icon} ${block.title || info.label} + + + + +
+
${getBlockPreviewText(block)}
+ `; + + 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 'John Smith
john@email.com | +1-555-123-4567
Cape Town, SA'; + case 'ProfessionalSummary': return 'Experienced software engineer with 8+ years...'; + case 'WorkExperience': return 'Senior Engineer โ€” TechCorp (2022-Present)
Software Engineer โ€” DataSoft (2019-2021)'; + case 'SkillsList': return 'Programming: C#, React, Python
Cloud: Azure, Docker'; + case 'Education': return 'BSc Computer Science
University of Cape Town'; + case 'Certifications': return 'Azure Developer Associate
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 += ` +
+ + +
+ `; + + // Type-specific config + if (block.type === 'WorkExperience') { + configFields += ` +
+
+
+ `; + } else if (block.type === 'SkillsList') { + configFields += ` +
+
+
+
+
+ `; + } else if (block.type === 'Education') { + configFields += ` +
+
+
+ `; + } else if (block.type === 'Certifications') { + configFields += ` +
+
+ `; + } else if (block.type === 'CustomText' || block.type === 'Footer') { + configFields += ` +
+ `; + } + + panel.innerHTML = ` +

${info.icon} ${info.label} Config

+

Block ID: ${blockId}

+ ${configFields} + + `; + 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'); + } +} \ No newline at end of file diff --git a/static/index.html b/static/index.html index b22e9ee..b7c6119 100644 --- a/static/index.html +++ b/static/index.html @@ -6,6 +6,7 @@ CV Application +
@@ -65,7 +66,8 @@

CV Templates

- + +
@@ -108,5 +110,6 @@ + \ No newline at end of file