"""规范简历 DOCX 导出:照片 + 基本信息表 + 分节正文(关键词高亮)+ 可选匹配诊断。 完全离线,仅依赖 python-docx。返回 bytes,由 API 层以文件流下发。 """ from __future__ import annotations import base64 import io import re from docx import Document from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.ns import qn from docx.shared import Cm, Pt, RGBColor from app.models import ResumeProfile _GREEN = RGBColor(0x00, 0xB4, 0x2A) # 命中必备 _BLUE = RGBColor(0x2F, 0x54, 0xEB) # 命中加分 _GRAY = RGBColor(0x4E, 0x59, 0x69) # 其他技能 _DARK = RGBColor(0x1D, 0x21, 0x29) def _set_cn_font(doc: Document) -> None: st = doc.styles["Normal"] st.font.name = "Times New Roman" st.font.size = Pt(10.5) st.element.rPr.rFonts.set(qn("w:eastAsia"), "宋体") def _highlight_runs(paragraph, text: str, cls_of: dict[str, RGBColor]) -> None: """按技能清单把 text 切段写入段落,命中技能加粗上色。""" if not cls_of: paragraph.add_run(text) return names = sorted(cls_of, key=len, reverse=True) pat = re.compile("|".join(re.escape(n) for n in names), re.IGNORECASE) pos = 0 for m in pat.finditer(text): if m.start() > pos: paragraph.add_run(text[pos:m.start()]) hit = m.group(0) run = paragraph.add_run(hit) run.bold = True run.font.color.rgb = cls_of.get(hit.lower(), _DARK) pos = m.end() if pos < len(text): paragraph.add_run(text[pos:]) def build_resume_docx(profile: ResumeProfile, match: dict | None = None) -> bytes: """把脱敏简历画像渲染为规范 DOCX,返回文件 bytes。""" doc = Document() _set_cn_font(doc) title = doc.add_paragraph() title.alignment = WD_ALIGN_PARAGRAPH.CENTER trun = title.add_run("个 人 简 历") trun.bold = True trun.font.size = Pt(20) sub = doc.add_paragraph() sub.alignment = WD_ALIGN_PARAGRAPH.CENTER srun = sub.add_run("(CareerNexus 职联 · 结构化解析导出,已自动脱敏)") srun.font.size = Pt(9) srun.font.color.rgb = _GRAY # ---- 头部:左照片 + 右基本信息表 ---- head = doc.add_table(rows=1, cols=2) head.style = "Table Grid" head.columns[0].width = Cm(3.4) head.columns[1].width = Cm(13.4) photo_cell, info_cell = head.cell(0, 0), head.cell(0, 1) if profile.photo_b64: try: raw = base64.b64decode(profile.photo_b64.split(",", 1)[1]) photo_cell.paragraphs[0].add_run().add_picture(io.BytesIO(raw), width=Cm(3.0)) except Exception: photo_cell.paragraphs[0].add_run("(照片解析失败)") else: photo_cell.paragraphs[0].add_run("(未提供照片)") info_items = [ ("姓名", profile.name or "—"), ("性别", profile.gender or "—"), ("学历", profile.education or "—"), ("工作年限", f"{profile.years:g} 年" if profile.years else "—"), ("手机", profile.phone or "—"), ("邮箱", profile.email or "—"), ("家庭住址", profile.address or "—"), ] info_table = info_cell.add_table(rows=(len(info_items) + 1) // 2, cols=2) for i, (k, v) in enumerate(info_items): cell = info_table.cell(i // 2, i % 2) p = cell.paragraphs[0] kr = p.add_run(f"{k}:") kr.bold = True p.add_run(v) # 技能高亮类别(小写键) cls_of: dict[str, RGBColor] = {} if match: for s in match.get("matched_required", []): cls_of[s.lower()] = _GREEN for s in match.get("matched_preferred", []): cls_of[s.lower()] = _BLUE for s in profile.skills: cls_of.setdefault(s.lower(), _GRAY) # ---- 分节正文(关键词标注) ---- for sec in profile.sections or [{"title": "简历全文", "text": profile.raw_masked_text}]: h = doc.add_paragraph() hrun = h.add_run(f"▍{sec['title']}") hrun.bold = True hrun.font.size = Pt(13) hrun.font.color.rgb = _BLUE _highlight_runs(doc.add_paragraph(), sec["text"], cls_of) # ---- 匹配诊断摘要(可选) ---- if match and match.get("job_title"): h = doc.add_paragraph() hrun = h.add_run("▍人岗匹配诊断(CareerNexus)") hrun.bold = True hrun.font.size = Pt(13) hrun.font.color.rgb = _BLUE for line in [ f"目标岗位:{match['job_title']} 综合匹配分:{match.get('total_score', 0)}", f"命中必备技能:{'、'.join(match.get('matched_required', [])) or '无'}", f"命中加分技能:{'、'.join(match.get('matched_preferred', [])) or '无'}", f"缺失技能:{'、'.join((match.get('missing_required', []) + match.get('missing_preferred', []))) or '无'}", ]: doc.add_paragraph(line) buf = io.BytesIO() doc.save(buf) return buf.getvalue()