/* global React, Icons, TEMPLATES, contactList, Photo, SectionBody, sectionLabel, activeSections */ // ============================================================ // ResumePreview — unified, section-driven template engine // A template is a layout descriptor (see data.jsx). The engine // renders the résumé's sections in the user's chosen order, routing // some to a side column when the template asks for it. All visual // identity lives in scoped CSS under .tpl- (see resume.css). // ============================================================ // identity block (name / title / contact / photo) function Identity({ d, tpl, layout }) { const nameParts = (d.name || "").split(" "); return (
{tpl.photo && }

{nameParts[0]} {nameParts.length > 1 && {nameParts.slice(1).join(" ")}}

{d.title &&
{d.title}
}
{contactList(d).map(({ k, Ic, val }) => ( {val} ))}
); } function renderSecs(keys, d, tpl) { const titles = tpl.titles || {}; return keys.map((k) => (
{titles[k] || sectionLabel(k)}
)); } function TemplateShell({ d, tpl }) { const all = activeSections(d); const sideSet = new Set(tpl.side || []); const sideKeys = all.filter((k) => sideSet.has(k)); const mainKeys = all.filter((k) => !sideSet.has(k)); if (tpl.arch === "sidebar") { return (
{renderSecs(mainKeys, d, tpl)}
); } // arch === "stack" const hasSide = sideKeys.length > 0; return ( <>
{hasSide ? (
{renderSecs(mainKeys, d, tpl)}
) : (
{renderSecs(mainKeys, d, tpl)}
)} ); } function ResumePaper({ data, templateId, accent, accent2, skillStyle }) { const base = (TEMPLATES.find((t) => t.id === templateId)) || TEMPLATES[0]; // a global tweak can override the template's skill variant ("template" = leave as-is) const tpl = (skillStyle && skillStyle !== "template") ? { ...base, skills: skillStyle } : base; const style = { "--pa": accent || tpl.accent, "--pa2": accent2 || tpl.accent2 }; return (
); } // Scales a 768px paper to fit its container width (gallery thumbnails). function FitPaper({ data, templateId, designWidth = 768 }) { const ref = React.useRef(null); const [scale, setScale] = React.useState(0.4); React.useEffect(() => { const el = ref.current; if (!el) return; const fit = () => setScale((el.clientWidth || designWidth * 0.4) / designWidth); fit(); const ro = new ResizeObserver(fit); ro.observe(el); return () => ro.disconnect(); }, [designWidth]); return (
); } // Splits the résumé into Letter-sized page sheets with uniform print margins. // Breaking rules: // • whole "blocks" (a job, a degree, an entry, a skill bar/ring, a section // heading) are never split across pages — breaks land between blocks; // • a section heading stays glued to its first item (no orphan heading); // • only a single block taller than a page falls back to a clean line break. // Sidebar rails bleed through the top/bottom margins; every page gets a footer. function PagedPaper({ data, templateId, accent, accent2, skillStyle, pageH = 994, gap = 26, mTop = 60, mBot = 62 }) { const measRef = React.useRef(null); const [pages, setPages] = React.useState([{ s: 0, e: pageH - mBot, top: 0 }]); const [bg, setBg] = React.useState("#fff"); const [ink, setInk] = React.useState("#6b6b72"); const [rail, setRail] = React.useState(null); const BLOCK_SEL = ".rjob, .redu, .rproj, .rnamed-i, .rlang, .rpub, .rref, .sbar, .sring, .srow, .rsec-t"; const FIRST_SEL = ".rjob, .redu, .rproj, .rnamed-i, .rlang, .rpub, .rref, .sbar, .sring, .srow, .rsum, .rtags, .rchips, .rskill-list, .rbars, .rrings, .rscale"; React.useLayoutEffect(() => { const host = measRef.current; if (!host) return; const compute = () => { const paper = host.querySelector(".paper"); if (!paper) return; const pr = paper.getBoundingClientRect(); const total = pr.height; const gs = getComputedStyle(paper); if (gs.backgroundColor && gs.backgroundColor !== "rgba(0, 0, 0, 0)") setBg(gs.backgroundColor); if (gs.color) setInk(gs.color); const rside = paper.querySelector(".rcols.arch-sidebar .rside"); if (rside) { const rg = getComputedStyle(rside); setRail({ w: rside.offsetWidth, h: rside.offsetHeight, color: rg.backgroundColor, image: rg.backgroundImage }); } else setRail(null); const relTop = (n) => n.getBoundingClientRect().top - pr.top; const relBox = (n) => { const r = n.getBoundingClientRect(); return { top: r.top - pr.top, bottom: r.bottom - pr.top }; }; // Protect EVERY block in BOTH columns: a page break must never fall inside // a block (a job, a degree, an entry, a skill row, a section heading, …). // When the two columns don't align this can leave extra whitespace near a // break — that's the intended trade-off: each block stays whole on one page. const allBlocks = [...paper.querySelectorAll(BLOCK_SEL)].filter((n) => n.getClientRects().length).map(relBox); const glue = []; paper.querySelectorAll(".rsec").forEach((sec) => { const h = sec.querySelector(".rsec-t"); const f = sec.querySelector(FIRST_SEL); if (h && f) glue.push({ top: relTop(h), bottom: relBox(f).bottom }); }); const straddlers = allBlocks.concat(glue); const blockValid = (y) => !straddlers.some((b) => b.top < y - 0.5 && b.bottom > y + 0.5); // candidate break points: every block top + every section top (both columns) const pcand = new Set([0, total]); paper.querySelectorAll(BLOCK_SEL).forEach((n) => { if (n.getClientRects().length) pcand.add(Math.round(relTop(n))); }); paper.querySelectorAll(".rsec").forEach((s) => pcand.add(Math.round(relTop(s)))); const pcands = [...pcand].sort((a, b) => a - b); // leaf-level fallback (used only when a single block exceeds a page) const leaves = []; paper.querySelectorAll("*").forEach((n) => { if (n.children.length === 0 && n.getClientRects().length) leaves.push(relBox(n)); }); const lcand = new Set([0, total]); leaves.forEach((l) => { lcand.add(Math.round(l.top)); lcand.add(Math.round(l.bottom)); }); const lcands = [...lcand].sort((a, b) => a - b); const leafValid = (y) => !leaves.some((l) => l.top < y - 0.5 && l.bottom > y + 0.5); let out = []; let start = 0, i = 0, guard = 0; while (start < total - 1 && guard++ < 80) { const top = i === 0 ? 0 : mTop; const avail = Math.max(120, pageH - top - mBot); const target = start + avail; if (target >= total - 1) { out.push({ s: start, e: total, top }); break; } let cut = 0; for (const c of pcands) { if (c > start + 40 && c <= target && blockValid(c)) cut = c; } // Normally we take the furthest valid WHOLE-block break so nothing splits. // But two-column card layouts (e.g. Prism) often have no break line that // clears both columns until far down the page, which would strand a // near-empty sheet. When the best block break fills less than ~40% of the // page, pack it tighter with a line-level break instead. if (!cut || (cut - start) < avail * 0.4) { let lcut = 0; for (const c of lcands) { if (c > start + 40 && c <= target && leafValid(c)) lcut = c; } if (lcut > cut) cut = lcut; } if (!cut) cut = target; out.push({ s: start, e: cut, top }); start = cut; i++; } if (!out.length) out.push({ s: 0, e: Math.min(total, pageH - mBot), top: 0 }); // Prefer a two-page résumé: if everything fits on one page yet there's a // reasonable amount of content, split at the block break nearest the middle // so it reads as two balanced pages (then the cover letter is page 3). // The break must land in a sensible middle band — never right under the // header — or we'd strand a near-empty first page (the old Prism bug). If no // clean mid-page break exists, leave it as one full page. if (out.length === 1 && total > (pageH - mTop - mBot) * 0.62) { const mid = total * 0.55, lo = total * 0.34, hi = total * 0.72; let best = 0; for (const c of pcands) { if (c > lo && c < hi && blockValid(c) && (best === 0 || Math.abs(c - mid) < Math.abs(best - mid))) best = c; } if (best > 0) out = [{ s: 0, e: best, top: 0 }, { s: best, e: total, top: mTop }]; } setPages(out); }; compute(); const ro = new ResizeObserver(compute); ro.observe(host); return () => ro.disconnect(); }, [data, templateId, accent, accent2, skillStyle, pageH, mTop, mBot]); const fpl = rail ? rail.w + 36 : 54; return (
{pages.map(({ s, e, top }, i) => (
{rail && (
)}
{data.name} Page {i + 1} of {pages.length}
))}
); } Object.assign(window, { ResumePaper, FitPaper, PagedPaper, TemplateShell });