diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index 090c6d6..7faad21 100644 Binary files a/__pycache__/main.cpython-312.pyc and b/__pycache__/main.cpython-312.pyc differ 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