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()