import sys from reportlab.lib.pagesizes import A4 from reportlab.lib.colors import HexColor from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from config import ( USABLE_WIDTH, USABLE_HEIGHT, MARGIN_X_PT, MARGIN_Y_PT, MINIMUM_POLICE_SIZE, COLOR_ACCENT, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_TEXT_MAIN, COLOR_BACKGROUND ) def create_background_and_tags_drawer(tags: str): """Crée la fonction de callback pour dessiner le fond et les tags.""" def draw_bg(canvas, doc): canvas.saveState() # Remplissage du fond canvas.setFillColor(HexColor(COLOR_BACKGROUND)) canvas.rect(0, 0, doc.pagesize[0], doc.pagesize[1], stroke=0, fill=1) # Injection ATS/IA (tag stuffing) if tags: # Même couleur que le fond pour l'invisibilité canvas.setFillColor(HexColor(COLOR_BACKGROUND)) canvas.setFont("Helvetica", 1) # Positionné tout en bas à gauche (x=5, y=5) # Les IA et ATS lisent le flux de texte sans se soucier des retours à la ligne canvas.drawString(5, 5, tags) canvas.restoreState() return draw_bg def create_styles(scale: float): styles = getSampleStyleSheet() title_size = max(14 * scale, 9) heading_size = max(11 * scale, 8) body_size = 9 * scale leading_ratio = 1.25 return { "Name": ParagraphStyle( "NameStyle", fontName="Helvetica-Bold", fontSize=title_size * 1.3, leading=title_size * 1.3 * leading_ratio, textColor=HexColor(COLOR_PRIMARY), spaceAfter=2 * scale ), "JobTitle": ParagraphStyle( "JobTitleStyle", fontName="Helvetica-Bold", fontSize=title_size, leading=title_size * leading_ratio, textColor=HexColor(COLOR_SECONDARY), spaceAfter=2 * scale ), "Keywords": ParagraphStyle( # <-- Nouveau style pour les mots-clefs "KeywordsStyle", fontName="Helvetica-Bold", fontSize=body_size, leading=body_size * leading_ratio, textColor=HexColor(COLOR_ACCENT), spaceAfter=4 * scale ), "SectionHeading": ParagraphStyle( "SectionHeadingStyle", fontName="Helvetica-Bold", fontSize=heading_size, leading=heading_size * leading_ratio, textColor=HexColor(COLOR_PRIMARY), spaceBefore=6 * scale, spaceAfter=2 * scale ), "Body": ParagraphStyle( "BodyStyle", fontName="Helvetica", fontSize=body_size, leading=body_size * leading_ratio, textColor=HexColor(COLOR_TEXT_MAIN), spaceAfter=2 * scale ), "SubText": ParagraphStyle( "SubTextStyle", fontName="Helvetica-Oblique", fontSize=max(body_size * 0.9, 6), leading=max(body_size * 0.9, 6) * leading_ratio, textColor=HexColor(COLOR_TEXT_MAIN) ), "body_font_size": body_size } def build_story(data: dict, styles: dict, scale: float): story = [] id_data = data["identity"] context = data["context"] links = data["hyperlinks"] # 1. Nom & Titre full_name = f"{id_data.get('first_name', '')} {id_data.get('last_name', '')}" story.append(Paragraph(full_name, styles["Name"])) story.append(Paragraph(context.get("title", ""), styles["JobTitle"])) # NOUVEAU : Mots-clefs sous le titre keywords = id_data.get("keywords", []) if keywords: kw_line = " • ".join(keywords) story.append(Paragraph(kw_line, styles["Keywords"])) # 2. Infos contact & liens contact_bits = [id_data.get("email"), id_data.get("phone"), id_data.get("location")] for k, v in links.items(): contact_bits.append(f'{k}') contact_line = " • ".join(filter(None, contact_bits)) story.append(Paragraph(contact_line, styles["SubText"])) story.append(Spacer(1, 4 * scale)) story.append(HRFlowable(width="100%", thickness=1, color=HexColor(COLOR_SECONDARY), spaceAfter=6 * scale)) # 3. Résumé (Profil) if context.get("summary"): story.append(Paragraph("PROFIL", styles["SectionHeading"])) story.append(Paragraph(context["summary"], styles["Body"])) # 4. Expériences (Modifiées) if data.get("experiences"): story.append(Paragraph("EXPÉRIENCES PROFESSIONNELLES", styles["SectionHeading"])) for exp in data["experiences"]: # Ligne 1 : Rôle et Dates header = f"{exp.get('role', '')} ({exp.get('dates', '')})" story.append(Paragraph(header, styles["Body"])) # Ligne 2 : Entreprise (Nouvelle ligne) if exp.get("company"): story.append(Paragraph(f"Entreprise : {exp.get('company')}", styles["Body"])) # Ligne 3 et + : Détails for detail in exp.get("details", []): story.append(Paragraph(f"• {detail}", styles["Body"])) # 5. Langues if data.get("languages"): story.append(Paragraph("LANGUES", styles["SectionHeading"])) langs = [f"{k}: {v}" for k, v in data["languages"].items()] story.append(Paragraph(" • ".join(langs), styles["Body"])) # 6. Diplômes (NOUVEAU) if data.get("diplomas"): story.append(Paragraph("DIPLÔMES", styles["SectionHeading"])) for dip in data["diplomas"]: dip_text = f"{dip.get('degree', '')} - {dip.get('school', '')} ({dip.get('year', '')})" story.append(Paragraph(dip_text, styles["Body"])) # 7. Centres d'intérêt if data.get("hobbies"): story.append(Paragraph("CENTRES D'INTÉRÊT", styles["SectionHeading"])) story.append(Paragraph(" • ".join(data["hobbies"]), styles["Body"])) return story def measure_story_height(story: list) -> float: """Calcule la hauteur totale cumulée du contenu, espacements inclus.""" total_height = 0 for item in story: _, h = item.wrap(USABLE_WIDTH, USABLE_HEIGHT) # Récupération des espacements spécifiques à l'élément ou à son style space_before = getattr(item, 'spaceBefore', 0) space_after = getattr(item, 'spaceAfter', 0) if hasattr(item, 'style'): space_before = max(space_before, getattr(item.style, 'spaceBefore', 0)) space_after = max(space_after, getattr(item.style, 'spaceAfter', 0)) total_height += h + space_before + space_after return total_height def generate_pdf(data: dict, tags: str, output_path: str): scale = 1.0 min_scale = 0.3 step = 0.02 # Pas d'ajustement plus fin # Marge de sécurité de 2% pour parer aux arrondis de moteur de rendu PDF usable_height_safe = USABLE_HEIGHT * 0.98 while scale >= min_scale: styles = create_styles(scale) story = build_story(data, styles, scale) height = measure_story_height(story) if height <= usable_height_safe: break scale -= step critical_font_size = styles["body_font_size"] # Vérification de la taille minimale de police if critical_font_size < MINIMUM_POLICE_SIZE: print(f"\n⚠️ [ALERTE TAILLE POLICE]") print(f"La taille de police nécessaire pour faire tenir le CV sur 1 page ({critical_font_size:.1f}pt) " f"est sous le seuil configuré ({MINIMUM_POLICE_SIZE}pt).") choice = input("Voulez-vous quand même générer le fichier ? (o/N) : ").strip().lower() if choice not in ['o', 'oui']: print("Génération annulée par l'utilisateur.") sys.exit(0) doc = SimpleDocTemplate( output_path, pagesize=A4, leftMargin=MARGIN_X_PT, rightMargin=MARGIN_X_PT, topMargin=MARGIN_Y_PT, bottomMargin=MARGIN_Y_PT ) bg_drawer = create_background_and_tags_drawer(tags) doc.build(story, onFirstPage=bg_drawer, onLaterPages=bg_drawer) print(f"✅ CV généré sur 1 seule page avec succès : {output_path}")