diff --git a/README.md b/README.md index 410c170..b412342 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ StackResume turns minimal input into a polished, interview-ready software-develo - **Generates** a complete resume JSON with STAR-format bullets and quantified metrics. - **Reviews & enhances** in a loop — a Reviewer agent scores ATS compatibility, writing quality, impact, and completeness (0–100 each), then an Enhancer rewrites weak bullets and injects missing keywords. Loops until the score crosses `MIN_QUALITY_SCORE` (default 82) or hits `MAX_REVIEW_ITERATIONS` (default 3). - **Tailors** — paste a job description to unlock a cover letter and a library of outreach email templates alongside the resume. -- **Exports** to PDF, Word (`.docx`), or OpenDocument (`.odt`) across four visual templates. +- **Exports** to PDF, Word (`.docx`), or OpenDocument (`.odt`) across five visual templates. Everything is wrapped in a chat-style web UI with persistent sessions, search, favourites, an application tracker, and a live LLM activity log. @@ -249,7 +249,7 @@ Every resume has an **✏ Edit** tab for inline editing of every section — sum | **Word (`.docx`)** | Editable in Word / Google Docs / Pages. | | **OpenDocument (`.odt`)** | Editable in LibreOffice / OpenOffice. | -Four templates: **Classic ATS** (max ATS compatibility), **Modern Clean** (blue accents), **Executive** (dark slate), **Dark Theme** (deep navy). Font size (9 / 10 / 11pt) and page cap (auto / 1 / 2) configurable per export. The Export modal includes a **live side-by-side PDF preview** that updates as you change options. +Five templates: **Classic ATS** (max ATS compatibility), **Modern Clean** (blue accents), **Executive** (dark slate), **Dark Theme** (deep navy), and **LaTeX** (serif, academic — Computer Modern with Font Awesome icons). Font size (9 / 10 / 11pt) and page cap (auto / 1 / 2) configurable per export. The Export modal includes a **live side-by-side PDF preview** that updates as you change options. ### Application tracker diff --git a/backend/app/api/document_routes.py b/backend/app/api/document_routes.py index 7e69b4b..ab8757f 100644 --- a/backend/app/api/document_routes.py +++ b/backend/app/api/document_routes.py @@ -36,7 +36,7 @@ class CoverLetterPDFRequest(BaseModel): inline: bool = False # true → render inline (preview iframe), false → download -ALLOWED_TEMPLATES = {"classic_ats", "modern_clean", "executive_dark", "dark_theme"} +ALLOWED_TEMPLATES = {"classic_ats", "modern_clean", "executive_dark", "dark_theme", "latex_serif"} # Presets stay for the UI labels; numeric strings in [8.0, 13.0] also accepted # (see is_valid_font_size). The slider sends e.g. "10.3". ALLOWED_FONT_PRESETS = {"small", "normal", "large"} diff --git a/backend/app/documents/docx_generator.py b/backend/app/documents/docx_generator.py index 0e6c1e0..2b16e9d 100644 --- a/backend/app/documents/docx_generator.py +++ b/backend/app/documents/docx_generator.py @@ -14,6 +14,9 @@ from __future__ import annotations import io +import os +import uuid +import zipfile from datetime import date from typing import Literal @@ -25,7 +28,9 @@ from docx.shared import Cm, Pt, RGBColor -TemplateName = Literal["classic_ats", "modern_clean", "executive_dark", "dark_theme"] +TemplateName = Literal[ + "classic_ats", "modern_clean", "executive_dark", "dark_theme", "latex_serif" +] FontSize = Literal["small", "normal", "large"] @@ -47,6 +52,12 @@ "name": "e8eaf2", "title": "a4a8ff", "header": "e8eaf2", "rule": "7c83ff", "body": "d0d4e8", "muted": "9098b3", "accent": "a4a8ff", }, + # latex_serif is a PDF-only serif layout; for DOCX we fall back to a clean + # all-black light palette so the same selection still exports sensibly. + "latex_serif": { + "name": "000000", "title": "1a1a1a", "header": "000000", + "rule": "000000", "body": "111111", "muted": "333333", "accent": "000000", + }, } FONT_SIZES = {"small": 9.5, "normal": 10.5, "large": 11.5} @@ -387,6 +398,614 @@ def _set_doc_margins(doc: Document, *, side_cm: float, vert_cm: float): section.bottom_margin = Cm(vert_cm) +# ═══════════════════════════════════════════════════════════════════════════ +# latex_serif — DOCX twin of the PDF's LaTeX layout. +# +# Same Computer Modern typeface (embedded so it renders without the font being +# installed), same Font Awesome contact icons, small-caps name, Title-Case ruled +# headers, "Company - Title / dates" hfill rows, italic technologies line, and +# itemize-indented bullets. +# ═══════════════════════════════════════════════════════════════════════════ + +from docx.enum.text import WD_TAB_ALIGNMENT # noqa: E402 +from docx.shared import Inches # noqa: E402 + +_FONTS_DIR = os.path.join(os.path.dirname(__file__), "fonts") +_CMU = "CMU Serif" +# The bundled FA faces were renamed to clean, unique families with a "Regular" +# subfamily so Word/LibreOffice resolve them unambiguously (the stock names use +# a non-standard "Solid" subfamily that resolves to a missing Regular face). +_FA_SOLID = "SR FA Solid" +_FA_BRANDS = "SR FA Brands" + +# family -> [(filename, embed-kind)] for the package font embedding. +_EMBED_FONTS = [ + (_CMU, [ + ("CMUSerif-Roman.ttf", "embedRegular"), + ("CMUSerif-Bold.ttf", "embedBold"), + ("CMUSerif-Italic.ttf", "embedItalic"), + ("CMUSerif-BoldItalic.ttf", "embedBoldItalic"), + ]), + (_FA_SOLID, [("fa-solid-900.ttf", "embedRegular")]), + (_FA_BRANDS, [("fa-brands-400.ttf", "embedRegular")]), +] + +# contact-field -> (font family, FontAwesome glyph) +_FA_ICONS = { + "phone": (_FA_SOLID, ""), + "email": (_FA_SOLID, ""), + "location": (_FA_SOLID, ""), + "linkedin": (_FA_BRANDS, ""), + "github": (_FA_BRANDS, ""), + "web": (_FA_SOLID, ""), +} + +# Geometry mirroring the PDF (0.4in side margins on US-Letter, itemize indent). +_SERIF_MARGIN_IN = 0.4 +_SERIF_CONTENT_CM = (8.5 - 2 * _SERIF_MARGIN_IN) * 2.54 # 19.558cm text width +_SERIF_BULLET_POS_CM = 14 / 72 * 2.54 # bullet glyph @ 14pt +_SERIF_BULLET_TEXT_CM = 25 / 72 * 2.54 # text @ 25pt + + +def _obfuscate(data: bytes, guid_hex: str) -> bytes: + """OOXML font obfuscation: XOR the first 32 bytes with the reversed GUID.""" + key = bytes.fromhex(guid_hex)[::-1] + out = bytearray(data) + for i in range(32): + out[i] ^= key[i % 16] + return bytes(out) + + +def _embed_fonts_docx(docx_bytes: bytes) -> bytes: + """Embed the bundled CMU Serif + Font Awesome faces into a saved .docx so the + LaTeX look survives on machines without those fonts installed.""" + W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + zin = zipfile.ZipFile(io.BytesIO(docx_bytes)) + items: dict[str, bytes] = {n: zin.read(n) for n in zin.namelist()} + + # Obfuscate the font binaries and collect / relationship entries. + font_xml, rels_xml, fidx = "", "", 0 + for family, faces in _EMBED_FONTS: + font_xml += f'' + for fn, kind in faces: + raw = open(os.path.join(_FONTS_DIR, fn), "rb").read() + g = uuid.uuid4() + fidx += 1 + items[f"word/fonts/font{fidx}.odttf"] = _obfuscate(raw, g.hex) + rid = f"rIdFont{fidx}" + font_xml += (f'') + rels_xml += (f'') + font_xml += "" + + # word/fontTable.xml — merge into the existing table, or create one. + ft = items.get("word/fontTable.xml") + if ft and b"" in ft: + items["word/fontTable.xml"] = ft.replace( + b"", font_xml.encode() + b"") + else: + items["word/fontTable.xml"] = ( + '' + f'{font_xml}' + ).encode() + + # word/_rels/fontTable.xml.rels — merge or create. + fr = items.get("word/_rels/fontTable.xml.rels") + if fr and b"" in fr: + items["word/_rels/fontTable.xml.rels"] = fr.replace( + b"", rels_xml.encode() + b"") + else: + items["word/_rels/fontTable.xml.rels"] = ( + '' + '{rels_xml}' + ).encode() + + # [Content_Types].xml — declare the obfuscated-font extension (+ fontTable). + ct = items["[Content_Types].xml"].decode() + if "obfuscatedFont" not in ct: + ct = ct.replace("", + '') + if "/word/fontTable.xml" not in ct: + ct = ct.replace("", + '') + items["[Content_Types].xml"] = ct.encode() + + # word/_rels/document.xml.rels — reference the fontTable part. + dr = items.get("word/_rels/document.xml.rels", b"") + if b"fontTable" not in dr and b"" in dr: + items["word/_rels/document.xml.rels"] = dr.replace( + b"", + f''.encode()) + + # word/settings.xml — turn on embedded-font usage. + st = items.get("word/settings.xml", b"").decode() + if st and "embedTrueTypeFonts" not in st: + idx = st.find(">", st.find("" + '' + st[idx:]) + items["word/settings.xml"] = st.encode() + + out = io.BytesIO() + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zout: + for n, d in items.items(): + zout.writestr(n, d) + return out.getvalue() + + +def _srun(p, text: str, size: float, color: str, *, bold=False, italic=False, + font: str = _CMU, underline=False): + """Add a run in an explicit font (defaults to CMU Serif).""" + run = p.add_run(text) + run.font.size = Pt(size) + run.font.color.rgb = _rgb(color) + run.bold = bold + run.italic = italic + if underline: + run.font.underline = True + rPr = run._element.get_or_add_rPr() + rFonts = OxmlElement("w:rFonts") + for attr in ("w:ascii", "w:hAnsi", "w:cs"): + rFonts.set(qn(attr), font) + rPr.insert(0, rFonts) + return run + + +def _sicon(p, kind: str, color: str, size: float): + spec = _FA_ICONS.get(kind) + if not spec: + return + face, glyph = spec + _srun(p, glyph, size, color, font=face) + _srun(p, " ", size, color) # nbsp gap to the text + + +def _ssection(doc: Document, title: str, palette: dict, base: float): + p = doc.add_paragraph() + _set_paragraph_spacing(p, before=6, after=3, line=1.05) + _srun(p, title, base + 2.5, palette["header"], bold=True) + _bottom_border(p, palette["rule"], size=4) + + +def _shrow(doc: Document, fill_left, right_text: str, right_size: float, + right_color: str, *, right_bold=True, right_italic=False, after=0): + """LaTeX \\hfill row via a right-aligned tab stop: left text flows, the right + item (date/location) hugs the right margin on the same line.""" + p = doc.add_paragraph() + _set_paragraph_spacing(p, after=after, line=1.12) + p.paragraph_format.tab_stops.add_tab_stop(Cm(_SERIF_CONTENT_CM), WD_TAB_ALIGNMENT.RIGHT) + fill_left(p) + if right_text: + p.add_run("\t") + _srun(p, right_text, right_size, right_color, bold=right_bold, italic=right_italic) + return p + + +def _sbullet(doc: Document, palette: dict, base: float, fill_text=None, text: str | None = None): + """Itemize-style bullet: glyph indented under the entry, hanging text.""" + p = doc.add_paragraph() + pf = p.paragraph_format + pf.left_indent = Cm(_SERIF_BULLET_TEXT_CM) + pf.first_line_indent = Cm(-(_SERIF_BULLET_TEXT_CM - _SERIF_BULLET_POS_CM)) + _set_paragraph_spacing(p, after=1, line=1.18) + pf.tab_stops.add_tab_stop(Cm(_SERIF_BULLET_TEXT_CM), WD_TAB_ALIGNMENT.LEFT) + p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY + _srun(p, "•", base - 0.5, palette["body"]) + p.add_run("\t") + if fill_text is not None: + fill_text(p) + else: + _srun(p, text or "", base - 0.5, palette["body"]) + + +def _build_resume_doc_serif(resume: dict, base: float) -> Document: + """LaTeX-style DOCX (Computer Modern + Font Awesome) matching the PDF.""" + palette = PALETTES_HEX["latex_serif"] + body, muted, accent = palette["body"], palette["muted"], palette["accent"] + doc = Document() + for section in doc.sections: + section.page_width = Inches(8.5) + section.page_height = Inches(11) + section.left_margin = Inches(_SERIF_MARGIN_IN) + section.right_margin = Inches(_SERIF_MARGIN_IN) + section.top_margin = Inches(0.3) + section.bottom_margin = Inches(0.4) + + pi = resume.get("personal_info", {}) or {} + + # ── Header: small-caps name, optional title, icon contact line ────────── + name = _safe(pi.get("full_name")) or "Your Name" + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + _set_paragraph_spacing(p, after=1, line=1.0) + nr = _srun(p, name, base + 12, palette["name"], bold=True) + nr.font.small_caps = True + + role = _safe(pi.get("professional_title")) + if role: + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + _set_paragraph_spacing(p, after=2, line=1.2) + _srun(p, role, base + 0.5, muted, italic=True) + + # Only emit a contact line when there's at least one contact field, so a + # bare resume doesn't leave an empty centred paragraph (matches the ODT). + if any(_safe(pi.get(k)) for k in + ("phone", "email", "location", "linkedin", "github", "website", "portfolio")): + cp = doc.add_paragraph() + cp.alignment = WD_ALIGN_PARAGRAPH.CENTER + _set_paragraph_spacing(cp, after=2, line=1.3) + csize = base - 0.5 + first = True + + def _sep(): + nonlocal first + if not first: + _srun(cp, "    ", csize, body) + first = False + + phone = _safe(pi.get("phone")) + if phone: + _sep(); _sicon(cp, "phone", body, csize); _srun(cp, phone, csize, body) + email = _safe(pi.get("email")) + if email: + _sep(); _sicon(cp, "email", body, csize) + _add_hyperlink_serif(cp, _normalize_url(email), email, accent, csize) + location = _safe(pi.get("location")) + if location: + _sep(); _sicon(cp, "location", body, csize); _srun(cp, location, csize, body) + for key in ("linkedin", "github", "website", "portfolio"): + v = _safe(pi.get(key)) + if v: + display = v.replace("https://", "").replace("http://", "").rstrip("/") + _sep(); _sicon(cp, key if key in ("linkedin", "github") else "web", body, csize) + _add_hyperlink_serif(cp, _normalize_url(v), display, accent, csize) + + # ── Summary ───────────────────────────────────────────────────────────── + summary = _safe(resume.get("professional_summary", "")) + if summary: + _ssection(doc, "Summary", palette, base) + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY + _set_paragraph_spacing(p, after=1, line=1.2) + _srun(p, summary, base, body) + + # ── Core Competencies (3-col grid) ────────────────────────────────────── + competencies = [_safe(c) for c in (resume.get("core_competencies") or []) if _safe(c)] + if competencies: + _ssection(doc, "Core Competencies", palette, base) + cols = 3 + rows = (len(competencies) + cols - 1) // cols + table = doc.add_table(rows=rows, cols=cols) + table.autofit = True + for i, item in enumerate(competencies): + cell = table.rows[i % rows].cells[i // rows] + tc_pr = cell._tc.get_or_add_tcPr() + tc_mar = OxmlElement("w:tcMar") + for side in ("top", "left", "bottom", "right"): + m = OxmlElement(f"w:{side}") + m.set(qn("w:w"), "0"); m.set(qn("w:type"), "dxa"); tc_mar.append(m) + tc_pr.append(tc_mar) + cpp = cell.paragraphs[0] + _set_paragraph_spacing(cpp, after=1.5, line=1.2) + _srun(cpp, f"• {item}", base - 0.5, body) + _strip_table_borders(table) + + # ── Professional Experience ───────────────────────────────────────────── + experience = resume.get("experience") or [] + if experience: + _ssection(doc, "Professional Experience", palette, base) + for exp in experience: + title_str = _safe(exp.get("title")) + company_str = _safe(exp.get("company")) + loc_str = _safe(exp.get("location")) + start = _safe(exp.get("start_date")) + end = _safe(exp.get("end_date")) or ("Present" if exp.get("current") else "Present") + emp_type = _safe(exp.get("employment_type", "")) + head_left = " – ".join([x for x in (company_str, title_str) if x]) or title_str + if emp_type and emp_type.lower() not in ("full-time", "fulltime", ""): + head_left = f"{head_left} ({emp_type})" + _shrow(doc, lambda p, t=head_left: _srun(p, t, base, body, bold=True), + _serif_dates(start, end), base, body, right_bold=True) + + techs = [_safe(t) for t in (exp.get("technologies") or []) if _safe(t)] + team = _safe(exp.get("team_size")) + if techs or team or loc_str: + def _tech(p, _techs=techs, _team=team): + if _techs: + _srun(p, "Technologies", base - 0.5, body, bold=True, italic=True) + _srun(p, f": {', '.join(_techs)}", base - 0.5, body, italic=True) + if _team: + _srun(p, f" · Team: {_team}", base - 0.5, body, italic=True) + elif _team: + _srun(p, f"Team: {_team}", base - 0.5, body, italic=True) + _shrow(doc, _tech, loc_str, base - 0.5, body, right_bold=False, right_italic=True) + + bullets = list(exp.get("responsibilities") or []) + list(exp.get("achievements") or []) + seen: set[str] = set() + for b in bullets: + b_str = _safe(b) + if b_str and b_str.lower() not in seen: + seen.add(b_str.lower()) + _sbullet(doc, palette, base, text=b_str) + _serif_gap(doc) + + # ── Projects ──────────────────────────────────────────────────────────── + projects = resume.get("projects") or [] + if projects: + _ssection(doc, "Projects", palette, base) + for proj in projects: + p_name = _safe(proj.get("name")) + url_raw = _safe(proj.get("url") or proj.get("github") or "") + url = _normalize_url(url_raw) if url_raw else None + tech = ", ".join(_safe(t) for t in (proj.get("technologies") or []) if _safe(t)) + proj_role = _safe(proj.get("role")) + proj_type = _safe(proj.get("type")) + name_lc = p_name.lower() + descriptor = next((d for d in (proj_role, proj_type) if d and d.lower() not in name_lc), "") + dates_str = _serif_dates(_safe(proj.get("start_date")), _safe(proj.get("end_date"))) + + def _proj_head(p, _n=p_name, _u=url, _d=descriptor, _t=tech): + if _u: + _add_hyperlink_serif(p, _u, _n, body, base, bold=True, underline=False) + else: + _srun(p, _n, base, body, bold=True) + if _d: + _srun(p, f" ({_d})", base, body, bold=True) + if _t: + _srun(p, " | ", base, body) + _srun(p, _t, base - 0.5, body, italic=True) + _shrow(doc, _proj_head, dates_str, base, body, right_bold=True) + desc = _safe(proj.get("description")) + if desc: + _sbullet(doc, palette, base, text=desc) + for h in (proj.get("highlights") or []): + h_str = _safe(h) + if h_str: + _sbullet(doc, palette, base, text=h_str) + _serif_gap(doc) + + # ── Education ─────────────────────────────────────────────────────────── + education = resume.get("education") or [] + if education: + _ssection(doc, "Education", palette, base) + for edu in education: + deg = _safe(edu.get("degree")) + field = _safe(edu.get("field_of_study")) + inst = _safe(edu.get("institution")) + loc = _safe(edu.get("location")) + start = _safe(edu.get("start_date")) + end = _safe(edu.get("end_date")) or _safe(edu.get("graduation_year")) + deg_line = deg + if field and field.lower() not in deg.lower(): + deg_line = f"{deg} in {field}" if deg else field + _shrow(doc, lambda p, t=inst: _srun(p, t, base, body, bold=True), + _serif_dates(start, end), base, body, right_bold=True) + if deg_line or loc: + _shrow(doc, lambda p, t=deg_line: _srun(p, t, base - 0.5, body, italic=True), + loc, base - 0.5, body, right_bold=False, right_italic=True) + extras = [] + if _safe(edu.get("gpa")): + extras.append(f"GPA: {_safe(edu.get('gpa'))}") + if _safe(edu.get("honors")): + extras.append(_safe(edu.get("honors"))) + cw = ", ".join(_safe(c) for c in (edu.get("relevant_coursework") or []) if _safe(c)) + if cw: + extras.append(f"Coursework: {cw}") + if extras: + p = doc.add_paragraph() + _set_paragraph_spacing(p, after=0, line=1.2) + _srun(p, " · ".join(extras), base - 1.5, muted, italic=True) + _serif_gap(doc) + + # ── Technical Skills ──────────────────────────────────────────────────── + skills = resume.get("technical_skills") or {} + skill_cats = [ + ("Languages", skills.get("programming_languages")), + ("Frameworks & Libraries", skills.get("frameworks_and_libraries")), + ("Databases", skills.get("databases")), + ("Cloud & Infrastructure", skills.get("cloud_and_infrastructure")), + ("DevOps & Tooling", skills.get("devops_and_tools") or skills.get("tools_and_practices")), + ("Testing", skills.get("testing")), + ("Methodologies", skills.get("methodologies")), + ("Soft Skills", skills.get("soft_skills")), + ] + skill_rows = [(k, ", ".join(_safe(x) for x in v if _safe(x))) for k, v in skill_cats if v] + if skill_rows: + _ssection(doc, "Technical Skills", palette, base) + for label, vals in skill_rows: + if not vals: + continue + p = doc.add_paragraph() + _set_paragraph_spacing(p, after=1.5, line=1.2) + _srun(p, label, base - 0.5, body, bold=True) + _srun(p, f" : {vals}", base - 0.5, body) + + _serif_tail_sections(doc, resume, palette, base) + return doc + + +def _serif_dates(start: str, end: str) -> str: + if start and end: + return f"{start} – {end}" + return end or start or "" + + +def _serif_gap(doc: Document): + p = doc.add_paragraph() + _set_paragraph_spacing(p, before=0, after=0, line=1.0) + _srun(p, "", 3, "FFFFFF") + + +def _add_hyperlink_serif(paragraph, url: str, text: str, color_hex: str, + size: float, *, bold=False, underline=True): + """Clickable hyperlink run in CMU Serif (optionally underlined like the PDF).""" + part = paragraph.part + r_id = part.relate_to( + url, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + is_external=True) + hyperlink = OxmlElement("w:hyperlink") + hyperlink.set(qn("r:id"), r_id) + new_run = OxmlElement("w:r") + rPr = OxmlElement("w:rPr") + rFonts = OxmlElement("w:rFonts") + for a in ("w:ascii", "w:hAnsi", "w:cs"): + rFonts.set(qn(a), _CMU) + rPr.append(rFonts) + if bold: + rPr.append(OxmlElement("w:b")) + sz = OxmlElement("w:sz"); sz.set(qn("w:val"), str(int(size * 2))); rPr.append(sz) + color = OxmlElement("w:color"); color.set(qn("w:val"), color_hex); rPr.append(color) + if underline: + u = OxmlElement("w:u"); u.set(qn("w:val"), "single"); rPr.append(u) + new_run.append(rPr) + t = OxmlElement("w:t"); t.text = text; t.set(qn("xml:space"), "preserve") + new_run.append(t) + hyperlink.append(new_run) + paragraph._p.append(hyperlink) + + +def _serif_tail_sections(doc: Document, resume: dict, palette: dict, base: float): + """Open Source · Certifications · Publications · Patents · Awards · Volunteer + · Languages · Interests · References — serif-styled, mirroring the PDF.""" + body, muted, accent = palette["body"], palette["muted"], palette["accent"] + + oss = resume.get("open_source_contributions") or [] + if oss: + _ssection(doc, "Open Source", palette, base) + for o in oss: + proj = _safe(o.get("project")); role = _safe(o.get("role")) + url_raw = _safe(o.get("url", "")); url = _normalize_url(url_raw) if url_raw else None + p = doc.add_paragraph(); _set_paragraph_spacing(p, after=0, line=1.18) + if url: + _add_hyperlink_serif(p, url, proj, body, base, bold=True, underline=False) + else: + _srun(p, proj, base, body, bold=True) + if role: + _srun(p, f" – {role}", base, body, italic=True) + o_desc = _safe(o.get("description", "")) or _safe(o.get("contribution", "")) + if o_desc: + pd = doc.add_paragraph(); _set_paragraph_spacing(pd, after=0, line=1.2) + _srun(pd, o_desc, base - 1.5, muted, italic=True) + for c in (o.get("contributions") or []): + if _safe(c): + _sbullet(doc, palette, base, text=_safe(c)) + _serif_gap(doc) + + certs = resume.get("certifications") or [] + if certs: + _ssection(doc, "Certifications", palette, base) + for c in certs: + url_raw = _safe(c.get("url", "")); url = _normalize_url(url_raw) if url_raw else None + p = doc.add_paragraph(); _set_paragraph_spacing(p, after=1, line=1.2) + if url: + _add_hyperlink_serif(p, url, _safe(c.get("name")), body, base - 0.5, bold=True, underline=False) + else: + _srun(p, _safe(c.get("name")), base - 0.5, body, bold=True) + if _safe(c.get("issuer")): + _srun(p, f" – {_safe(c.get('issuer'))}", base - 0.5, body, italic=True) + dp = [] + if _safe(c.get("date")) or _safe(c.get("year")): + dp.append(f"issued {_safe(c.get('date')) or _safe(c.get('year'))}") + if _safe(c.get("expiry")): + dp.append(f"expires {_safe(c.get('expiry'))}") + if dp: + _srun(p, f" ({', '.join(dp)})", base - 0.5, muted) + _serif_gap(doc) + + pubs = resume.get("publications") or [] + if pubs: + _ssection(doc, "Publications", palette, base) + for pub in pubs: + url_raw = _safe(pub.get("url", "")); url = _normalize_url(url_raw) if url_raw else None + p = doc.add_paragraph(); _set_paragraph_spacing(p, after=1, line=1.2) + if url: + _add_hyperlink_serif(p, url, _safe(pub.get("title")), body, base - 0.5, bold=True, underline=False) + else: + _srun(p, _safe(pub.get("title")), base - 0.5, body, bold=True) + if _safe(pub.get("venue")): + _srun(p, f" – {_safe(pub.get('venue'))}", base - 0.5, body, italic=True) + if _safe(pub.get("date")): + _srun(p, f" ({_safe(pub.get('date'))})", base - 0.5, muted) + _serif_gap(doc) + + patents = resume.get("patents") or [] + if patents: + _ssection(doc, "Patents", palette, base) + for pt in patents: + p = doc.add_paragraph(); _set_paragraph_spacing(p, after=1, line=1.2) + _srun(p, _safe(pt.get("title")), base - 0.5, body, bold=True) + if _safe(pt.get("patent_number")): + _srun(p, f" – {_safe(pt.get('patent_number'))}", base - 0.5, body) + if _safe(pt.get("date")): + _srun(p, f" ({_safe(pt.get('date'))})", base - 0.5, muted) + _serif_gap(doc) + + awards = resume.get("awards_and_honors") or [] + if awards: + _ssection(doc, "Awards & Honors", palette, base) + for a in awards: + if isinstance(a, dict): + nm = _safe(a.get("name") or a.get("title")) + issuer = _safe(a.get("issuer")); yr = _safe(a.get("year") or a.get("date")) + v = nm + (f" – {issuer}" if issuer else "") + (f" ({yr})" if yr else "") + else: + v = _safe(a) + if v: + _sbullet(doc, palette, base, text=v) + + vols = resume.get("volunteer_experience") or [] + if vols: + _ssection(doc, "Volunteer Experience", palette, base) + for v in vols: + org = _safe(v.get("organization")); role = _safe(v.get("role")) + head_left = " – ".join([x for x in (org, role) if x]) + _shrow(doc, lambda p, t=head_left: _srun(p, t, base, body, bold=True), + _serif_dates(_safe(v.get("start_date")), _safe(v.get("end_date"))), + base, body, right_bold=True) + if _safe(v.get("description")): + pd = doc.add_paragraph(); _set_paragraph_spacing(pd, after=0, line=1.2) + _srun(pd, _safe(v.get("description")), base - 1.5, muted, italic=True) + _serif_gap(doc) + + langs = resume.get("languages") or [] + if langs: + _ssection(doc, "Languages", palette, base) + p = doc.add_paragraph(); _set_paragraph_spacing(p, after=1.5, line=1.2) + for i, l in enumerate(langs): + lang = _safe(l.get("language")); prof = _safe(l.get("proficiency")) + if not lang: + continue + if i > 0: + _srun(p, " · ", base - 0.5, body) + _srun(p, lang, base - 0.5, body, bold=True) + if prof: + _srun(p, f" ({prof})", base - 0.5, body) + + interests = [_safe(i) for i in (resume.get("interests") or []) if _safe(i)] + if interests: + _ssection(doc, "Interests", palette, base) + p = doc.add_paragraph(); _set_paragraph_spacing(p, after=1.5, line=1.2) + _srun(p, ", ".join(interests), base - 0.5, body) + + references = resume.get("references") + if references: + _ssection(doc, "References", palette, base) + if isinstance(references, str): + p = doc.add_paragraph(); _set_paragraph_spacing(p, after=1.5, line=1.2) + _srun(p, _safe(references), base - 0.5, body) + elif isinstance(references, list): + for ref in references: + if _safe(ref): + _sbullet(doc, palette, base, text=_safe(ref)) + + def _build_resume_doc(resume: dict, template: TemplateName, base: float) -> Document: palette = PALETTES_HEX.get(template, PALETTES_HEX["classic_ats"]) doc = Document() @@ -885,6 +1504,18 @@ def generate_docx_resume( from app.documents._normalize import normalize_resume, resolve_base_font_size resume = normalize_resume(resume) base = resolve_base_font_size(font_size) + + # latex_serif gets the dedicated Computer-Modern layout with embedded fonts, + # so the DOCX matches the PDF on any machine. + if template == "latex_serif": + doc = _build_resume_doc_serif(resume, base) + buf = io.BytesIO() + doc.save(buf) + try: + return _embed_fonts_docx(buf.getvalue()) + except Exception: + return buf.getvalue() # fall back to font-name-only if embedding fails + doc = _build_resume_doc(resume, template, base) palette = PALETTES_HEX.get(template, PALETTES_HEX["classic_ats"]) if "bg" in palette: diff --git a/backend/app/documents/fonts/CMUSerif-Bold.ttf b/backend/app/documents/fonts/CMUSerif-Bold.ttf new file mode 100644 index 0000000..4378100 Binary files /dev/null and b/backend/app/documents/fonts/CMUSerif-Bold.ttf differ diff --git a/backend/app/documents/fonts/CMUSerif-BoldItalic.ttf b/backend/app/documents/fonts/CMUSerif-BoldItalic.ttf new file mode 100644 index 0000000..e70a259 Binary files /dev/null and b/backend/app/documents/fonts/CMUSerif-BoldItalic.ttf differ diff --git a/backend/app/documents/fonts/CMUSerif-Italic.ttf b/backend/app/documents/fonts/CMUSerif-Italic.ttf new file mode 100644 index 0000000..f29594a Binary files /dev/null and b/backend/app/documents/fonts/CMUSerif-Italic.ttf differ diff --git a/backend/app/documents/fonts/CMUSerif-Roman.ttf b/backend/app/documents/fonts/CMUSerif-Roman.ttf new file mode 100644 index 0000000..8cdc6d7 Binary files /dev/null and b/backend/app/documents/fonts/CMUSerif-Roman.ttf differ diff --git a/backend/app/documents/fonts/LICENSE-CMU.txt b/backend/app/documents/fonts/LICENSE-CMU.txt new file mode 100644 index 0000000..fde7cb0 --- /dev/null +++ b/backend/app/documents/fonts/LICENSE-CMU.txt @@ -0,0 +1,108 @@ +Computer Modern Unicode (CMU Serif) — bundled with StackResume +============================================================== + +These TrueType files — + + CMUSerif-Roman.ttf + CMUSerif-Bold.ttf + CMUSerif-Italic.ttf + CMUSerif-BoldItalic.ttf + +— were generated by converting the OpenType (CFF) "cm-unicode" fonts to +TrueType outlines (ReportLab cannot embed CFF/PostScript outlines). The glyph +shapes are unchanged; only the outline format was converted (cubic → quadratic +Béziers via fontTools/cu2qu). + +They are used by the `latex_serif` ("LaTeX") résumé template so the generated +PDF is typographically identical to a pdfLaTeX-compiled document. + +Original work +------------- +Computer Modern Unicode fonts, by Andrey V. Panov, derived from the METAFONT +sources of Computer Modern by Donald E. Knuth. +Project: https://cm-unicode.sourceforge.io/ (CTAN: fonts/cm-unicode) + +License +------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to any +document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may include +source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical writer or +other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or in +the appropriate machine-readable metadata fields within text or binary +files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name +as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any Modified +Version, except to acknowledge the contribution(s) of the Copyright +Holder(s) and the Author(s) or with their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must be +distributed entirely under this license, and must not be distributed under +any other license. The requirement for fonts to remain under this license +does not apply to any document created using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are not +met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS +IN THE FONT SOFTWARE. diff --git a/backend/app/documents/fonts/LICENSE-FONTAWESOME.txt b/backend/app/documents/fonts/LICENSE-FONTAWESOME.txt new file mode 100644 index 0000000..0500966 --- /dev/null +++ b/backend/app/documents/fonts/LICENSE-FONTAWESOME.txt @@ -0,0 +1,27 @@ +Font Awesome Free — bundled with StackResume +============================================ + +Files: + fa-solid-900.ttf (Font Awesome 6 Free, Solid style) + fa-brands-400.ttf (Font Awesome 6 Free, Brands style) + +Used by the `latex_serif` ("LaTeX") résumé template to render contact icons +(phone, envelope, map-marker, globe, LinkedIn, GitHub) as real glyphs — the same +icons the LaTeX `fontawesome5` package emits — so the output matches a +pdfLaTeX-compiled résumé exactly. + +Source: https://fontawesome.com / https://github.com/FortAwesome/Font-Awesome +Version: 6.x (Free) + +License (Font Awesome Free): + - Icons (the glyph artwork): CC BY 4.0 + https://creativecommons.org/licenses/by/4.0/ + - Fonts (the .ttf/.woff files): SIL OFL 1.1 + https://scripts.sil.org/OFL + - Code (not bundled here): MIT + +Attribution: "Font Awesome Free by @fontawesome - https://fontawesome.com". + +The fonts are redistributed unmodified under the SIL Open Font License v1.1. +A full copy of the OFL 1.1 text is included in LICENSE-CMU.txt in this directory +and applies equally to these font files. diff --git a/backend/app/documents/fonts/fa-brands-400.ttf b/backend/app/documents/fonts/fa-brands-400.ttf new file mode 100644 index 0000000..12d2723 Binary files /dev/null and b/backend/app/documents/fonts/fa-brands-400.ttf differ diff --git a/backend/app/documents/fonts/fa-solid-900.ttf b/backend/app/documents/fonts/fa-solid-900.ttf new file mode 100644 index 0000000..239a408 Binary files /dev/null and b/backend/app/documents/fonts/fa-solid-900.ttf differ diff --git a/backend/app/documents/odt_generator.py b/backend/app/documents/odt_generator.py index 7b98a6c..a397c76 100644 --- a/backend/app/documents/odt_generator.py +++ b/backend/app/documents/odt_generator.py @@ -6,19 +6,25 @@ from __future__ import annotations import io +import os +import re +import zipfile from datetime import date from typing import Literal from odf.opendocument import OpenDocumentText from odf.style import ( Style, TextProperties, ParagraphProperties, TableColumnProperties, - TableProperties, TableCellProperties, TabStop, TabStops, + TableProperties, TableCellProperties, TabStop, TabStops, GraphicProperties, ) -from odf.text import P, Span, A, Tab +from odf.text import P, Span, A, Tab, S from odf.table import Table, TableColumn, TableRow, TableCell +from odf.draw import Frame, Image as DrawImage -TemplateName = Literal["classic_ats", "modern_clean", "executive_dark", "dark_theme"] +TemplateName = Literal[ + "classic_ats", "modern_clean", "executive_dark", "dark_theme", "latex_serif" +] FontSize = Literal["small", "normal", "large"] @@ -40,6 +46,12 @@ "name": "#e8eaf2", "title": "#a4a8ff", "header": "#e8eaf2", "rule": "#7c83ff", "body": "#d0d4e8", "muted": "#9098b3", "accent": "#a4a8ff", }, + # latex_serif is a PDF-only serif layout; for ODT we fall back to a clean + # all-black light palette so the same selection still exports sensibly. + "latex_serif": { + "name": "#000000", "title": "#1a1a1a", "header": "#000000", + "rule": "#000000", "body": "#111111", "muted": "#333333", "accent": "#000000", + }, } FONT_SIZES = {"small": 9.5, "normal": 10.5, "large": 11.5} @@ -100,45 +112,70 @@ def __init__(self, doc: OpenDocumentText, palette: dict, base: float): self.base = base self._counter = 0 self._cache: dict[str, str] = {} + self._icon_frame_style = None def _next(self) -> str: self._counter += 1 return f"AutoStyle{self._counter}" - def text(self, *, size: float, color: str, bold: bool = False, italic: bool = False) -> str: - key = f"t/{size}/{color}/{int(bold)}/{int(italic)}" + def text(self, *, size: float, color: str, bold: bool = False, italic: bool = False, + font: str = "Helvetica", small_caps: bool = False) -> str: + key = f"t/{size}/{color}/{int(bold)}/{int(italic)}/{font}/{int(small_caps)}" if key in self._cache: return self._cache[key] name = self._next() s = Style(name=name, family="text") - s.addElement(TextProperties( - fontname="Helvetica", + tp_kwargs = dict( + fontname=font, fontsize=f"{size}pt", color=color, fontweight="bold" if bold else "normal", fontstyle="italic" if italic else "normal", - )) + ) + if small_caps: + tp_kwargs["fontvariant"] = "small-caps" + s.addElement(TextProperties(**tp_kwargs)) self.doc.automaticstyles.addElement(s) self._cache[key] = name return name - def hyperlink(self, color: str, size: float) -> str: - key = f"hl/{color}/{size}" + def hyperlink(self, color: str, size: float, font: str = "Helvetica", + bold: bool = False, underline: bool = True) -> str: + key = f"hl/{color}/{size}/{font}/{int(bold)}/{int(underline)}" if key in self._cache: return self._cache[key] name = self._next() s = Style(name=name, family="text") - s.addElement(TextProperties( - fontname="Helvetica", + tp_kwargs = dict( + fontname=font, fontsize=f"{size}pt", color=color, - textunderlinetype="single", - textunderlinestyle="solid", - )) + fontweight="bold" if bold else "normal", + ) + if underline: + tp_kwargs["textunderlinetype"] = "single" + tp_kwargs["textunderlinestyle"] = "solid" + s.addElement(TextProperties(**tp_kwargs)) self.doc.automaticstyles.addElement(s) self._cache[key] = name return name + def icon_frame(self): + """Graphic Style object for an inline icon image (odfpy's Frame needs the + Style object, not its name): centred on the text line, no border, inline.""" + if self._icon_frame_style is None: + s = Style(name=self._next(), family="graphic") + # For an as-char frame only the vertical alignment matters; setting + # horizontal pos/wrap turns it into a floating frame and spawns blank + # pages, so keep it minimal. + s.addElement(GraphicProperties( + verticalrel="text", verticalpos="middle", + border="none", padding="0cm", + )) + self.doc.automaticstyles.addElement(s) + self._icon_frame_style = s + return self._icon_frame_style + def para( self, *, @@ -149,8 +186,11 @@ def para( bottom_border: str | None = None, left_indent: float = 0, first_line_indent: float = 0, + right_tab_cm: float | None = None, + border_weight: str = "0.5pt", ) -> str: - key = f"p/{align}/{space_before}/{space_after}/{line_height}/{bottom_border}/{left_indent}/{first_line_indent}" + key = (f"p/{align}/{space_before}/{space_after}/{line_height}/{bottom_border}/" + f"{left_indent}/{first_line_indent}/{right_tab_cm}/{border_weight}") if key in self._cache: return self._cache[key] name = self._next() @@ -163,15 +203,20 @@ def para( if line_height: attrs["lineheight"] = f"{int(line_height * 100)}%" if bottom_border: - attrs["borderbottom"] = f"0.5pt solid {bottom_border}" + attrs["borderbottom"] = f"{border_weight} solid {bottom_border}" attrs["paddingbottom"] = "1pt" if left_indent: attrs["marginleft"] = f"{left_indent}pt" if first_line_indent: attrs["textindent"] = f"{first_line_indent}pt" pp = ParagraphProperties(**attrs) - if left_indent and first_line_indent and first_line_indent < 0: - # Add a tab stop at the indent position so "•\t" aligns continuation lines exactly + if right_tab_cm is not None: + # Right-aligned tab at the text-area edge → LaTeX \hfill behaviour. + ts = TabStops() + ts.addElement(TabStop(position=f"{right_tab_cm}cm", type="right")) + pp.addElement(ts) + elif left_indent and first_line_indent and first_line_indent < 0: + # Tab stop at the indent so "•\t" aligns continuation lines exactly. ts = TabStops() ts.addElement(TabStop(position=f"{left_indent}pt", type="left")) pp.addElement(ts) @@ -346,6 +391,612 @@ def _make_doc(*, side_cm: float, vert_cm: float, bg_color: str | None = None) -> return doc +# ═══════════════════════════════════════════════════════════════════════════ +# latex_serif — ODT twin of the PDF's LaTeX layout (Computer Modern + Font +# Awesome icons embedded, small-caps name, Title-Case ruled headers, hfill rows, +# italic technologies line, itemize-indented bullets). +# ═══════════════════════════════════════════════════════════════════════════ + +_FONTS_DIR = os.path.join(os.path.dirname(__file__), "fonts") +_CMU = "CMU Serif" +_FA_SOLID = "SR FA Solid" +_FA_BRANDS = "SR FA Brands" +_FA_ICONS = { + "phone": (_FA_SOLID, ""), # faPhone + "email": (_FA_SOLID, ""), # faEnvelope + "location": (_FA_SOLID, ""), # faMapMarker + "linkedin": (_FA_BRANDS, ""), # faLinkedin + "github": (_FA_BRANDS, ""), # faGithub + "web": (_FA_SOLID, ""), # faGlobe +} +_SERIF_CONTENT_CM = (8.5 - 2 * 0.4) * 2.54 # 19.558cm (Letter, 0.4in margins) +_SERIF_BULLET_POS_PT = 14 +_SERIF_BULLET_TEXT_PT = 25 + +# Embedded font-face declarations (LibreOffice "loext" weight/style mapping). +# Only the Computer Modern text faces are embedded here — LibreOffice does NOT +# render embedded *icon* fonts in ODT, so contact icons are inlined as PNGs +# (rasterised from the same Font Awesome glyphs) instead. +_LOEXT_NS = "urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" +_EMBED_ODT_FONTS = [ + ("CMUSerif-Roman.ttf", _CMU, "normal", "normal", "roman"), + ("CMUSerif-Bold.ttf", _CMU, "normal", "bold", "roman"), + ("CMUSerif-Italic.ttf", _CMU, "italic", "normal", "roman"), + ("CMUSerif-BoldItalic.ttf", _CMU, "italic", "bold", "roman"), +] +_FA_FILES = {_FA_SOLID: "fa-solid-900.ttf", _FA_BRANDS: "fa-brands-400.ttf"} +_ICON_PNG_CACHE: dict[str, tuple[bytes, float]] = {} + + +def _fa_icon_png(kind: str, color_hex: str, px: int = 72) -> tuple[bytes, float] | None: + """Rasterise a Font Awesome glyph to a transparent PNG (cached). Returns + (png_bytes, width/height aspect) or None if Pillow/the font is unavailable.""" + key = f"{kind}/{color_hex}" + if key in _ICON_PNG_CACHE: + return _ICON_PNG_CACHE[key] + spec = _FA_ICONS.get(kind) + if not spec: + return None + try: + from PIL import Image, ImageDraw, ImageFont + except Exception: + return None + face, glyph = spec + h = color_hex.lstrip("#") + rgb = (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255) + font = ImageFont.truetype(os.path.join(_FONTS_DIR, _FA_FILES[face]), px) + probe = ImageDraw.Draw(Image.new("RGBA", (px * 2, px * 2))) + bbox = probe.textbbox((0, 0), glyph, font=font) + w, ht = bbox[2] - bbox[0], bbox[3] - bbox[1] + img = Image.new("RGBA", (w + 4, ht + 4), (0, 0, 0, 0)) + ImageDraw.Draw(img).text((-bbox[0] + 2, -bbox[1] + 2), glyph, font=font, fill=rgb) + buf = io.BytesIO() + img.save(buf, "PNG") + out = (buf.getvalue(), img.width / img.height) + _ICON_PNG_CACHE[key] = out + return out + + +def _serif_dates(start: str, end: str) -> str: + if start and end: + return f"{start} – {end}" + return end or start or "" + + +def _srun(p: P, styles: "_Styles", text: str, size: float, color: str, + *, bold=False, italic=False, font=_CMU, small_caps=False): + span = Span(stylename=styles.text(size=size, color=color, bold=bold, + italic=italic, font=font, small_caps=small_caps)) + span.addText(text) + p.addElement(span) + + +def _slink(p: P, styles: "_Styles", text: str, url: str, color: str, size: float, + *, bold=False, underline=True): + a = A(href=url, type="simple") + span = Span(stylename=styles.hyperlink(color, size, font=_CMU, bold=bold, underline=underline)) + span.addText(text) + a.addElement(span) + p.addElement(a) + + +def _sicon(p: P, styles: "_Styles", kind: str, color: str, size: float): + """Inline a Font Awesome icon as a PNG image (LibreOffice won't render an + embedded icon font in ODT, but inline images always render).""" + out = _fa_icon_png(kind, color) + if not out: + return + png, aspect = out + href = styles.doc.addPictureFromString(png, "image/png") + h_in = (size * 0.95) / 72.0 + w_in = h_in * aspect + frame = Frame( + stylename=styles.icon_frame(), + width=f"{w_in:.3f}in", height=f"{h_in:.3f}in", + anchortype="as-char", + ) + frame.addElement(DrawImage(href=href, type="simple", show="embed", actuate="onLoad")) + p.addElement(frame) + p.addElement(S(c=1)) # real space (ODF collapses literal spaces) + + +def _ssection(doc: OpenDocumentText, styles: "_Styles", title: str): + p = P(stylename=styles.para(align="left", space_before=6, space_after=3, + line_height=1.05, bottom_border=styles.palette["rule"], + border_weight="0.6pt")) + _srun(p, styles, title, styles.base + 2.5, styles.palette["header"], bold=True) + doc.text.addElement(p) + + +def _serif_gap(doc: OpenDocumentText, styles: "_Styles"): + p = P(stylename=styles.para(space_after=0, line_height=1.0)) + _srun(p, styles, "", 3, "#ffffff") + doc.text.addElement(p) + + +def _shrow(doc: OpenDocumentText, styles: "_Styles", fill_left, right_text: str, + right_size: float, right_color: str, *, right_bold=True, + right_italic=False, space_after=0): + p = P(stylename=styles.para(align="left", space_after=space_after, + line_height=1.12, right_tab_cm=_SERIF_CONTENT_CM)) + fill_left(p) + if right_text: + p.addElement(Tab()) + _srun(p, styles, right_text, right_size, right_color, + bold=right_bold, italic=right_italic) + doc.text.addElement(p) + + +def _sbullet(doc: OpenDocumentText, styles: "_Styles", text: str): + p = P(stylename=styles.para( + align="justify", space_after=1, line_height=1.18, + left_indent=_SERIF_BULLET_TEXT_PT, + first_line_indent=-(_SERIF_BULLET_TEXT_PT - _SERIF_BULLET_POS_PT))) + _srun(p, styles, "•", styles.base - 0.5, styles.palette["body"]) + p.addElement(Tab()) + _srun(p, styles, text, styles.base - 0.5, styles.palette["body"]) + doc.text.addElement(p) + + +def _embed_fonts_odt(odt_bytes: bytes) -> bytes: + """Embed CMU Serif + Font Awesome into the .odt (ODF font embedding) so the + LaTeX look renders without the fonts installed.""" + zin = zipfile.ZipFile(io.BytesIO(odt_bytes)) + items: dict[str, bytes] = {n: zin.read(n) for n in zin.namelist()} + + # 1) Font binaries (ODF stores them unobfuscated). + for fn, *_ in _EMBED_ODT_FONTS: + items[f"Fonts/{fn}"] = open(os.path.join(_FONTS_DIR, fn), "rb").read() + + # 2) manifest entries. + man = items["META-INF/manifest.xml"].decode() + seen = set() + entries = "" + for fn, *_ in _EMBED_ODT_FONTS: + if fn in seen: + continue + seen.add(fn) + entries += (f'') + man = man.replace("", entries + "") + items["META-INF/manifest.xml"] = man.encode() + + # 3) font-face-decls with embedded src, grouped per family. + by_family: dict[str, list] = {} + generic: dict[str, str] = {} + for fn, fam, style, weight, gen in _EMBED_ODT_FONTS: + by_family.setdefault(fam, []).append((fn, style, weight)) + generic[fam] = gen + faces = "" + for fam, srcs in by_family.items(): + gen_attr = f' style:font-family-generic="{generic[fam]}"' if generic[fam] != "system" else "" + faces += (f'') + for fn, style, weight in srcs: + faces += (f'' + f'') + faces += "" + decls = f"{faces}" + + # Inject the embedded font-face-decls into BOTH content.xml and styles.xml — + # LibreOffice reads embedded fonts from the styles part. + for part, root, anchor in ( + ("content.xml", "office:document-content", "" in x: + # lambda replacement avoids re.sub interpreting backslashes/\g in decls + x = re.sub(r".*?", + lambda _m: decls, x, count=1, flags=re.S) + elif "" in x: + x = x.replace("", decls, 1) + elif anchor in x: + x = x.replace(anchor, decls + anchor, 1) + items[part] = x.encode() + + # 4) settings.xml with EmbedFonts=true — REQUIRED for LibreOffice to actually + # use the embedded fonts on open (odfpy writes no settings.xml). + items["settings.xml"] = ( + '' + '' + '' + 'true' + '' + '' + ).encode() + man = items["META-INF/manifest.xml"].decode() + if "settings.xml" not in man: + man = man.replace("", + '') + items["META-INF/manifest.xml"] = man.encode() + + # 5) Re-zip — mimetype MUST be first and stored uncompressed. + out = io.BytesIO() + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zout: + if "mimetype" in items: + zi = zipfile.ZipInfo("mimetype") + zi.compress_type = zipfile.ZIP_STORED + zout.writestr(zi, items.pop("mimetype")) + for n, d in items.items(): + zout.writestr(n, d) + return out.getvalue() + + +def _build_resume_doc_serif(resume: dict, base: float) -> OpenDocumentText: + """LaTeX-style ODT (Computer Modern + Font Awesome) matching the PDF.""" + palette = PALETTES_HEX["latex_serif"] + body, muted, accent = palette["body"], palette["muted"], palette["accent"] + doc = _make_doc(side_cm=0.4 * 2.54, vert_cm=0.36 * 2.54) + styles = _Styles(doc, palette, base) + pi = resume.get("personal_info", {}) or {} + + # ── Header: small-caps name, optional title, icon contact line ────────── + name = _safe(pi.get("full_name")) or "Your Name" + p = P(stylename=styles.para(align="center", space_after=1, line_height=1.0)) + _srun(p, styles, name, base + 12, palette["name"], bold=True, small_caps=True) + doc.text.addElement(p) + + role = _safe(pi.get("professional_title")) + if role: + p = P(stylename=styles.para(align="center", space_after=2, line_height=1.2)) + _srun(p, styles, role, base + 0.5, muted, italic=True) + doc.text.addElement(p) + + contact = [(k, _safe(pi.get(k))) for k in + ("phone", "email", "location", "linkedin", "github", "website", "portfolio")] + contact = [(k, v) for k, v in contact if v] + if contact: + cp = P(stylename=styles.para(align="center", space_after=2, line_height=1.3)) + csize = base - 0.5 + first = True + for key, v in contact: + if not first: + cp.addElement(S(c=4)) # gap between contact items + first = False + kind = {"website": "web", "portfolio": "web"}.get(key, key) + _sicon(cp, styles, kind, body, csize) + if key in ("phone", "location"): + _srun(cp, styles, v, csize, body) + elif key == "email": + _slink(cp, styles, v, _normalize_url(v), accent, csize) + else: + disp = v.replace("https://", "").replace("http://", "").rstrip("/") + _slink(cp, styles, disp, _normalize_url(v), accent, csize) + doc.text.addElement(cp) + + # ── Summary ───────────────────────────────────────────────────────────── + summary = _safe(resume.get("professional_summary", "")) + if summary: + _ssection(doc, styles, "Summary") + p = P(stylename=styles.para(align="justify", space_after=1, line_height=1.2)) + _srun(p, styles, summary, base, body) + doc.text.addElement(p) + + # ── Core Competencies ─────────────────────────────────────────────────── + competencies = [_safe(c) for c in (resume.get("core_competencies") or []) if _safe(c)] + if competencies: + _ssection(doc, styles, "Core Competencies") + cols = 3 + rows = (len(competencies) + cols - 1) // cols + tname = f"scomp_{styles._next()}" + col = Style(name=f"{tname}_c", family="table-column") + col.addElement(TableColumnProperties(columnwidth="6.5cm")) + cell = Style(name=f"{tname}_cell", family="table-cell") + cell.addElement(TableCellProperties(padding="0pt", bordertop="none", + borderbottom="none", borderleft="none", borderright="none")) + ts = Style(name=tname, family="table") + ts.addElement(TableProperties(width=f"{_SERIF_CONTENT_CM}cm", align="left")) + for s in (col, cell, ts): + doc.automaticstyles.addElement(s) + tbl = Table(stylename=tname) + for _ in range(cols): + tbl.addElement(TableColumn(stylename=col)) + for r in range(rows): + tr = TableRow() + for c in range(cols): + idx = c * rows + r + tc = TableCell(stylename=cell) + if idx < len(competencies): + pp = P(stylename=styles.para(space_after=1.5, line_height=1.2)) + _srun(pp, styles, f"• {competencies[idx]}", base - 0.5, body) + tc.addElement(pp) + else: + tc.addElement(P()) + tr.addElement(tc) + tbl.addElement(tr) + doc.text.addElement(tbl) + + # ── Professional Experience ───────────────────────────────────────────── + experience = resume.get("experience") or [] + if experience: + _ssection(doc, styles, "Professional Experience") + for exp in experience: + title_str = _safe(exp.get("title")) + company_str = _safe(exp.get("company")) + loc_str = _safe(exp.get("location")) + start = _safe(exp.get("start_date")) + end = _safe(exp.get("end_date")) or ("Present" if exp.get("current") else "Present") + emp_type = _safe(exp.get("employment_type", "")) + head_left = " – ".join([x for x in (company_str, title_str) if x]) or title_str + if emp_type and emp_type.lower() not in ("full-time", "fulltime", ""): + head_left = f"{head_left} ({emp_type})" + _shrow(doc, styles, + lambda p, t=head_left: _srun(p, styles, t, base, body, bold=True), + _serif_dates(start, end), base, body, right_bold=True) + + techs = [_safe(t) for t in (exp.get("technologies") or []) if _safe(t)] + team = _safe(exp.get("team_size")) + if techs or team or loc_str: + def _tech(p, _techs=techs, _team=team): + if _techs: + _srun(p, styles, "Technologies", base - 0.5, body, bold=True, italic=True) + _srun(p, styles, f": {', '.join(_techs)}", base - 0.5, body, italic=True) + if _team: + _srun(p, styles, f" · Team: {_team}", base - 0.5, body, italic=True) + elif _team: + _srun(p, styles, f"Team: {_team}", base - 0.5, body, italic=True) + _shrow(doc, styles, _tech, loc_str, base - 0.5, body, + right_bold=False, right_italic=True) + + bullets = list(exp.get("responsibilities") or []) + list(exp.get("achievements") or []) + seen: set[str] = set() + for b in bullets: + bs = _safe(b) + if bs and bs.lower() not in seen: + seen.add(bs.lower()) + _sbullet(doc, styles, bs) + _serif_gap(doc, styles) + + # ── Projects ──────────────────────────────────────────────────────────── + projects = resume.get("projects") or [] + if projects: + _ssection(doc, styles, "Projects") + for proj in projects: + p_name = _safe(proj.get("name")) + url_raw = _safe(proj.get("url") or proj.get("github") or "") + url = _normalize_url(url_raw) if url_raw else None + tech = ", ".join(_safe(t) for t in (proj.get("technologies") or []) if _safe(t)) + descriptor = next((d for d in (_safe(proj.get("role")), _safe(proj.get("type"))) + if d and d.lower() not in p_name.lower()), "") + dates_str = _serif_dates(_safe(proj.get("start_date")), _safe(proj.get("end_date"))) + + def _phead(p, _n=p_name, _u=url, _d=descriptor, _t=tech): + if _u: + _slink(p, styles, _n, _u, body, base, bold=True, underline=False) + else: + _srun(p, styles, _n, base, body, bold=True) + if _d: + _srun(p, styles, f" ({_d})", base, body, bold=True) + if _t: + _srun(p, styles, " | ", base, body) + _srun(p, styles, _t, base - 0.5, body, italic=True) + _shrow(doc, styles, _phead, dates_str, base, body, right_bold=True) + desc = _safe(proj.get("description")) + if desc: + _sbullet(doc, styles, desc) + for h in (proj.get("highlights") or []): + if _safe(h): + _sbullet(doc, styles, _safe(h)) + _serif_gap(doc, styles) + + # ── Education ─────────────────────────────────────────────────────────── + education = resume.get("education") or [] + if education: + _ssection(doc, styles, "Education") + for edu in education: + deg = _safe(edu.get("degree")) + field = _safe(edu.get("field_of_study")) + inst = _safe(edu.get("institution")) + loc = _safe(edu.get("location")) + start = _safe(edu.get("start_date")) + end = _safe(edu.get("end_date")) or _safe(edu.get("graduation_year")) + deg_line = deg + if field and field.lower() not in deg.lower(): + deg_line = f"{deg} in {field}" if deg else field + _shrow(doc, styles, + lambda p, t=inst: _srun(p, styles, t, base, body, bold=True), + _serif_dates(start, end), base, body, right_bold=True) + if deg_line or loc: + _shrow(doc, styles, + lambda p, t=deg_line: _srun(p, styles, t, base - 0.5, body, italic=True), + loc, base - 0.5, body, right_bold=False, right_italic=True) + extras = [] + if _safe(edu.get("gpa")): + extras.append(f"GPA: {_safe(edu.get('gpa'))}") + if _safe(edu.get("honors")): + extras.append(_safe(edu.get("honors"))) + cw = ", ".join(_safe(c) for c in (edu.get("relevant_coursework") or []) if _safe(c)) + if cw: + extras.append(f"Coursework: {cw}") + if extras: + p = P(stylename=styles.para(space_after=0, line_height=1.2)) + _srun(p, styles, " · ".join(extras), base - 1.5, muted, italic=True) + doc.text.addElement(p) + _serif_gap(doc, styles) + + # ── Technical Skills ──────────────────────────────────────────────────── + skills = resume.get("technical_skills") or {} + skill_cats = [ + ("Languages", skills.get("programming_languages")), + ("Frameworks & Libraries", skills.get("frameworks_and_libraries")), + ("Databases", skills.get("databases")), + ("Cloud & Infrastructure", skills.get("cloud_and_infrastructure")), + ("DevOps & Tooling", skills.get("devops_and_tools") or skills.get("tools_and_practices")), + ("Testing", skills.get("testing")), + ("Methodologies", skills.get("methodologies")), + ("Soft Skills", skills.get("soft_skills")), + ] + skill_rows = [(k, ", ".join(_safe(x) for x in v if _safe(x))) for k, v in skill_cats if v] + if skill_rows: + _ssection(doc, styles, "Technical Skills") + for label, vals in skill_rows: + if not vals: + continue + p = P(stylename=styles.para(space_after=1.5, line_height=1.2)) + _srun(p, styles, label, base - 0.5, body, bold=True) + _srun(p, styles, f" : {vals}", base - 0.5, body) + doc.text.addElement(p) + + _serif_tail_sections(doc, styles, resume) + return doc + + +def _serif_tail_sections(doc: OpenDocumentText, styles: "_Styles", resume: dict): + """Open Source · Certifications · Publications · Patents · Awards · Volunteer + · Languages · Interests · References — serif-styled to match the PDF.""" + base = styles.base + body, muted = styles.palette["body"], styles.palette["muted"] + + oss = resume.get("open_source_contributions") or [] + if oss: + _ssection(doc, styles, "Open Source") + for o in oss: + proj = _safe(o.get("project")); role = _safe(o.get("role")) + url_raw = _safe(o.get("url", "")); url = _normalize_url(url_raw) if url_raw else None + p = P(stylename=styles.para(space_after=0, line_height=1.18)) + if url: + _slink(p, styles, proj, url, body, base, bold=True, underline=False) + else: + _srun(p, styles, proj, base, body, bold=True) + if role: + _srun(p, styles, f" – {role}", base, body, italic=True) + doc.text.addElement(p) + o_desc = _safe(o.get("description", "")) or _safe(o.get("contribution", "")) + if o_desc: + pd = P(stylename=styles.para(space_after=0, line_height=1.2)) + _srun(pd, styles, o_desc, base - 1.5, muted, italic=True) + doc.text.addElement(pd) + for c in (o.get("contributions") or []): + if _safe(c): + _sbullet(doc, styles, _safe(c)) + _serif_gap(doc, styles) + + certs = resume.get("certifications") or [] + if certs: + _ssection(doc, styles, "Certifications") + for c in certs: + url_raw = _safe(c.get("url", "")); url = _normalize_url(url_raw) if url_raw else None + p = P(stylename=styles.para(space_after=1, line_height=1.2)) + if url: + _slink(p, styles, _safe(c.get("name")), url, body, base - 0.5, bold=True, underline=False) + else: + _srun(p, styles, _safe(c.get("name")), base - 0.5, body, bold=True) + if _safe(c.get("issuer")): + _srun(p, styles, f" – {_safe(c.get('issuer'))}", base - 0.5, body, italic=True) + dp = [] + if _safe(c.get("date")) or _safe(c.get("year")): + dp.append(f"issued {_safe(c.get('date')) or _safe(c.get('year'))}") + if _safe(c.get("expiry")): + dp.append(f"expires {_safe(c.get('expiry'))}") + if dp: + _srun(p, styles, f" ({', '.join(dp)})", base - 0.5, muted) + doc.text.addElement(p) + _serif_gap(doc, styles) + + pubs = resume.get("publications") or [] + if pubs: + _ssection(doc, styles, "Publications") + for pub in pubs: + url_raw = _safe(pub.get("url", "")); url = _normalize_url(url_raw) if url_raw else None + p = P(stylename=styles.para(space_after=1, line_height=1.2)) + if url: + _slink(p, styles, _safe(pub.get("title")), url, body, base - 0.5, bold=True, underline=False) + else: + _srun(p, styles, _safe(pub.get("title")), base - 0.5, body, bold=True) + if _safe(pub.get("venue")): + _srun(p, styles, f" – {_safe(pub.get('venue'))}", base - 0.5, body, italic=True) + if _safe(pub.get("date")): + _srun(p, styles, f" ({_safe(pub.get('date'))})", base - 0.5, muted) + doc.text.addElement(p) + _serif_gap(doc, styles) + + patents = resume.get("patents") or [] + if patents: + _ssection(doc, styles, "Patents") + for pt in patents: + p = P(stylename=styles.para(space_after=1, line_height=1.2)) + _srun(p, styles, _safe(pt.get("title")), base - 0.5, body, bold=True) + if _safe(pt.get("patent_number")): + _srun(p, styles, f" – {_safe(pt.get('patent_number'))}", base - 0.5, body) + if _safe(pt.get("date")): + _srun(p, styles, f" ({_safe(pt.get('date'))})", base - 0.5, muted) + doc.text.addElement(p) + _serif_gap(doc, styles) + + awards = resume.get("awards_and_honors") or [] + if awards: + _ssection(doc, styles, "Awards & Honors") + for a in awards: + if isinstance(a, dict): + nm = _safe(a.get("name") or a.get("title")) + issuer = _safe(a.get("issuer")); yr = _safe(a.get("year") or a.get("date")) + v = nm + (f" – {issuer}" if issuer else "") + (f" ({yr})" if yr else "") + else: + v = _safe(a) + if v: + _sbullet(doc, styles, v) + + vols = resume.get("volunteer_experience") or [] + if vols: + _ssection(doc, styles, "Volunteer Experience") + for v in vols: + org = _safe(v.get("organization")); role = _safe(v.get("role")) + head_left = " – ".join([x for x in (org, role) if x]) + _shrow(doc, styles, + lambda p, t=head_left: _srun(p, styles, t, base, body, bold=True), + _serif_dates(_safe(v.get("start_date")), _safe(v.get("end_date"))), + base, body, right_bold=True) + if _safe(v.get("description")): + pd = P(stylename=styles.para(space_after=0, line_height=1.2)) + _srun(pd, styles, _safe(v.get("description")), base - 1.5, muted, italic=True) + doc.text.addElement(pd) + _serif_gap(doc, styles) + + langs = resume.get("languages") or [] + if langs: + _ssection(doc, styles, "Languages") + p = P(stylename=styles.para(space_after=1.5, line_height=1.2)) + for i, l in enumerate(langs): + lang = _safe(l.get("language")); prof = _safe(l.get("proficiency")) + if not lang: + continue + if i > 0: + _srun(p, styles, " · ", base - 0.5, body) + _srun(p, styles, lang, base - 0.5, body, bold=True) + if prof: + _srun(p, styles, f" ({prof})", base - 0.5, body) + doc.text.addElement(p) + + interests = [_safe(i) for i in (resume.get("interests") or []) if _safe(i)] + if interests: + _ssection(doc, styles, "Interests") + p = P(stylename=styles.para(space_after=1.5, line_height=1.2)) + _srun(p, styles, ", ".join(interests), base - 0.5, body) + doc.text.addElement(p) + + references = resume.get("references") + if references: + _ssection(doc, styles, "References") + if isinstance(references, str): + p = P(stylename=styles.para(space_after=1.5, line_height=1.2)) + _srun(p, styles, _safe(references), base - 0.5, body) + doc.text.addElement(p) + elif isinstance(references, list): + for ref in references: + if _safe(ref): + _sbullet(doc, styles, _safe(ref)) + + def _build_resume_doc(resume: dict, template: TemplateName, base: float) -> OpenDocumentText: palette = PALETTES_HEX.get(template, PALETTES_HEX["classic_ats"]) # Match the PDF generator: 0.6in side, 0.55in top/bottom. @@ -817,6 +1468,16 @@ def generate_odt_resume( from app.documents._normalize import normalize_resume, resolve_base_font_size resume = normalize_resume(resume) base = resolve_base_font_size(font_size) + + if template == "latex_serif": + doc = _build_resume_doc_serif(resume, base) + buf = io.BytesIO() + doc.write(buf) + try: + return _embed_fonts_odt(buf.getvalue()) + except Exception: + return buf.getvalue() # font-name-only fallback if embedding fails + doc = _build_resume_doc(resume, template, base) buf = io.BytesIO() doc.write(buf) diff --git a/backend/app/documents/pdf_generator.py b/backend/app/documents/pdf_generator.py index 8deb73c..6ad1bb2 100644 --- a/backend/app/documents/pdf_generator.py +++ b/backend/app/documents/pdf_generator.py @@ -1,6 +1,6 @@ """PDF resume + cover-letter generators using ReportLab. -Templates: classic_ats | modern_clean | executive_dark | dark_theme +Templates: classic_ats | modern_clean | executive_dark | dark_theme | latex_serif Design notes (2026 era, not 1990): - Generous whitespace and clear typographic hierarchy. @@ -10,9 +10,18 @@ - Bullets use a true hanging indent and tight leading. - Skills rendered as a clean 'Label : values' line block. - For max_pages=1|2 the resume generator transparently shrinks the body until it fits. + +`latex_serif` is a separate, light-only layout that recreates the classic LaTeX +("Jake's Resume") look: the bundled Computer Modern (CMU Serif) typeface so the +output is typographically identical to a pdfLaTeX résumé, ~0.5in margins, a +centred small-caps name, an icon-prefixed contact line, Title-Case ruled section +headers, and combined "Company - Title / dates" rows with an italic technologies +line beneath (rendered by `_build_story_serif`). If the CM font files are +missing it transparently falls back to Times. """ from __future__ import annotations import io +import os from datetime import date from typing import Literal @@ -27,7 +36,9 @@ ) -TemplateName = Literal["classic_ats", "modern_clean", "executive_dark", "dark_theme"] +TemplateName = Literal[ + "classic_ats", "modern_clean", "executive_dark", "dark_theme", "latex_serif" +] FontSize = Literal["small", "normal", "large"] MaxPages = Literal["1", "2", "auto"] @@ -70,6 +81,17 @@ "muted": colors.HexColor("#9098b3"), "accent": colors.HexColor("#a4a8ff"), }, + # Light-only LaTeX-style template — almost pure black on white, like a + # freshly compiled `moderncv`/`Jake's Resume`. Rendered by _build_story_serif. + "latex_serif": { + "name": colors.black, + "title": colors.HexColor("#1a1a1a"), + "header": colors.black, + "rule": colors.black, + "body": colors.HexColor("#111111"), + "muted": colors.HexColor("#333333"), + "accent": colors.black, + }, } FONT_SIZES = {"small": 9.5, "normal": 10.5, "large": 11.5} @@ -134,10 +156,44 @@ def _link(text: str, url: str | None, accent_hex: str) -> str: return f'{text}' +def _smallcaps(text: str, small_size: float) -> str: + """Fake small-caps markup: first letter of each word at the style's size, + the remainder uppercased at `small_size`. Mimics LaTeX \\scshape headings. + """ + words = (text or "").split(" ") + out: list[str] = [] + for w in words: + if not w: + continue + first = _esc(w[0]) + rest = _esc(w[1:].upper()) + out.append(f"{first}{rest}" if rest else first) + return " ".join(out) + + _CONTENT_W = letter[0] - 2 * (0.6 * inch) # 525.6pt for letter + 0.6in margins _BULLET_COL = 11 # pt: visual indent from bullet to text (consistent at all font sizes) _TEXT_COL = _CONTENT_W - _BULLET_COL +# The serif (LaTeX) template mimics Jake's-résumé geometry: ink starts ~0.4in +# from each edge, with a tight top and bullets indented under each entry. +# +# SimpleDocTemplate wraps the page in a Frame whose default inner padding is 6pt +# on every side, so the *ink* lands at (margin + 6pt). We therefore pass margins +# reduced by that padding, which makes the real left/right ink edge fall at +# exactly 0.4in — matching the template — while the content width stays 554.4pt. +_SERIF_FRAME_PAD = 6 +_SERIF_INK_MARGIN_X = 0.4 * inch # where ink should start +_SERIF_MARGIN_X = _SERIF_INK_MARGIN_X - _SERIF_FRAME_PAD # margin handed to ReportLab +# Top/bottom are tuned to the final ink position (the 6pt pad is already folded +# in): topMargin lands the name top at ~0.3in like the original. +_SERIF_MARGIN_TOP = 0.13 * inch +_SERIF_MARGIN_BOTTOM = 0.4 * inch +_SERIF_CONTENT_W = letter[0] - 2 * _SERIF_INK_MARGIN_X # 554.4pt frame content width +_SERIF_BULLET_INDENT = 14 # pt: left indent of the bullet glyph under an entry +_SERIF_BULLET_GAP = 11 # pt: bullet glyph column → text column +_SERIF_TEXT_COL = _SERIF_CONTENT_W - _SERIF_BULLET_INDENT - _SERIF_BULLET_GAP + def _styles(p: dict, base: float) -> dict: bf, bld = "Helvetica", "Helvetica-Bold" @@ -228,20 +284,36 @@ def _section(title: str, st: dict, p: dict) -> list: ] -def _bullet_row(text_markup: str, st: dict) -> Table: +def _bullet_row(text_markup: str, st: dict, bottom_pad: float | None = None) -> Table: """Two-cell table bullet: col-0=•, col-1=text. Continuation lines align perfectly because they're in their own column. + + The gap below each bullet defaults to 2pt, but a style set may carry a + ``"bullet_pad"`` override — the serif (LaTeX) template uses a tighter value + to pack bullets like a pdfLaTeX résumé. """ - t = Table( - [[Paragraph("•", st["bullet_mark"]), Paragraph(text_markup, st["bullet_body"])]], - colWidths=[_BULLET_COL, _TEXT_COL], - ) + if bottom_pad is None: + bottom_pad = st.get("bullet_pad", 2) + bullet_col = st.get("bullet_col", _BULLET_COL) + text_col = st.get("text_col", _TEXT_COL) + indent = st.get("bullet_indent", 0) + mark = Paragraph("•", st["bullet_mark"]) + body = Paragraph(text_markup, st["bullet_body"]) + if indent: + # itemize-style: an empty spacer column indents the bullet under the entry + row, widths = [["", mark, body]], [indent, bullet_col, text_col] + else: + row, widths = [[mark, body]], [bullet_col, text_col] + t = Table(row, colWidths=widths) + # Pin to the left margin — reportlab Tables default to hAlign='CENTER', which + # would drift the row sideways whenever its width != the frame width. + t.hAlign = "LEFT" t.setStyle(TableStyle([ ("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0), ("TOPPADDING", (0, 0), (-1, -1), 0), - ("BOTTOMPADDING", (0, 0), (-1, -1), 2), + ("BOTTOMPADDING", (0, 0), (-1, -1), bottom_pad), ])) return t @@ -674,19 +746,679 @@ def _build_story(resume: dict, p: dict, st: dict) -> list: return story +# ═══════════════════════════════════════════════════════════════════════════ +# latex_serif — a self-contained, light-only LaTeX-style layout. +# ═══════════════════════════════════════════════════════════════════════════ + + +# Bundled Computer Modern (CMU Serif) faces — the actual LaTeX typeface, so the +# template is typographically identical to a pdfLaTeX-compiled résumé. Converted +# from the OFL-licensed cm-unicode OTFs to TrueType (ReportLab can't embed CFF). +_FONTS_DIR = os.path.join(os.path.dirname(__file__), "fonts") +_CM_FACES = { + "CMUSerif": "CMUSerif-Roman.ttf", + "CMUSerif-Bold": "CMUSerif-Bold.ttf", + "CMUSerif-Italic": "CMUSerif-Italic.ttf", + "CMUSerif-BoldItalic": "CMUSerif-BoldItalic.ttf", +} +# FontAwesome 6 Free — the exact icon font the LaTeX `fontawesome5` package uses, +# so contact icons are glyph-identical to the template (solid handset/envelope/ +# pin) rather than hand-drawn approximations. +_FA_FACES = { + "FA-Solid": "fa-solid-900.ttf", + "FA-Brands": "fa-brands-400.ttf", +} +_CM_REGISTERED: bool | None = None # None = not tried yet +_FA_REGISTERED: bool | None = None + + +def _ensure_cm_fonts() -> bool: + """Register the bundled Computer Modern faces once. Returns True if the + family is usable; False (→ Times fallback) if the files are missing.""" + global _CM_REGISTERED + if _CM_REGISTERED is not None: + return _CM_REGISTERED + try: + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + for name, fn in _CM_FACES.items(): + if name not in pdfmetrics.getRegisteredFontNames(): + pdfmetrics.registerFont(TTFont(name, os.path.join(_FONTS_DIR, fn))) + # Map / markup (on text whose base font is CMUSerif) to the faces. + pdfmetrics.registerFontFamily( + "CMUSerif", normal="CMUSerif", bold="CMUSerif-Bold", + italic="CMUSerif-Italic", boldItalic="CMUSerif-BoldItalic", + ) + _CM_REGISTERED = True + except Exception: + _CM_REGISTERED = False + return _CM_REGISTERED + + +def _ensure_fa_fonts() -> bool: + """Register the bundled FontAwesome faces once. Returns False (→ no icons) + if the files are missing.""" + global _FA_REGISTERED + if _FA_REGISTERED is not None: + return _FA_REGISTERED + try: + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + for name, fn in _FA_FACES.items(): + if name not in pdfmetrics.getRegisteredFontNames(): + pdfmetrics.registerFont(TTFont(name, os.path.join(_FONTS_DIR, fn))) + _FA_REGISTERED = True + except Exception: + _FA_REGISTERED = False + return _FA_REGISTERED + + +# Contact-field → (FontAwesome face, glyph) — the same icons fontawesome5 emits. +_FA_ICONS = { + "phone": ("FA-Solid", ""), # faPhone (handset) + "email": ("FA-Solid", ""), # faEnvelope (solid) + "location": ("FA-Solid", ""), # faMapMarker (solid teardrop) + "linkedin": ("FA-Brands", ""), # faLinkedin + "github": ("FA-Brands", ""), # faGithub + "web": ("FA-Solid", ""), # faGlobe +} + + +def _fa_icon(kind: str, color_hex: str, size: float) -> str: + """Inline FontAwesome glyph markup for a contact icon, or '' if unavailable.""" + if not _ensure_fa_fonts() or kind not in _FA_ICONS: + return "" + face, glyph = _FA_ICONS[kind] + return (f'' + f'{glyph}') + + +def _serif_styles(p: dict, base: float) -> dict: + """Computer-Modern paragraph styles recreating the classic LaTeX résumé look + (falls back to Times if the bundled CM fonts can't be loaded).""" + if _ensure_cm_fonts(): + sf, sb, si, sbi = "CMUSerif", "CMUSerif-Bold", "CMUSerif-Italic", "CMUSerif-BoldItalic" + else: + sf, sb, si, sbi = "Times-Roman", "Times-Bold", "Times-Italic", "Times-BoldItalic" + name_sz = base + 13.5 + return { + "name": ParagraphStyle( + "SName", fontName=sb, fontSize=name_sz, + textColor=p["name"], alignment=TA_CENTER, + spaceAfter=1, leading=name_sz, + ), + "title": ParagraphStyle( + "STitle", fontName=si, fontSize=base + 0.5, + textColor=p["muted"], alignment=TA_CENTER, + spaceAfter=2, leading=(base + 0.5) * 1.2, + ), + "contact": ParagraphStyle( + "SContact", fontName=sf, fontSize=base - 0.5, + textColor=p["body"], alignment=TA_CENTER, + spaceAfter=1, leading=(base - 0.5) * 1.45, + ), + "section": ParagraphStyle( + "SSection", fontName=sb, fontSize=base + 2.5, + textColor=p["header"], spaceBefore=6, spaceAfter=0, + leading=(base + 2.5) * 1.05, + ), + "summary": ParagraphStyle( + "SSummary", fontName=sf, fontSize=base, + textColor=p["body"], alignment=TA_JUSTIFY, + leading=base * 1.2, spaceAfter=1, + ), + # Combined "Company - Title" row (bold) + bold dates on the right. + "head": ParagraphStyle( + "SHead", fontName=sb, fontSize=base, + textColor=p["body"], leading=base * 1.2, spaceAfter=0, + ), + "head_right": ParagraphStyle( + "SHeadR", fontName=sb, fontSize=base, + textColor=p["body"], leading=base * 1.2, spaceAfter=0, + alignment=TA_RIGHT, + ), + # Italic technologies / degree line + italic location on the right. + "sub": ParagraphStyle( + "SSub", fontName=si, fontSize=base - 0.5, + textColor=p["body"], leading=(base - 0.5) * 1.18, spaceAfter=0, + ), + # Same as "sub" but nudged in by ~one space — used for the experience + # "Technologies:" line so it doesn't start at the exact same left edge + # as the company name above it. + "sub_indent": ParagraphStyle( + "SSubIndent", fontName=si, fontSize=base - 0.5, + textColor=p["body"], leading=(base - 0.5) * 1.18, spaceAfter=0, + leftIndent=(base - 0.5) * 0.5, + ), + "sub_right": ParagraphStyle( + "SSubR", fontName=si, fontSize=base - 0.5, + textColor=p["body"], leading=(base - 0.5) * 1.18, spaceAfter=0, + alignment=TA_RIGHT, + ), + "bullet_mark": ParagraphStyle( + "SBulMark", fontName=sf, fontSize=base - 0.5, + textColor=p["body"], leading=(base - 0.5) * 1.18, + alignment=TA_LEFT, spaceAfter=0, + ), + "bullet_body": ParagraphStyle( + "SBulBody", fontName=sf, fontSize=base - 0.5, + textColor=p["body"], leading=(base - 0.5) * 1.18, + alignment=TA_JUSTIFY, spaceAfter=0, + ), + "skill_line": ParagraphStyle( + "SSkill", fontName=sf, fontSize=base - 0.5, + textColor=p["body"], leading=(base - 0.5) * 1.22, spaceAfter=1.5, + ), + "comp_item": ParagraphStyle( + "SComp", fontName=sf, fontSize=base - 0.5, + textColor=p["body"], leading=(base - 0.5) * 1.3, spaceAfter=0, + ), + "small": ParagraphStyle( + "SSmall", fontName=si, fontSize=base - 1.5, + textColor=p["muted"], leading=(base - 1.5) * 1.3, spaceAfter=0, + ), + "cert": ParagraphStyle( + "SCert", fontName=sf, fontSize=base - 0.5, + textColor=p["body"], leading=(base - 0.5) * 1.35, spaceAfter=1, + ), + # Non-style overrides read by _bullet_row: tighter bullet spacing, an + # itemize-style indent, and a wider text column matching 0.4in margins. + "bullet_pad": 1.0, + "bullet_col": _SERIF_BULLET_GAP, + "bullet_indent": _SERIF_BULLET_INDENT, + "text_col": _SERIF_TEXT_COL, + # Explicit face names for inline spans whose weight/slant must not be + # inherited from the surrounding run (e.g. the bold "Technologies" label, + # or regular-italic project tech sitting inside a bold title), resolved + # for whichever family is active. + "bolditalic_font": sbi, + "italic_font": si, + "bold_font": sb, + "regular_font": sf, + } + + +def _serif_hrow(left_para: Paragraph, right_plain: str, right_style, + total_w: float = _SERIF_CONTENT_W) -> Table: + """LaTeX ``\\hfill`` row: left text flows across the line, the right item + (date / location) is sized to its own width and hugs the right margin, so a + long left string uses the full width instead of wrapping inside a fixed cell. + """ + from reportlab.pdfbase.pdfmetrics import stringWidth + right_plain = right_plain or "" + if right_plain: + rw = stringWidth(right_plain, right_style.fontName, right_style.fontSize) + 3 + rw = min(rw, total_w * 0.5) + else: + rw = 0.01 + t = Table([[left_para, Paragraph(_esc(right_plain), right_style)]], + colWidths=[total_w - rw, rw]) + t.hAlign = "LEFT" # pin to the left margin (Tables default to CENTER) + t.setStyle(TableStyle([ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ])) + return t + + +def _serif_section(title: str, st: dict, p: dict) -> list: + """Title-Case heading with a hairline rule beneath (no uppercasing).""" + return [ + Paragraph(_esc(title), st["section"]), + HRFlowable(width="100%", thickness=0.6, + color=p["rule"], spaceBefore=2, spaceAfter=4), + ] + + +def _serif_dates(start: str, end: str) -> str: + if start and end: + return f"{start} – {end}" + return end or start or "" + + +def _build_story_serif(resume: dict, p: dict, st: dict, base: float) -> list: + """Compose the flowable story for the latex_serif (LaTeX-style) template.""" + accent_hex = _hex(p["accent"]) + muted_hex = _hex(p["muted"]) + icon_hex = _hex(p["body"]) + icon_sz = base - 0.5 + sep = "    " + + def _ico(kind: str) -> str: + g = _fa_icon(kind, icon_hex, icon_sz) + return f"{g} " if g else "" + # Computer Modern packs tighter than the sans templates; use a smaller + # inter-item gap here (shadows the module default just inside this builder) + # so a typical résumé lands on the same page count as the LaTeX original. + _ITEM_GAP = 3 + + pi = resume.get("personal_info", {}) or {} + story: list = [] + + # ── Header: small-caps name, optional title, icon contact line ──────────── + name = _safe(pi.get("full_name")) or "Your Name" + # Small-caps body sized to 0.74× the name style's font size (base + 13.5). + story.append(Paragraph(_smallcaps(name, (base + 13.5) * 0.74), st["name"])) + role_title = _safe(pi.get("professional_title")) + if role_title: + story.append(Paragraph(_esc(role_title), st["title"])) + + # Phone · email · location go on their own row; profile links flow onto the + # next row. The separators are non-breaking spaces, so without this split a + # too-long single line would wrap at the only breakable space — the one + # inside the location ("Austin, TX" → "Austin," / "TX") — which looks broken. + contact_personal: list[str] = [] + phone = _safe(pi.get("phone")) + if phone: + contact_personal.append(_ico("phone") + _esc(phone)) + # LaTeX hyperref underlines the linked contacts; mirror that with . + email = _safe(pi.get("email")) + if email: + contact_personal.append( + _ico("email") + f"{_link(email, _normalize_url(email), accent_hex)}" + ) + location = _safe(pi.get("location")) + if location: + # Hold the location together so it never splits across a line. + contact_personal.append(_ico("location") + _esc(location).replace(" ", " ")) + contact_links: list[str] = [] + for key in ("linkedin", "github", "website", "portfolio"): + v = _safe(pi.get(key)) + if v: + url = _normalize_url(v) + display = v.replace("https://", "").replace("http://", "").rstrip("/") + icon_kind = key if key in ("linkedin", "github") else "web" + contact_links.append(_ico(icon_kind) + f"{_link(display, url, accent_hex)}") + contact_rows = [sep.join(row) for row in (contact_personal, contact_links) if row] + if contact_rows: + story.append(Paragraph("
".join(contact_rows), st["contact"])) + + story.append(Spacer(1, 1)) + + # ── Summary ─────────────────────────────────────────────────────────────── + summary = _safe(resume.get("professional_summary", "")) + if summary: + story += _serif_section("Summary", st, p) + story.append(Paragraph(_esc(summary), st["summary"])) + + # ── Core Competencies (3-col grid, column-major) ────────────────────────── + competencies = [_safe(c) for c in (resume.get("core_competencies") or []) if _safe(c)] + if competencies: + story += _serif_section("Core Competencies", st, p) + cols = 3 + rows = (len(competencies) + cols - 1) // cols + grid = [["" for _ in range(cols)] for _ in range(rows)] + for i, item in enumerate(competencies): + grid[i % rows][i // rows] = item + table_data = [ + [Paragraph(f"• {_esc(cell)}" if cell else "", st["comp_item"]) for cell in row] + for row in grid + ] + tbl = Table(table_data, colWidths=["33.3%", "33.3%", "33.4%"]) + tbl.hAlign = "LEFT" + tbl.setStyle(TableStyle([ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 1.5), + ])) + story.append(tbl) + story.append(Spacer(1, 2)) + + # ── Professional Experience ─────────────────────────────────────────────── + experience = resume.get("experience") or [] + if experience: + story += _serif_section("Professional Experience", st, p) + for exp in experience: + title_str = _safe(exp.get("title")) + company_str = _safe(exp.get("company")) + loc_str = _safe(exp.get("location")) + start = _safe(exp.get("start_date")) + end = _safe(exp.get("end_date")) or ("Present" if exp.get("current") else "Present") + emp_type = _safe(exp.get("employment_type", "")) + + head_left = " – ".join([x for x in (company_str, title_str) if x]) or title_str + if emp_type and emp_type.lower() not in ("full-time", "fulltime", ""): + head_left = f"{head_left} ({emp_type})" + + header_block: list = [_serif_hrow( + Paragraph(f"{_esc(head_left)}", st["head"]), + _serif_dates(start, end), st["head_right"], + )] + + techs = [_safe(t) for t in (exp.get("technologies") or []) if _safe(t)] + team_size = _safe(exp.get("team_size")) + # "Technologies" label bold-italic, values italic (matches the source). + sub_markup = "" + if techs: + sub_markup = (f'Technologies: ' + f'{_esc(", ".join(techs))}') + if team_size: + sub_markup += f" · Team: {_esc(team_size)}" + elif team_size: + sub_markup = f"Team: {_esc(team_size)}" + if sub_markup or loc_str: + header_block.append(_serif_hrow( + Paragraph(sub_markup, st["sub_indent"]), loc_str, st["sub_right"], + )) + header_block.append(Spacer(1, 1)) + + bullets = list(exp.get("responsibilities") or []) + list(exp.get("achievements") or []) + dedup: list[str] = [] + seen: set[str] = set() + for b in bullets: + b_str = _safe(b) + if not b_str or b_str.lower() in seen: + continue + seen.add(b_str.lower()) + dedup.append(b_str) + + if dedup: + header_block.append(_bullet_row(_esc(dedup[0]), st)) + story.append(KeepTogether(header_block)) + for b_str in dedup[1:]: + story.append(_bullet_row(_esc(b_str), st)) + story.append(Spacer(1, _ITEM_GAP)) + + # ── Projects ────────────────────────────────────────────────────────────── + projects = resume.get("projects") or [] + if projects: + story += _serif_section("Projects", st, p) + for proj in projects: + block: list = [] + p_name = _safe(proj.get("name")) + url_raw = _safe(proj.get("url") or proj.get("github") or "") + url = _normalize_url(url_raw) if url_raw else None + tech = ", ".join(_safe(t) for t in (proj.get("technologies") or []) if _safe(t)) + proj_role = _safe(proj.get("role")) + proj_type = _safe(proj.get("type")) + p_start = _safe(proj.get("start_date")) + p_end = _safe(proj.get("end_date")) + name_lc = p_name.lower() + + head = f"{_link(p_name, url, accent_hex)}" + descriptor = next((d for d in (proj_role, proj_type) if d and d.lower() not in name_lc), "") + if descriptor: + head += f" ({_esc(descriptor)})" + if tech: + # Regular-weight separator + regular-italic tech (NOT bold-italic) + # — explicit faces stop them inheriting the bold title weight. + head += (f' | ' + f'{_esc(tech)}') + + dates_str = _serif_dates(p_start, p_end) + block.append(_serif_hrow( + Paragraph(head, st["head"]), dates_str, st["head_right"], + )) + block.append(Spacer(1, 1)) + + desc = _safe(proj.get("description")) + if desc: + block.append(_bullet_row(_esc(desc), st)) + for h in (proj.get("highlights") or []): + h_str = _safe(h) + if h_str: + block.append(_bullet_row(_esc(h_str), st)) + block.append(Spacer(1, _ITEM_GAP)) + story.append(KeepTogether(block)) + + # ── Open Source ─────────────────────────────────────────────────────────── + oss = resume.get("open_source_contributions") or [] + if oss: + story += _serif_section("Open Source", st, p) + for o in oss: + block = [] + proj = _safe(o.get("project")) + role = _safe(o.get("role")) + url_raw = _safe(o.get("url", "")) + url = _normalize_url(url_raw) if url_raw else None + stars = _safe(o.get("stars", "")) + o_lang = _safe(o.get("language", "")) + head = f"{_link(proj, url, accent_hex)}" + if role: + head += f" – {_esc(role)}" + if o_lang: + head += f" {_esc(o_lang)}" + if stars: + head += f" ★ {_esc(stars)}" + block.append(Paragraph(head, st["head"])) + o_desc = _safe(o.get("description", "")) or _safe(o.get("contribution", "")) + if o_desc: + block.append(Paragraph(_esc(o_desc), st["small"])) + for contrib in (o.get("contributions") or []): + c_str = _safe(contrib) + if c_str: + block.append(_bullet_row(_esc(c_str), st)) + block.append(Spacer(1, _ITEM_GAP)) + story.append(KeepTogether(block)) + + # ── Education ───────────────────────────────────────────────────────────── + education = resume.get("education") or [] + if education: + story += _serif_section("Education", st, p) + for edu in education: + block = [] + deg = _safe(edu.get("degree")) + field = _safe(edu.get("field_of_study")) + inst = _safe(edu.get("institution")) + loc = _safe(edu.get("location")) + start = _safe(edu.get("start_date")) + end = _safe(edu.get("end_date")) or _safe(edu.get("graduation_year")) + gpa = _safe(edu.get("gpa")) + honors = _safe(edu.get("honors")) + + # Only append the field when the degree doesn't already mention it, + # so "M.S. in Computer Science" + field "Computer Science" doesn't + # become "...Computer Science in Computer Science". + deg_line = deg + if field and field.lower() not in deg.lower(): + deg_line = f"{deg} in {field}" if deg else field + block.append(_serif_hrow( + Paragraph(f"{_esc(inst)}" if inst else "", st["head"]), + _serif_dates(start, end), st["head_right"], + )) + if deg_line or loc: + block.append(_serif_hrow( + Paragraph(_esc(deg_line) if deg_line else "", st["sub"]), + loc, st["sub_right"], + )) + extras = [] + if gpa: + extras.append(f"GPA: {_esc(gpa)}") + if honors: + extras.append(_esc(honors)) + cw = ", ".join(_safe(c) for c in (edu.get("relevant_coursework") or []) if _safe(c)) + if cw: + extras.append(f"Coursework: {_esc(cw)}") + if extras: + block.append(Paragraph(" · ".join(extras), st["small"])) + acts = ", ".join(_safe(a) for a in (edu.get("activities") or []) if _safe(a)) + if acts: + block.append(Paragraph(f"Activities: {_esc(acts)}", st["small"])) + block.append(Spacer(1, _ITEM_GAP)) + story.append(KeepTogether(block)) + + # ── Technical Skills ────────────────────────────────────────────────────── + skills = resume.get("technical_skills") or {} + skill_cats = [ + ("Languages", skills.get("programming_languages")), + ("Frameworks & Libraries", skills.get("frameworks_and_libraries")), + ("Databases", skills.get("databases")), + ("Cloud & Infrastructure", skills.get("cloud_and_infrastructure")), + ("DevOps & Tooling", skills.get("devops_and_tools") or skills.get("tools_and_practices")), + ("Testing", skills.get("testing")), + ("Methodologies", skills.get("methodologies")), + ("Soft Skills", skills.get("soft_skills")), + ] + skill_rows = [(k, ", ".join(_safe(x) for x in v if _safe(x))) for k, v in skill_cats if v] + if skill_rows: + story += _serif_section("Technical Skills", st, p) + for label, vals in skill_rows: + if vals: + story.append(Paragraph( + f"{_esc(label)} : {_esc(vals)}", st["skill_line"], + )) + + # ── Certifications ──────────────────────────────────────────────────────── + certs = resume.get("certifications") or [] + if certs: + story += _serif_section("Certifications", st, p) + for c in certs: + c_name = _safe(c.get("name")) + c_issuer = _safe(c.get("issuer")) + c_date = _safe(c.get("date")) or _safe(c.get("year")) + c_expiry = _safe(c.get("expiry")) + c_cred_id = _safe(c.get("credential_id")) + c_url_raw = _safe(c.get("url", "")) + c_url = _normalize_url(c_url_raw) if c_url_raw else None + line = f"{_link(c_name, c_url, accent_hex)}" + if c_issuer: + line += f" – {_esc(c_issuer)}" + date_parts = [] + if c_date: + date_parts.append(f"issued {_esc(c_date)}") + if c_expiry: + date_parts.append(f"expires {_esc(c_expiry)}") + if date_parts: + line += f" ({', '.join(date_parts)})" + if c_cred_id: + line += f" ID: {_esc(c_cred_id)}" + story.append(Paragraph(line, st["cert"])) + story.append(Spacer(1, _ITEM_GAP)) + + # ── Publications ────────────────────────────────────────────────────────── + pubs = resume.get("publications") or [] + if pubs: + story += _serif_section("Publications", st, p) + for pub in pubs: + title = _safe(pub.get("title")) + venue = _safe(pub.get("venue")) + date_pub = _safe(pub.get("date")) + pub_type = _safe(pub.get("type", "")) + url_raw = _safe(pub.get("url", "")) + url = _normalize_url(url_raw) if url_raw else None + authors = ", ".join(_safe(a) for a in (pub.get("authors") or []) if _safe(a)) + line = f"{_link(title, url, accent_hex)}" + if pub_type and f"[{pub_type.lower()}]" not in title.lower(): + line += f" [{_esc(pub_type)}]" + if venue: + line += f" – {_esc(venue)}" + if date_pub: + line += f" ({_esc(date_pub)})" + story.append(Paragraph(line, st["cert"])) + if authors: + story.append(Paragraph(_esc(authors), st["small"])) + story.append(Spacer(1, _ITEM_GAP)) + + # ── Patents ─────────────────────────────────────────────────────────────── + patents = resume.get("patents") or [] + if patents: + story += _serif_section("Patents", st, p) + for pt in patents: + pt_title = _safe(pt.get("title")) + pt_url_raw = _safe(pt.get("url", "")) + pt_url = _normalize_url(pt_url_raw) if pt_url_raw else None + line = f"{_link(pt_title, pt_url, accent_hex)}" + num = _safe(pt.get("patent_number")) + date_pt = _safe(pt.get("date")) + if num: + line += f" – {_esc(num)}" + if date_pt: + line += f" ({_esc(date_pt)})" + story.append(Paragraph(line, st["cert"])) + desc = _safe(pt.get("description")) + if desc: + story.append(Paragraph(_esc(desc), st["small"])) + story.append(Spacer(1, _ITEM_GAP)) + + # ── Awards & Honors ─────────────────────────────────────────────────────── + awards = resume.get("awards_and_honors") or [] + if awards: + story += _serif_section("Awards & Honors", st, p) + for a in awards: + if isinstance(a, dict): + nm = _safe(a.get("name") or a.get("title")) + issuer = _safe(a.get("issuer")) + yr = _safe(a.get("year") or a.get("date")) + v = nm + (f" – {issuer}" if issuer else "") + (f" ({yr})" if yr else "") + else: + v = _safe(a) + if v: + story.append(_bullet_row(_esc(v), st)) + + # ── Volunteer Experience ────────────────────────────────────────────────── + volunteer = resume.get("volunteer_experience") or [] + if volunteer: + story += _serif_section("Volunteer Experience", st, p) + for v in volunteer: + org = _safe(v.get("organization")) + role = _safe(v.get("role")) + sd, ed = _safe(v.get("start_date")), _safe(v.get("end_date")) + dates_str = _serif_dates(sd, ed) + head_left = " – ".join([x for x in (org, role) if x]) + story.append(_serif_hrow( + Paragraph(f"{_esc(head_left)}", st["head"]), + dates_str, st["head_right"], + )) + desc = _safe(v.get("description")) + if desc: + story.append(Paragraph(_esc(desc), st["small"])) + story.append(Spacer(1, _ITEM_GAP)) + + # ── Languages ───────────────────────────────────────────────────────────── + langs = resume.get("languages") or [] + if langs: + story += _serif_section("Languages", st, p) + parts = [] + for l in langs: + lang = _safe(l.get("language")) + prof = _safe(l.get("proficiency")) + if lang: + parts.append(f"{_esc(lang)}" + (f" ({_esc(prof)})" if prof else "")) + story.append(Paragraph(" · ".join(parts), st["skill_line"])) + + # ── Interests ───────────────────────────────────────────────────────────── + interests = [_safe(i) for i in (resume.get("interests") or []) if _safe(i)] + if interests: + story += _serif_section("Interests", st, p) + story.append(Paragraph(_esc(", ".join(interests)), st["skill_line"])) + + # ── References ──────────────────────────────────────────────────────────── + references = resume.get("references") + if references: + story += _serif_section("References", st, p) + if isinstance(references, str): + story.append(Paragraph(_esc(references), st["skill_line"])) + elif isinstance(references, list): + for ref in references: + ref_str = _safe(ref) + if ref_str: + story.append(_bullet_row(_esc(ref_str), st)) + + return story + + def _render(resume: dict, template: TemplateName, base: float) -> bytes: p = PALETTES.get(template, PALETTES["classic_ats"]) - st = _styles(p, base) + serif = template == "latex_serif" + st = _serif_styles(p, base) if serif else _styles(p, base) buf = io.BytesIO() - margin = 0.6 * inch + margin_x = _SERIF_MARGIN_X if serif else 0.6 * inch + margin_top = _SERIF_MARGIN_TOP if serif else 0.55 * inch + margin_bottom = _SERIF_MARGIN_BOTTOM if serif else 0.55 * inch doc = SimpleDocTemplate( buf, pagesize=letter, - leftMargin=margin, rightMargin=margin, - topMargin=0.55 * inch, bottomMargin=0.55 * inch, + leftMargin=margin_x, rightMargin=margin_x, + topMargin=margin_top, bottomMargin=margin_bottom, title=_safe(resume.get("personal_info", {}).get("full_name")) or "Resume", author=_safe(resume.get("personal_info", {}).get("full_name")) or "", ) - story = _build_story(resume, p, st) + story = _build_story_serif(resume, p, st, base) if serif else _build_story(resume, p, st) bg_color = p.get("bg") if bg_color: def _draw_bg(canvas, doc, _c=bg_color): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 276a459..301bffd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -176,7 +176,7 @@ class SessionsPage(BaseModel): # ── Document export schemas ─────────────────────────────────────────────────── class PDFGenerateRequest(BaseModel): resume_json: dict - template: str = "classic_ats" # classic_ats | modern_clean | executive_dark + template: str = "classic_ats" # classic_ats | modern_clean | executive_dark | dark_theme | latex_serif font_size: str = "normal" # small | normal | large max_pages: str = "auto" # 1 | 2 | auto (only meaningful for PDF) format: str = "pdf" # pdf | docx | odt diff --git a/backend/requirements.txt b/backend/requirements.txt index 7c4b5b7..5fc7bd6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,4 +20,5 @@ pypdf==6.12.1 pdfminer.six==20260107 python-docx==1.2.0 odfpy==1.4.1 +pillow==11.0.0 langsmith==0.8.5 diff --git a/backend/tests/api/test_document_routes.py b/backend/tests/api/test_document_routes.py index 031e763..4cf3c98 100644 --- a/backend/tests/api/test_document_routes.py +++ b/backend/tests/api/test_document_routes.py @@ -21,7 +21,7 @@ async def test_generate_resume_each_format(async_client, sample_resume, fmt, con assert r.content[:4].startswith(magic[:4]) or r.content[:2] == magic[:2] -@pytest.mark.parametrize("template", ["classic_ats", "modern_clean", "executive_dark", "dark_theme"]) +@pytest.mark.parametrize("template", ["classic_ats", "modern_clean", "executive_dark", "dark_theme", "latex_serif"]) async def test_each_template_pdf(async_client, sample_resume, template): r = await async_client.post( "/api/documents/generate", diff --git a/backend/tests/documents/test_docx_generator.py b/backend/tests/documents/test_docx_generator.py index 31d75ef..c306a08 100644 --- a/backend/tests/documents/test_docx_generator.py +++ b/backend/tests/documents/test_docx_generator.py @@ -54,6 +54,31 @@ def test_docx_minimal(minimal_resume): assert "Grace Hopper" in text +@pytest.mark.parametrize("font_size", ["small", "normal", "large"]) +def test_docx_latex_serif_embeds_fonts(sample_resume, font_size): + """latex_serif DOCX mirrors the PDF: Computer Modern + Font Awesome embedded + into the package so it renders the same on any machine.""" + import zipfile + + from app.documents.docx_generator import generate_docx_resume + + data = generate_docx_resume(sample_resume, template="latex_serif", font_size=font_size) + assert data[:2] == b"PK" + assert "Ada Lovelace" in _extract_text(data) + z = zipfile.ZipFile(io.BytesIO(data)) + assert any("word/fonts/font" in n for n in z.namelist()), "fonts not embedded" + ft = z.read("word/fontTable.xml").decode() + assert "CMU Serif" in ft and "SR FA Solid" in ft and "SR FA Brands" in ft + + +def test_docx_latex_serif_minimal(minimal_resume): + from app.documents.docx_generator import generate_docx_resume + + data = generate_docx_resume(minimal_resume, template="latex_serif") + assert data[:2] == b"PK" + assert "Grace Hopper" in _extract_text(data) + + def test_docx_cover_letter(sample_resume): from app.documents.docx_generator import generate_docx_cover_letter diff --git a/backend/tests/documents/test_odt_generator.py b/backend/tests/documents/test_odt_generator.py index 8bf5d1c..6752d2c 100644 --- a/backend/tests/documents/test_odt_generator.py +++ b/backend/tests/documents/test_odt_generator.py @@ -41,6 +41,32 @@ def test_odt_minimal(minimal_resume): assert "Grace Hopper" in _extract_text(data) +@pytest.mark.parametrize("font_size", ["small", "normal", "large"]) +def test_odt_latex_serif_embeds_font_and_icons(sample_resume, font_size): + """latex_serif ODT mirrors the PDF: Computer Modern embedded (with the + EmbedFonts flag LibreOffice needs) and contact icons inlined as PNGs (an + embedded icon *font* won't render in ODT).""" + from app.documents.odt_generator import generate_odt_resume + + data = generate_odt_resume(sample_resume, template="latex_serif", font_size=font_size) + assert data[:2] == b"PK" + assert "Ada Lovelace" in _extract_text(data) + z = zipfile.ZipFile(io.BytesIO(data)) + names = z.namelist() + assert any(n.startswith("Fonts/CMUSerif") for n in names), "CMU not embedded" + assert any(n.startswith("Pictures/") for n in names), "icons not inlined as images" + assert "EmbedFonts" in z.read("settings.xml").decode() + assert "font-face-uri" in z.read("styles.xml").decode() + + +def test_odt_latex_serif_minimal(minimal_resume): + from app.documents.odt_generator import generate_odt_resume + + data = generate_odt_resume(minimal_resume, template="latex_serif") + assert data[:2] == b"PK" + assert "Grace Hopper" in _extract_text(data) + + def test_odt_cover_letter(sample_resume): from app.documents.odt_generator import generate_odt_cover_letter diff --git a/backend/tests/documents/test_pdf_generator.py b/backend/tests/documents/test_pdf_generator.py index d867de3..ab4ad24 100644 --- a/backend/tests/documents/test_pdf_generator.py +++ b/backend/tests/documents/test_pdf_generator.py @@ -43,6 +43,63 @@ def test_resume_pdf_dark_theme_renders(sample_resume): assert pdf.startswith(b"%PDF") +@pytest.mark.parametrize("font_size", ["small", "normal", "large"]) +def test_resume_pdf_latex_serif_renders(sample_resume, font_size): + """latex_serif is the PDF-only LaTeX-style template (bundled Computer Modern + font, small-caps name, icon contact line). The name is rendered in small-caps + so we match it case-insensitively; section headers stay Title-Case.""" + from app.documents.pdf_generator import generate_pdf + + pdf = generate_pdf(sample_resume, template="latex_serif", font_size=font_size, max_pages="auto") + assert pdf.startswith(b"%PDF") + assert len(pdf) > 5_000 + text = _extract_text(pdf) + assert "LOVELACE" in text.upper() + assert "Professional Experience" in text # Title-Case heading, not UPPERCASE + + +def test_resume_pdf_latex_serif_embeds_computer_modern(sample_resume): + """The whole point of this template is the LaTeX typeface — assert the + bundled Computer Modern face is actually registered and embedded, so a + broken font path degrades loudly in CI rather than silently to Times.""" + from app.documents.pdf_generator import generate_pdf, _ensure_cm_fonts + + assert _ensure_cm_fonts() is True, "CMU Serif fonts failed to register" + pdf = generate_pdf(sample_resume, template="latex_serif") + assert b"CMUSerif" in pdf # embedded font resource name appears in the PDF + + +def test_resume_pdf_latex_serif_embeds_fontawesome(sample_resume): + """Contact icons are real Font Awesome glyphs (the same the LaTeX template + uses), so the FA faces must register and embed — otherwise icons vanish.""" + from app.documents.pdf_generator import generate_pdf, _ensure_fa_fonts + + assert _ensure_fa_fonts() is True, "Font Awesome fonts failed to register" + # SAMPLE_RESUME has phone/email/location + linkedin/github/website → solid + brands. + # The faces embed under their internal PostScript names (renamed so DOCX/ODT + # can resolve them unambiguously). + pdf = generate_pdf(sample_resume, template="latex_serif") + assert b"SRFASolid" in pdf + assert b"SRFABrands" in pdf + + +def test_resume_pdf_latex_serif_minimal(minimal_resume): + """Serif builder must degrade gracefully when most sections are absent.""" + from app.documents.pdf_generator import generate_pdf + + pdf = generate_pdf(minimal_resume, template="latex_serif") + assert pdf.startswith(b"%PDF") + assert "HOPPER" in _extract_text(pdf).upper() + + +def test_resume_pdf_latex_serif_max_pages_1(sample_resume): + """The shrink-to-fit loop dispatches through the serif renderer too.""" + from app.documents.pdf_generator import generate_pdf, _page_count + + pdf = generate_pdf(sample_resume, template="latex_serif", max_pages="1", font_size="small") + assert _page_count(pdf) <= 1 + + def test_resume_pdf_minimal_resume(minimal_resume): from app.documents.pdf_generator import generate_pdf diff --git a/frontend/css/mobile.css b/frontend/css/mobile.css index 8e83e57..c28a9f5 100644 --- a/frontend/css/mobile.css +++ b/frontend/css/mobile.css @@ -13,6 +13,8 @@ #sb.mobile-open{transform:translateX(0)!important} #sb.collapsed{transform:translateX(-100%)!important;width:var(--sw)!important;min-width:var(--sw)!important} .sb-expand{display:none!important} + /* No edge-resizing in the mobile drawer. */ + .sb-resizer{display:none!important} .mob-menu-btn{display:flex} /* Main fills the full screen — dvh keeps it within the actual visible area on iOS */ diff --git a/frontend/css/sidebar.css b/frontend/css/sidebar.css index 14c1beb..1e8b0f9 100644 --- a/frontend/css/sidebar.css +++ b/frontend/css/sidebar.css @@ -1,6 +1,15 @@ /* Sidebar — chrome, search, sessions list, footer buttons, and the mobile-overlay backdrop. */ -#sb{width:var(--sw);min-width:var(--sw);height:100vh;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;transition:width .22s ease,min-width .22s ease} +#sb{width:var(--sw);min-width:var(--sw);height:100vh;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;transition:width .22s ease,min-width .22s ease;position:relative} #sb.collapsed{width:0;min-width:0;border-right:0;overflow:hidden} +/* Suspend the width animation while the user is actively dragging the handle. */ +#sb.sb-resizing{transition:none} + +/* Right-edge drag handle for resizing the sidebar. */ +.sb-resizer{position:absolute;top:0;right:-3px;width:6px;height:100%;cursor:col-resize;z-index:40;touch-action:none} +.sb-resizer::after{content:"";position:absolute;top:0;left:2px;width:2px;height:100%;background:transparent;transition:background .15s} +.sb-resizer:hover::after,body.sb-resizing .sb-resizer::after{background:var(--ab)} +#sb.collapsed .sb-resizer{display:none} +body.sb-resizing{cursor:col-resize;user-select:none} .sb-expand{position:absolute;left:8px;top:62px;z-index:30;width:34px;height:34px;background:var(--bg3);border:1px solid var(--border2);border-radius:9px;color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s} .sb-expand:hover{background:var(--bg4);color:var(--text)} .sb-collapse{margin-left:auto;background:none;border:none;color:var(--text3);cursor:pointer;padding:4px 6px;border-radius:6px;display:flex;align-items:center;justify-content:center} diff --git a/frontend/index.html b/frontend/index.html index d88f287..7003223 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -88,6 +88,8 @@
+ +
+
+
📄
LaTeX
+
Serif, academic
+
Font Size
@@ -825,6 +831,7 @@ + diff --git a/frontend/js/diff.js b/frontend/js/diff.js index 0c16dfe..80c1e51 100644 --- a/frontend/js/diff.js +++ b/frontend/js/diff.js @@ -12,6 +12,43 @@ function openDiff(msgId,sessionId){ function closeDiff(){document.getElementById('diff-modal').classList.remove('open');} document.getElementById('diff-modal').addEventListener('click',e=>{if(e.target===e.currentTarget)closeDiff();}); +// Line-aware diff. Aligns unchanged lines first (each bullet/point is a line), +// then runs the word-level diff only inside the line pairs that actually +// changed. This keeps a small wording tweak in one bullet from lighting up +// every bullet, and keeps each word-level diff small enough to stay precise. +function _diffText(a,b){ + a=a==null?'':String(a);b=b==null?'':String(b); + if(a===b)return esc(a); + const al=a.split('\n'),bl=b.split('\n'); + if(al.length<2&&bl.length<2)return _diffWords(a,b); + const m=al.length,n=bl.length; + const dp=Array.from({length:m+1},()=>new Array(n+1).fill(0)); + for(let i=1;i<=m;i++)for(let j=1;j<=n;j++) + dp[i][j]=al[i-1]===bl[j-1]?dp[i-1][j-1]+1:Math.max(dp[i-1][j],dp[i][j-1]); + const ops=[];let i=m,j=n; + while(i>0||j>0){ + if(i>0&&j>0&&al[i-1]===bl[j-1]){ops.unshift({t:'=',v:al[i-1]});i--;j--;} + else if(j>0&&(i===0||dp[i][j-1]>=dp[i-1][j])){ops.unshift({t:'+',v:bl[j-1]});j--;} + else{ops.unshift({t:'-',v:al[i-1]});i--;} + } + const out=[]; + for(let k=0;k${esc(dels[p])}`); + for(let p=pairs;p${esc(adds[p])}`); + } + return out.join('\n'); +} + function _diffWords(a,b){ if(!a&&!b)return''; if(!a)return`${esc(b)}`; @@ -19,7 +56,7 @@ function _diffWords(a,b){ if(a===b)return esc(a); const aw=a.split(/(\s+)/),bw=b.split(/(\s+)/); const m=aw.length,n=bw.length; - if(m*n>15000)return`${esc(a)}\n${esc(b)}`; + if(m*n>1000000)return`${esc(a)}\n${esc(b)}`; const dp=Array.from({length:m+1},()=>new Array(n+1).fill(0)); for(let i=1;i<=m;i++)for(let j=1;j<=n;j++) dp[i][j]=aw[i-1]===bw[j-1]?dp[i-1][j-1]+1:Math.max(dp[i-1][j],dp[i][j-1]); @@ -34,7 +71,7 @@ function _diffWords(a,b){ function _buildDiffHtml(a,b){ const secs=[]; - const check=(title,va,vb)=>{if(va!==vb)secs.push({title,html:_diffWords(va||'',vb||'')});}; + const check=(title,va,vb)=>{if(va!==vb)secs.push({title,html:_diffText(va||'',vb||'')});}; check('Professional Summary',a.professional_summary,b.professional_summary); @@ -48,7 +85,7 @@ function _buildDiffHtml(a,b){ const pj=expA[idx]; const txtA=pj?[...(pj.responsibilities||[]),...(pj.achievements||[])].join('\n'):''; const txtB=[...(job.responsibilities||[]),...(job.achievements||[])].join('\n'); - if(txtA!==txtB)secs.push({title:`Experience — ${job.title} @ ${job.company}`,html:_diffWords(txtA,txtB)}); + if(txtA!==txtB)secs.push({title:`Experience — ${job.title} @ ${job.company}`,html:_diffText(txtA,txtB)}); }); const skA=Object.values(a.technical_skills||{}).flat().join(', '); diff --git a/frontend/js/keys.js b/frontend/js/keys.js index faf1f98..c907c0a 100644 --- a/frontend/js/keys.js +++ b/frontend/js/keys.js @@ -31,8 +31,8 @@ async function saveKeys(){ try{ const r=await fetch(`${API}/api/app-settings`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(_collectKeysBody())}); if(!r.ok)throw new Error(await r.text()); + // Keep the Settings panel open after saving — it only closes via Cancel or ✕. showToast('Settings saved'); - closeSettingsModal(); }catch(e){showToast('Save failed: '+e.message);} } async function testLangsmith(){ diff --git a/frontend/js/pdf.js b/frontend/js/pdf.js index c8847ac..1e3ef4b 100644 --- a/frontend/js/pdf.js +++ b/frontend/js/pdf.js @@ -137,7 +137,7 @@ async function updateExportPreview(){ empty.style.display='flex'; } } -function _tmplLabel(t){return {classic_ats:'Classic ATS',modern_clean:'Modern Clean',executive_dark:'Executive',dark_theme:'Dark Theme'}[t]||t;} +function _tmplLabel(t){return {classic_ats:'Classic ATS',modern_clean:'Modern Clean',executive_dark:'Executive',dark_theme:'Dark Theme',latex_serif:'LaTeX'}[t]||t;} async function downloadDoc(){ if(exportKind==='coverletter'){return downloadCoverLetter();} diff --git a/frontend/js/section-prefs.js b/frontend/js/section-prefs.js index 5fd42b1..29a22c8 100644 --- a/frontend/js/section-prefs.js +++ b/frontend/js/section-prefs.js @@ -201,8 +201,8 @@ async function saveSectionPrefs() { body: JSON.stringify(_secPrefsState), }); if (!r.ok) throw new Error(await r.text()); + // Keep the Settings panel open after saving — it only closes via Cancel or ✕. showToast('✓ Section preferences saved'); - closeSettingsModal(); } catch (e) { showToast('⚠ ' + (e.message || 'Save failed')); } diff --git a/frontend/js/settings.js b/frontend/js/settings.js index 34f5bec..334800d 100644 --- a/frontend/js/settings.js +++ b/frontend/js/settings.js @@ -62,7 +62,8 @@ async function saveCfg(){ _modelsByProvider[p]=m; try{localStorage.setItem('sr_models_by_provider',JSON.stringify(_modelsByProvider));}catch(e){} localStorage.setItem('sr_p',p);localStorage.setItem('sr_m',m); - updProvPill();closeCfg();showToast('✓ Settings saved — '+p+' / '+m); + // Keep the Settings panel open after saving — it only closes via Cancel or ✕. + updProvPill();showToast('✓ Settings saved — '+p+' / '+m); // Collect base URLs from the model tab inputs. const _saveBody={models_by_provider:_modelsByProvider}; const _ollamaUrl=(document.getElementById('model-ollama-url')||{}).value||''; diff --git a/frontend/js/sidebar-resize.js b/frontend/js/sidebar-resize.js new file mode 100644 index 0000000..f4d5ad2 --- /dev/null +++ b/frontend/js/sidebar-resize.js @@ -0,0 +1,95 @@ +// ── Sidebar resize ─────────────────────────────────────────────────────────── +// Drag the right edge of the sidebar to resize it. The chosen width is written +// to the --sw CSS variable (which drives both #sb width and min-width) and +// persisted to localStorage. The initial restore happens pre-paint in +// theme-init.js to avoid a width flash on load. +// +// The intended width is tracked in `currentWidth` rather than read back from the +// DOM, so the collapse animation and viewport-driven re-clamps never bake a +// transient/animated width into the variable. +(function () { + const MIN = 200; + const DEFAULT = 268; + const STORE_KEY = 'sr_sidebar_w'; + + // Cap so the main pane never gets squeezed below a usable width, and never + // let the sidebar take more than ~60% of the viewport on small screens. + function maxWidth() { + return Math.max(MIN, Math.min(window.innerWidth - 360, window.innerWidth * 0.6, 600)); + } + function clampWidth(w) { + return Math.round(Math.max(MIN, Math.min(w, maxWidth()))); + } + + function readStored() { + try { + const w = parseInt(localStorage.getItem(STORE_KEY), 10); + return (w && w >= MIN) ? w : DEFAULT; + } catch (e) { return DEFAULT; } + } + + let currentWidth = readStored(); + + function applyWidth(w) { + currentWidth = w; + document.documentElement.style.setProperty('--sw', w + 'px'); + } + + function init() { + const sb = document.getElementById('sb'); + const handle = document.getElementById('sb-resizer'); + if (!sb || !handle) return; + + // Reconcile with whatever theme-init applied pre-paint. + applyWidth(clampWidth(currentWidth)); + + let dragging = false; + + function onMove(e) { + if (!dragging) return; + const left = sb.getBoundingClientRect().left; + applyWidth(clampWidth(e.clientX - left)); + e.preventDefault(); + } + + function onUp() { + if (!dragging) return; + dragging = false; + sb.classList.remove('sb-resizing'); + document.body.classList.remove('sb-resizing'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + try { localStorage.setItem(STORE_KEY, String(currentWidth)); } catch (e) {} + } + + handle.addEventListener('pointerdown', (e) => { + // Left button only; ignore when the sidebar is collapsed. + if (e.button !== 0 || sb.classList.contains('collapsed')) return; + dragging = true; + sb.classList.add('sb-resizing'); + document.body.classList.add('sb-resizing'); + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + e.preventDefault(); + }); + + // Double-click the handle to reset to the default width. + handle.addEventListener('dblclick', () => { + applyWidth(DEFAULT); + try { localStorage.removeItem(STORE_KEY); } catch (e) {} + }); + + // On viewport shrink, re-clamp the *intended* width (never the live/animated + // DOM width) so a wide sidebar can't strand the main pane. + window.addEventListener('resize', () => { + const w = clampWidth(currentWidth); + if (w !== currentWidth) applyWidth(w); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/frontend/js/theme-init.js b/frontend/js/theme-init.js index 823cf4f..b675f7d 100644 --- a/frontend/js/theme-init.js +++ b/frontend/js/theme-init.js @@ -11,4 +11,12 @@ } catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); } + // Restore the saved sidebar width pre-paint so it doesn't flash at the + // default. The drag handler (sidebar-resize.js) maintains this value. + try { + var w = parseInt(localStorage.getItem('sr_sidebar_w'), 10); + if (w && w >= 200 && w <= 600) { + document.documentElement.style.setProperty('--sw', w + 'px'); + } + } catch (e) {} })();