194 lines
7.9 KiB
Python
194 lines
7.9 KiB
Python
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'<a href="{v}"><u>{k}</u></a>')
|
|
|
|
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"<b>{exp.get('role', '')}</b> ({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"<b>{k}:</b> {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"<b>{dip.get('degree', '')}</b> - {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}") |