from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer,
Table, TableStyle, HRFlowable
)
import io
W, H = A4
def _styles():
base = getSampleStyleSheet()
return {
"title": ParagraphStyle("title", fontSize=20, fontName="Helvetica-Bold",
spaceAfter=6, textColor=colors.HexColor("#111111")),
"heading": ParagraphStyle("heading", fontSize=13, fontName="Helvetica-Bold",
spaceBefore=14, spaceAfter=4),
"subhead": ParagraphStyle("subhead", fontSize=11, fontName="Helvetica-Bold",
spaceBefore=8, spaceAfter=2),
"body": ParagraphStyle("body", fontSize=10, fontName="Helvetica",
spaceAfter=3, leading=14),
"small": ParagraphStyle("small", fontSize=9, fontName="Helvetica",
textColor=colors.grey),
"positive": ParagraphStyle("positive", fontSize=10, fontName="Helvetica",
textColor=colors.HexColor("#1a6e2e")),
"negative": ParagraphStyle("negative", fontSize=10, fontName="Helvetica",
textColor=colors.HexColor("#8b1a1a")),
}
def _bar_table(label_left, label_right, value, min_val, max_val, avg_val):
BAR_CELLS = 20
pct = min(1.0, max(0.0, (value - min_val) / (max_val - min_val)))
avg_pct = min(1.0, max(0.0, (avg_val - min_val) / (max_val - min_val)))
filled = round(pct * BAR_CELLS)
avg_cell = round(avg_pct * BAR_CELLS)
cell_w = 0.38 * cm
bar_colors = []
for i in range(BAR_CELLS):
if i < filled:
bar_colors.append(colors.HexColor("#185FA5"))
else:
bar_colors.append(colors.HexColor("#e0e0e0"))
cells = [[""] * BAR_CELLS]
style_cmds = [
("ROWBACKGROUNDS", (0, 0), (-1, -1), [colors.white]),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("LEFTPADDING", (0, 0), (-1, -1), 1),
("RIGHTPADDING", (0, 0), (-1, -1), 1),
("ROWHEIGHT", (0, 0), (-1, -1), 10),
]
for i, c in enumerate(bar_colors):
style_cmds.append(("BACKGROUND", (i, 0), (i, 0), c))
if 0 <= avg_cell < BAR_CELLS:
style_cmds.append(("BOX", (avg_cell, 0), (avg_cell, 0),
1, colors.HexColor("#888888")))
bar = Table(cells, colWidths=[cell_w] * BAR_CELLS)
bar.setStyle(TableStyle(style_cmds))
styles = _styles()
wrapper = Table(
[[Paragraph(label_left, styles["small"]), "",
Paragraph(label_right, styles["small"])]],
colWidths=[5 * cm, BAR_CELLS * cell_w - 10 * cm + 5 * cm, 5 * cm]
)
return bar, wrapper
def generate_pdf(features, traits, recs, norms_df) -> bytes:
buf = io.BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4,
leftMargin=2*cm, rightMargin=2*cm,
topMargin=2*cm, bottomMargin=2*cm)
s = _styles()
story = []
story.append(Paragraph("Hairstyle AI Recommender", s["title"]))
story.append(Paragraph("Facial Analysis Report", s["body"]))
story.append(HRFlowable(width="100%", thickness=1,
color=colors.HexColor("#cccccc"), spaceAfter=10))
story.append(Paragraph("Face Analysis", s["heading"]))
for exp in recs["face_analysis"]:
story.append(Paragraph(f"• {exp}", s["body"]))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph("Facial Proportions", s["heading"]))
BARS = [
("face_ratio", "Wide face", "Long face"),
("jaw_ratio", "Narrow jaw", "Wide jaw"),
("eye_ratio", "Close-set eyes", "Wide-set eyes"),
("eye_height", "Narrow eyes", "Wide eyes"),
("lip_ratio", "Narrow lips", "Wide lips"),
("nose_position", "High nose", "Low nose"),
("lower_face_ratio", "Short lower face", "Long lower face"),
("chin_prominence", "Flat chin", "Strong chin"),
("symmetry", "Symmetrical", "Asymmetrical"),
("upper_third", "Low forehead", "High forehead"),
("middle_third", "Short mid face", "Long mid face"),
("lower_third", "Short lower face", "Long lower face"),
]
bar_data = []
for feat, lbl_left, lbl_right in BARS:
if feat not in features:
continue
val = features[feat]
min_val = float(norms_df.loc["p5", feat])
max_val = float(norms_df.loc["p95", feat])
avg_val = float(norms_df.loc["mean", feat])
pct = (val - min_val) / (max_val - min_val) if (max_val - min_val) > 0 else 0.5
pct = min(1.0, max(0.0, pct))
if pct < 0.33:
position_label = lbl_left
pos_color = colors.HexColor("#185FA5")
elif pct > 0.67:
position_label = lbl_right
pos_color = colors.HexColor("#185FA5")
else:
position_label = "Average"
pos_color = colors.HexColor("#444444")
out_of_range = val < min_val or val > max_val
row = [
Paragraph(lbl_left, s["small"]),
Paragraph(
f'{val:.3f}'
f' → {position_label}'
f'
'
f'typical range: {min_val:.3f} – {max_val:.3f}'
f' | avg: {avg_val:.3f}',
s["body"]
),
Paragraph(lbl_right, s["small"]),
]
bar_data.append(row)
bar_table = Table(bar_data, colWidths=[4.5*cm, 8*cm, 4.5*cm])
bar_table.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (0, -1), "RIGHT"),
("ALIGN", (2, 0), (2, -1), "LEFT"),
("ALIGN", (1, 0), (1, -1), "LEFT"),
("ROWBACKGROUNDS",(0, 0), (-1, -1), [colors.HexColor("#f9f9f9"),
colors.white]),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]))
story.append(bar_table)
story.append(Spacer(1, 0.4*cm))
story.append(HRFlowable(width="100%", thickness=1,
color=colors.HexColor("#cccccc"), spaceAfter=6))
story.append(Paragraph("Top Hairstyle Recommendations", s["heading"]))
for i, style in enumerate(recs["top_styles"], 1):
story.append(Paragraph(
f"{i}. {style['name']} — {style['score']:.1f} points",
s["subhead"]
))
if style.get("contributions"):
story.append(Paragraph("Why it works:", s["body"]))
for c in style["contributions"][:3]:
story.append(Paragraph(
f" ✓ {c['desc']} ({c['percent']*100:.0f}%)",
s["positive"]
))
if style.get("negatives"):
story.append(Paragraph("Potential drawbacks:", s["body"]))
for c in style["negatives"][:2]:
story.append(Paragraph(
f" ✗ {c['desc']}",
s["negative"]
))
story.append(Spacer(1, 0.2*cm))
doc.build(story)
return buf.getvalue()