/* pdf.jsx — TRUE text-based PDF export (selectable, ATS-parseable). Builds the PDF from the résumé DATA with jsPDF's standard fonts, in clean single-column reading order. Every character is real text — copy/paste and ATS keyword extraction work. Standard fonts (Helvetica / Times) are the safest possible choice for applicant-tracking systems. */ function ensureJsPDF() { if (window.jspdf && window.jspdf.jsPDF) return Promise.resolve(true); if (!window.__jspdfP) { window.__jspdfP = new Promise((res) => { const s = document.createElement("script"); s.src = "https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"; s.onload = () => res(!!(window.jspdf && window.jspdf.jsPDF)); s.onerror = () => res(false); document.head.appendChild(s); }); } return window.__jspdfP; } function loadPdfLibs() { return ensureJsPDF(); } function hexToRgb(hex) { const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || ""); return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [20, 23, 31]; } /* HTML (rich-text custom section) -> ordered blocks of {type, text} */ function htmlToBlocks(html) { if (!html) return []; const root = document.createElement("div"); root.innerHTML = DOMPurify.sanitize(html); const blocks = []; const pushPara = (t) => { const s = t.replace(/\s+/g, " ").trim(); if (s) blocks.push({ type: "para", text: s }); }; root.childNodes.forEach((node) => { if (node.nodeType === 3) { pushPara(node.textContent); return; } const tag = (node.tagName || "").toLowerCase(); if (tag === "ul" || tag === "ol") { node.querySelectorAll("li").forEach((li) => { const s = li.textContent.replace(/\s+/g, " ").trim(); if (s) blocks.push({ type: "bullet", text: s }); }); } else if (tag === "br") { // skip } else { pushPara(node.textContent); } }); if (!blocks.length) pushPara(root.textContent); return blocks; } /* Per-template PDF style profiles — keep every template visually distinct while staying real, selectable, single-column text. */ const PDF_STYLES = { classic: { fam: "times", align: "center", name: { size: 23 }, title: { c: "sub", italic: true }, contact: { bar: "none", sep: " • " }, head: { rule: "full", c: "ink", upper: true, size: 11.5, sp: 1.4 }, comp: { c: "ink", italic: true } }, modern: { fam: "helvetica", align: "left", name: { size: 24, bold: true }, title: { c: "accent" }, contact: { bar: "none", sep: " | " },head: { rule: "accent", c: "ink", upper: true, size: 11.5, sp: 1.2 }, comp: { c: "accent" } }, minimal: { fam: "helvetica", align: "left", name: { size: 25 }, title: { c: "gray" }, contact: { bar: "none", sep: " · " },head: { rule: "none", c: "gray", upper: true, size: 10, sp: 3 }, comp: { c: "ink" } }, executive: { fam: "times", align: "center", name: { size: 23, upper: true, sp: 2 }, title: { c: "accent", upper: true, size: 10.5, sp: 2 }, contact: { bar: "none", sep: " • " }, head: { rule: "shortAccent", c: "ink", upper: false, size: 13 }, comp: { c: "ink", italic: true } }, technical: { fam: "courier", align: "left", name: { size: 21, bold: true }, title: { c: "accent" }, contact: { bar: "none", sep: " / " }, head: { rule: "block", c: "ink", upper: true, size: 11, sp: .8 }, comp: { c: "accent" } }, academic: { fam: "times", align: "center", name: { size: 22, upper: true, sp: 2.6 }, title: { c: "sub" }, contact: { bar: "none", sep: " · " },head: { rule: "full", c: "ink", upper: false, size: 13 }, comp: { c: "ink", italic: true } }, cobalt: { fam: "helvetica", align: "left", name: { size: 24, bold: true, upper: true, split: true }, title: { c: "sub" }, contact: { bar: "dark", sep: " | " },head: { rule: "accent", c: "ink", upper: false, size: 13 }, comp: { c: "accent" } }, slab: { fam: "times", align: "left", name: { size: 25, bold: true, upper: true }, title: { c: "accent", bold: true }, contact: { bar: "none", sep: " • " }, head: { rule: "thick", c: "ink", upper: true, size: 13, sp: 1 }, comp: { c: "accent" } }, indigo: { fam: "helvetica", align: "left", name: { size: 24 }, title: { c: "gray" }, contact: { bar: "none", sep: " · " },head: { rule: "none", c: "gray", upper: true, size: 10.5, sp: 3 }, comp: { c: "accent" } }, novo: { fam: "helvetica", align: "left", name: { size: 25, bold: true }, title: { c: "accent" }, contact: { bar: "dark", sep: " • " }, head: { rule: "accent", c: "accent", upper: true, size: 11.5, sp: 1 }, comp: { c: "accent" } }, slate: { fam: "helvetica", align: "left", name: { size: 25, bold: true }, title: { c: "sub" }, contact: { bar: "dark", sep: " | " },head: { rule: "none", c: "accent", upper: true, size: 12, sp: 1.5 }, comp: { c: "accent" } }, timeline: { fam: "helvetica", align: "left", name: { size: 23, bold: true, upper: true, color: "accent" }, title: { c: "accent" }, contact: { bar: "none", sep: " • " }, head: { rule: "none", c: "accent", upper: true, size: 12, sp: 1 }, comp: { c: "accent" } }, sidebar: { fam: "helvetica", align: "left", twocol: "left", name: { size: 24, bold: true, upper: true }, title: { c: "accent" }, contact: { bar: "none", sep: " · " },head: { rule: "full", c: "gray", upper: true, size: 11, sp: 1.5 }, comp: { c: "accent" } }, split: { fam: "helvetica", align: "left", twocol: "right", name: { size: 24, bold: true, upper: true, color: "ink" }, title: { c: "accent" }, contact: { bar: "accent", sep: " · " },head: { rule: "full", c: "gray", upper: true, size: 11, sp: 1.5 }, comp: { c: "accent" } }, }; function getPdfStyle(t) { return PDF_STYLES[t] || PDF_STYLES.modern; } async function downloadResumePDF(filename, payload) { const ok = await ensureJsPDF(); if (!ok || !payload) { window.print(); return false; } const { data = {}, template = "modern", accent = "#1C9BE6" } = payload; const PR = getPdfStyle(template); const FAM = PR.fam; const centered = PR.align === "center"; const serif = FAM === "times"; const { jsPDF } = window.jspdf; const pdf = new jsPDF({ unit: "pt", format: "letter", compress: !(payload.__noCompress) }); // Standard PDF fonts (Helvetica/Times) only encode a limited Latin set — // normalise Unicode dashes/quotes/ellipsis so nothing silently drops out. const sanitize = (s) => String(s) .replace(/[\u2012-\u2015\u2212]/g, "-") .replace(/[\u2018\u2019\u201B]/g, "'") .replace(/[\u201C\u201D]/g, '"') .replace(/\u2026/g, "...") .replace(/\u00A0/g, " ") .replace(/[\u2022\u25CF\u25E6\u2023]/g, "\u2022"); const __text = pdf.text.bind(pdf); pdf.text = (str, ...rest) => __text(typeof str === "string" ? sanitize(str) : str, ...rest); const __split = pdf.splitTextToSize.bind(pdf); pdf.splitTextToSize = (str, ...rest) => __split(sanitize(str == null ? "" : str), ...rest); const PW = 612, PH = 792; const MX = 52, MT = 50, MB = 48; const CW = PW - MX * 2; const L = MX, R = PW - MX, MID = PW / 2; const AC = hexToRgb(accent); const INK = [26, 28, 36]; const SUB = [70, 76, 92]; const META = [122, 128, 150]; const RULE = [205, 210, 220]; const COL = { ink: INK, sub: SUB, gray: META, accent: AC, white: [255, 255, 255] }; const col = (k) => COL[k] || INK; const charsp = (sp) => pdf.setCharSpace(sp || 0); let y = MT; const setF = (style, size) => { pdf.setFont(FAM, style); pdf.setFontSize(size); }; const setC = (rgb) => pdf.setTextColor(rgb[0], rgb[1], rgb[2]); const bulletDot = () => { pdf.setFillColor(META[0], META[1], META[2]); pdf.circle(L + 4, y + 5.4, 1.5, "F"); }; const space = (h) => { if (y + h > PH - MB) { pdf.addPage(); y = MT; return true; } return false; }; // draw wrapped text starting at top y (baseline-managed), return block height function lines(text, { size = 10, style = "normal", color = INK, x = L, w = CW, align = "left", lh = 1.34, gapAfter = 0 } = {}) { setF(style, size); setC(color); const arr = pdf.splitTextToSize(String(text == null ? "" : text), w); const step = size * lh; space(step); // make sure at least first line fits for (const ln of arr) { if (y + step > PH - MB) { pdf.addPage(); y = MT; } const bx = align === "center" ? MID : align === "right" ? x : x; pdf.text(ln, bx, y + size * 0.86, { align, maxWidth: w }); y += step; } y += gapAfter; return arr.length * step + gapAfter; } // measure (no draw) wrapped line count for keeping blocks together function measure(text, size, w) { setF("normal", size); return pdf.splitTextToSize(String(text || ""), w).length * size * 1.34; } function rule(color = RULE, gapBefore = 2, gapAfter = 8) { y += gapBefore; pdf.setDrawColor(color[0], color[1], color[2]); pdf.setLineWidth(0.7); pdf.line(L, y, R, y); y += gapAfter; } function sectionHead(label) { const H = PR.head; const fs = H.size || 11.5; const need = fs * 1.34 + 14 + 14; if (y + need > PH - MB) { pdf.addPage(); y = MT; } y += 14; const txt = H.upper ? label.toUpperCase() : label; const headCol = col(H.c); if (H.rule === "block") { // accent bar to the left of the heading pdf.setFillColor(AC[0], AC[1], AC[2]); pdf.rect(L, y + 1, 3.5, fs, "F"); setF("bold", fs); setC(headCol); charsp(H.sp); pdf.text(txt, L + 11, y + fs * 0.86); charsp(0); y += fs * 1.34 + 8; return; } setF("bold", fs); setC(headCol); charsp(H.sp); pdf.text(txt, centered ? MID : L, y + fs * 0.86, { align: centered ? "center" : "left" }); charsp(0); y += fs * 1.34; if (H.rule === "full") { rule(RULE, 2, 9); } else if (H.rule === "accent") { rule(AC, 2, 9); } else if (H.rule === "thick") { y += 2; pdf.setDrawColor(28, 31, 42); pdf.setLineWidth(1.8); pdf.line(L, y, R, y); y += 9; } else if (H.rule === "shortAccent") { y += 3; pdf.setDrawColor(AC[0], AC[1], AC[2]); pdf.setLineWidth(2); if (centered) pdf.line(MID - 24, y, MID + 24, y); else pdf.line(L, y, L + 48, y); y += 10; } else { y += 8; } // none } // ---------- Header ---------- const b = data.basics || {}; // ===== Two-column layout (Sidebar / Split) — matches the on-screen preview ===== function renderTwoCol() { const GAP = 22; const sideW = 168, mainW = CW - sideW - GAP; let sideX, mainX; if (PR.twocol === "left") { sideX = L; mainX = L + sideW + GAP; } else { mainX = L; sideX = L + mainW + GAP; } const exp = data.experience || [], edu = data.education || [], projects = data.projects || []; const skillItems = (data.skills || []).flatMap((g) => g.items || []); const certs = (data.certs || []).filter(Boolean); function ensurePage(p) { while (pdf.getNumberOfPages() < p) pdf.addPage(); } function flow(items, x, w, startPage, startY) { let page = startPage, yy = startY; for (const it of items) { const h = it(x, 0, w, true); if (yy + h > PH - MB) { page++; ensurePage(page); yy = MT; } pdf.setPage(page); it(x, yy, w, false); yy += h; } return { page, y: yy }; } // ---- header (full width) ---- function header() { let yy = MT; const photo = b.photo; const txtW = photo ? CW - 90 : CW; const nm = ((PR.name.upper ? (b.name || "").toUpperCase() : b.name) || "Your Name"); setF("bold", 25); setC(PR.name.color === "accent" ? AC : INK); charsp(PR.name.sp); pdf.splitTextToSize(nm, txtW).forEach((ln) => { pdf.text(ln, L, yy + 22); yy += 27; }); charsp(0); if (b.title) { setF("normal", 13); setC(AC); pdf.text(String(b.title), L, yy + 6, { maxWidth: txtW }); yy += 20; } const contact = [b.phone, b.email, b.linkedin, b.website, b.location].filter(Boolean); if (contact.length) { setF("normal", 9); setC(SUB); pdf.splitTextToSize(contact.join(" "), txtW).forEach((ln) => { pdf.text(ln, L, yy + 7); yy += 12.5; }); } if (photo) { try { const m = /^data:image\/(\w+)/.exec(photo); let f = m ? m[1].toUpperCase() : "PNG"; if (f === "JPG") f = "JPEG"; pdf.addImage(photo, f, R - 76, MT, 76, 76); } catch (e) {} } yy += 12; pdf.setDrawColor(60, 64, 78); pdf.setLineWidth(0.8); pdf.line(L, yy, R, yy); return yy + 16; } // ---- block builders: (x, yy, w, dry) => height ---- const headBlk = (label) => (x, yy, w, dry) => { const fs = 11; let yc = yy + 10; setF("bold", fs); setC(META); charsp(1); if (!dry) pdf.text((label || "").toUpperCase(), x, yc + fs * 0.82); charsp(0); yc += fs * 1.2; if (!dry) { pdf.setDrawColor(RULE[0], RULE[1], RULE[2]); pdf.setLineWidth(0.7); pdf.line(x, yc + 2, x + w, yc + 2); } return (yc + 12) - yy; }; const paraBlk = (text, o = {}) => (x, yy, w, dry) => { const size = o.size || 9.6; setF(o.style || "normal", size); setC(o.color || SUB); let yc = yy; pdf.splitTextToSize(String(text || ""), w).forEach((ln) => { if (!dry) pdf.text(ln, x, yc + size * 0.84); yc += size * 1.34; }); return (yc - yy) + (o.gap || 0); }; const bulletBlk = (text) => (x, yy, w, dry) => { const size = 9.3; setF("normal", size); setC(SUB); if (!dry) { pdf.setFillColor(META[0], META[1], META[2]); pdf.circle(x + 2.5, yy + 5, 1.3, "F"); } let yc = yy; pdf.splitTextToSize(String(text || ""), w - 12).forEach((ln) => { if (!dry) pdf.text(ln, x + 12, yc + size * 0.82); yc += size * 1.34; }); return (yc - yy) + 2; }; const entryBlk = (e) => (x, yy, w, dry) => { let yc = yy; setF("bold", 11); setC(INK); pdf.splitTextToSize(String(e.title || ""), w).forEach((ln) => { if (!dry) pdf.text(ln, x, yc + 11 * 0.86); yc += 11 * 1.28; }); if (e.org) { setF(serif ? "normal" : "bold", 9.8); setC(col(PR.comp.c)); pdf.splitTextToSize(String(e.org), w).forEach((ln) => { if (!dry) pdf.text(ln, x, yc + 9.8 * 0.86); yc += 9.8 * 1.25; }); } const meta = [e.dateR, e.locR].filter(Boolean).join(" · "); if (meta) { setF("normal", 8.8); setC(META); pdf.splitTextToSize(meta, w).forEach((ln) => { if (!dry) pdf.text(ln, x, yc + 8.8 * 0.86); yc += 8.8 * 1.25; }); } if (e.detail) { setF("normal", 9.4); setC(SUB); pdf.splitTextToSize(String(e.detail), w).forEach((ln) => { if (!dry) pdf.text(ln, x, yc + 9.4 * 0.84); yc += 9.4 * 1.3; }); } yc += 3; (e.bullets || []).filter(Boolean).forEach((bt) => { const size = 9.3; setF("normal", size); setC(SUB); if (!dry) { pdf.setFillColor(META[0], META[1], META[2]); pdf.circle(x + 2.5, yc + 5, 1.3, "F"); } pdf.splitTextToSize(String(bt), w - 12).forEach((ln) => { if (!dry) pdf.text(ln, x + 12, yc + size * 0.82); yc += size * 1.34; }); }); return (yc - yy) + 9; }; const skillBlk = (s) => (x, yy, w, dry) => { const size = 9.4; setF("normal", size); setC(INK); let yc = yy + 2; pdf.splitTextToSize(String(s), w).forEach((ln) => { if (!dry) pdf.text(ln, x, yc + size * 0.82); yc += size * 1.5; }); if (!dry) { pdf.setDrawColor(236, 238, 242); pdf.setLineWidth(0.5); pdf.line(x, yc - 3, x + w, yc - 3); } return yc - yy; }; const certBlk = (c) => (x, yy, w, dry) => { const size = 9.4; setF("normal", size); setC(AC); let yc = yy; pdf.splitTextToSize(String(c), w).forEach((ln) => { if (!dry) pdf.text(ln, x, yc + size * 0.82); yc += size * 1.32; }); return (yc - yy) + 7; }; const eduBlk = (e) => (x, yy, w, dry) => { let yc = yy; setF("bold", 10); setC(INK); pdf.splitTextToSize(String(e.degree || e.school || ""), w).forEach((ln) => { if (!dry) pdf.text(ln, x, yc + 10 * 0.86); yc += 10 * 1.25; }); if (e.degree && e.school) { setF("normal", 9.4); setC(AC); pdf.splitTextToSize(String(e.school), w).forEach((ln) => { if (!dry) pdf.text(ln, x, yc + 9.4 * 0.84); yc += 9.4 * 1.25; }); } const meta = [[e.start, e.end].filter(Boolean).join(" - "), e.location].filter(Boolean).join(" · "); if (meta) { setF("normal", 8.8); setC(META); if (!dry) pdf.text(meta, x, yc + 8.8 * 0.84); yc += 8.8 * 1.3; } return (yc - yy) + 8; }; // ---- assemble columns (matches preview section order) ---- const mainItems = []; if (data.summary) { mainItems.push(headBlk("Summary"), paraBlk(data.summary, { gap: 4 })); } if (exp.length) { mainItems.push(headBlk("Experience")); exp.forEach((x) => mainItems.push(entryBlk({ title: x.role, org: x.company, dateR: [x.start, x.end].filter(Boolean).join(" - "), locR: x.location, bullets: x.bullets }))); } if (projects.length) { mainItems.push(headBlk("Projects")); projects.forEach((p) => mainItems.push(entryBlk({ title: p.name, org: p.tech, detail: p.detail, bullets: p.bullets }))); } const sideItems = []; if (skillItems.length) { sideItems.push(headBlk("Skills")); skillItems.forEach((s) => sideItems.push(skillBlk(s))); } if (certs.length) { sideItems.push(headBlk("Training & Courses")); certs.forEach((c) => sideItems.push(certBlk(c))); } if (edu.length) { sideItems.push(headBlk("Education")); edu.forEach((e) => sideItems.push(eduBlk(e))); } (data.custom || []).forEach((cs) => { const blocks = htmlToBlocks(cs.body); if (!(cs.heading && cs.heading.trim()) && !blocks.length) return; sideItems.push(headBlk(cs.heading || "Section")); blocks.forEach((bk) => sideItems.push(bk.type === "bullet" ? bulletBlk(bk.text) : paraBlk(bk.text, { size: 9.4, gap: 3 }))); }); const hb = header(); flow(mainItems, mainX, mainW, 1, hb); flow(sideItems, sideX, sideW, 1, hb); } if (PR.twocol) { renderTwoCol(); } else { const N = PR.name, T = PR.title; const nameRaw = String(b.name || "Your Name"); const nameTxt = N.upper ? nameRaw.toUpperCase() : nameRaw; const nameStyle = N.bold ? "bold" : "normal"; setF(nameStyle, N.size); charsp(N.sp); if (centered) { setC(col(N.color || "ink")); pdf.text(nameTxt, MID, y + N.size * 0.86, { align: "center" }); } else if (N.split) { // two-tone: last word in accent (Cobalt) const parts = nameTxt.split(" "); const last = parts.length > 1 ? parts.pop() : ""; const first = parts.join(" "); setC(INK); pdf.text(first, L, y + N.size * 0.86); if (last) { const fw = pdf.getTextWidth(first + " "); setC(AC); pdf.text(last, L + fw, y + N.size * 0.86); } } else { setC(col(N.color || "ink")); pdf.text(nameTxt, L, y + N.size * 0.86); } charsp(0); y += N.size * 1.18 + 4; if (b.title) { const ts = T.size || 12.5; setF(T.bold ? "bold" : (T.italic ? "italic" : "normal"), ts); charsp(T.sp); setC(col(T.c === "accent" ? "accent" : T.c === "gray" ? "gray" : "sub")); const tt = T.upper ? String(b.title).toUpperCase() : String(b.title); pdf.text(tt, centered ? MID : L, y + ts * 0.82, { align: centered ? "center" : "left" }); charsp(0); y += ts * 1.4; } // ---------- Contact ---------- const contact = [b.phone, b.email, b.location, b.website, b.linkedin].filter(Boolean); const sep = PR.contact.sep || " • "; if (contact.length) { const joined = contact.join(sep); if (PR.contact.bar === "dark" || PR.contact.bar === "accent") { const fill = PR.contact.bar === "accent" ? AC : [24, 27, 38]; const barH = 23; y += 8; pdf.setFillColor(fill[0], fill[1], fill[2]); pdf.rect(0, y, PW, barH, "F"); // full-bleed bar setF("normal", 9.4); setC([255, 255, 255]); const one = pdf.splitTextToSize(joined, CW)[0] || joined; pdf.text(one, L, y + barH / 2 + 3.2); y += barH + 12; } else { setF("normal", 9.6); setC(SUB); const cl = pdf.splitTextToSize(joined, CW); for (const ln of cl) { pdf.text(ln, centered ? MID : L, y + 8, { align: centered ? "center" : "left" }); y += 13.5; } y += 4; rule([60, 64, 78], 4, 6); } } else { y += 2; } // ---------- Summary ---------- if (data.summary) { sectionHead(centered ? "Profile" : "Summary"); lines(data.summary, { size: 10, color: SUB, gapAfter: 2 }); } // ---------- Experience ---------- const drawEntry = ({ title, org, dateR, locR, detail, bullets }) => { // keep the title+org+first bullet together let need = 12 * 1.34 + 11 * 1.34 + 6; if (bullets && bullets[0]) need += measure("• " + bullets[0], 10, CW - 14); if (y + need > PH - MB) { pdf.addPage(); y = MT; } setF("bold", 11.5); setC(INK); const dW = dateR ? pdf.getTextWidth(dateR) + 8 : 0; pdf.text(String(title || ""), L, y + 10, { maxWidth: CW - dW }); if (dateR) { setF("normal", 9.6); setC(META); pdf.text(String(dateR), R, y + 10, { align: "right" }); } y += 16; if (org || locR) { const compStyle = PR.comp.italic ? "italic" : (serif ? "normal" : "bold"); setF(compStyle, 10); setC(col(PR.comp.c)); const lW = locR ? pdf.getTextWidth(locR) + 8 : 0; if (org) pdf.text(String(org), L, y + 8, { maxWidth: CW - lW }); if (locR) { setF("normal", 9.6); setC(META); pdf.text(String(locR), R, y + 8, { align: "right" }); } y += 14; } if (detail) lines(detail, { size: 10, color: SUB, gapAfter: 1 }); (bullets || []).filter(Boolean).forEach((bt) => { setF("normal", 10); setC(SUB); const wrap = pdf.splitTextToSize(String(bt), CW - 14); const step = 10 * 1.36; if (y + step > PH - MB) { pdf.addPage(); y = MT; } bulletDot(); setC(SUB); wrap.forEach((ln, i) => { if (y + step > PH - MB) { pdf.addPage(); y = MT; } pdf.text(ln, L + 14, y + 8.6); y += step; }); }); y += 8; }; if ((data.experience || []).length) { sectionHead("Experience"); data.experience.forEach((x) => drawEntry({ title: x.role, org: x.company, dateR: [x.start, x.end].filter(Boolean).join(" - "), locR: x.location, bullets: x.bullets, })); } if ((data.projects || []).length) { sectionHead("Projects"); data.projects.forEach((p) => drawEntry({ title: p.name, org: p.tech, detail: p.detail, bullets: p.bullets, })); } if ((data.education || []).length) { sectionHead("Education"); data.education.forEach((e) => drawEntry({ title: e.degree || e.school, org: e.degree ? e.school : "", dateR: [e.start, e.end].filter(Boolean).join(" - "), locR: e.location, detail: e.detail, })); } if ((data.skills || []).length) { sectionHead("Skills"); data.skills.forEach((g) => { const items = (g.items || []).join(", "); if (!items && !g.group) return; const step = 10 * 1.4; if (y + step > PH - MB) { pdf.addPage(); y = MT; } if (g.group) { setF("bold", 10); setC(INK); const gl = g.group + ": "; pdf.text(gl, L, y + 8.6); const gw = pdf.getTextWidth(gl); setF("normal", 10); setC(SUB); const wrap = pdf.splitTextToSize(items, CW - gw); wrap.forEach((ln, i) => { if (i > 0 && y + step > PH - MB) { pdf.addPage(); y = MT; } pdf.text(ln, i === 0 ? L + gw : L, y + 8.6); y += step; }); } else { lines(items, { size: 10, color: SUB }); } }); y += 2; } if ((data.certs || []).filter(Boolean).length) { sectionHead("Certifications"); data.certs.filter(Boolean).forEach((c) => { const step = 10 * 1.36; if (y + step > PH - MB) { pdf.addPage(); y = MT; } setF("normal", 10); setC(SUB); bulletDot(); const wrap = pdf.splitTextToSize(String(c), CW - 14); wrap.forEach((ln) => { if (y + step > PH - MB) { pdf.addPage(); y = MT; } pdf.text(ln, L + 14, y + 8.6); y += step; }); }); } (data.custom || []).forEach((cs) => { const blocks = htmlToBlocks(cs.body); if (!(cs.heading && cs.heading.trim()) && !blocks.length) return; sectionHead(cs.heading || "Section"); blocks.forEach((bk) => { if (bk.type === "bullet") { const step = 10 * 1.36; if (y + step > PH - MB) { pdf.addPage(); y = MT; } setF("normal", 10); setC(SUB); bulletDot(); const wrap = pdf.splitTextToSize(bk.text, CW - 14); wrap.forEach((ln) => { if (y + step > PH - MB) { pdf.addPage(); y = MT; } pdf.text(ln, L + 14, y + 8.6); y += step; }); } else { lines(bk.text, { size: 10, color: SUB, gapAfter: 3 }); } }); }); } // end single-column body // PDF metadata (helps ATS + nice file properties) try { pdf.setProperties({ title: (b.name ? b.name + " — Résumé" : "Résumé"), author: b.name || "", creator: "BDRecruit Resume Studio" }); } catch (e) {} if (payload.__returnDoc) return pdf; pdf.save((filename || b.name || "resume").replace(/[\\/:*?"<>|]+/g, "").trim() + ".pdf"); return true; } async function exportExactPDF(filename) { const el = document.getElementById("resume-page"); if (!el) { alert("Resume element not found"); return false; } if (!window.html2canvas) { alert("html2canvas not loaded yet, try again in a moment."); return false; } const jsPDFOk = await ensureJsPDF(); if (!jsPDFOk) { alert("jsPDF not loaded"); return false; } // Temporarily collapse min-height so the canvas matches actual content, // not the 1056px minimum that causes a blank half-page at the bottom. const prevMinH = el.style.minHeight; const prevH = el.style.height; el.style.minHeight = "0"; el.style.height = "auto"; let canvas; try { canvas = await window.html2canvas(el, { scale: 2, useCORS: true, allowTaint: true, // needed for accurate background-color rendering backgroundColor: "#ffffff", logging: false, imageTimeout: 0, }); } finally { el.style.minHeight = prevMinH; el.style.height = prevH; } const { jsPDF } = window.jspdf; const pdf = new jsPDF({ unit: "pt", format: "letter", orientation: "portrait" }); const PW = 612, PH = 792; // pixels-per-point ratio based on captured width const pxPerPt = canvas.width / PW; const pageHpx = Math.round(PH * pxPerPt); const totalH = canvas.height; const ctx2 = canvas.getContext("2d"); // Scan backward from targetY to find a clean all-white row so we never // slice through a text line. Search up to ~60pt worth of pixels. const findCleanSplit = (targetY) => { const searchPx = Math.round(60 * pxPerPt); for (let d = 0; d <= searchPx; d++) { const y = targetY - d; if (y <= 0) break; const { data } = ctx2.getImageData(0, y, canvas.width, 1); let clean = true; for (let i = 0; i < data.length; i += 4) { if (data[i] < 245 || data[i + 1] < 245 || data[i + 2] < 245) { clean = false; break; } } if (clean) return y; } return targetY; // fallback — split anyway }; let yPx = 0; while (yPx < totalH) { if (yPx > 0) pdf.addPage(); let splitAt = Math.min(yPx + pageHpx, totalH); if (splitAt < totalH) splitAt = findCleanSplit(splitAt); const sliceH = splitAt - yPx; const slice = document.createElement("canvas"); slice.width = canvas.width; slice.height = sliceH; slice.getContext("2d").drawImage(canvas, 0, yPx, canvas.width, sliceH, 0, 0, canvas.width, sliceH); const imgData = slice.toDataURL("image/jpeg", 0.97); pdf.addImage(imgData, "JPEG", 0, 0, PW, sliceH / pxPerPt); yPx = splitAt; } const safe = (filename || "resume").replace(/[\\/:*?"<>|]+/g, "").trim(); pdf.save(safe + ".pdf"); return true; } window.downloadResumePDF = downloadResumePDF; window.exportExactPDF = exportExactPDF; window.loadPdfLibs = loadPdfLibs;