#!/usr/bin/env python3 """ Modern PNG Banner Generator for Devour Scorecards Creates beautiful dark-themed banners with proper typography and data visualization Consistent with Go implementation design patterns """ import os import sys from PIL import Image, ImageDraw, ImageFont import math import argparse class ModernBannerGenerator: def __init__(self, data): self.data = data # Devour brand colors - consistent with Go theme self.colors = { # Dark theme backgrounds 'bg_start': (15, 23, 42), # #0f172a - deep slate 'bg_end': (30, 41, 59), # #1e293b - elevated surface 'card': (30, 41, 59), # #1e293b - card surface 'card_alt': (51, 65, 85), # #334155 - alternate card 'border': (71, 85, 105), # #475569 - subtle border 'border_subtle': (51, 65, 85), # #334155 - subtle border # Text colors 'text': (248, 250, 252), # #f8f9fc - pure white 'text_muted': (148, 163, 184), # #94a3b8 - soft gray 'text_dim': (100, 116, 139), # #64748b - dimmed gray # Devour brand accent colors 'orange': (251, 146, 60), # #fb923c - devour orange 'red': (239, 68, 68), # #ef4444 - bright red 'yellow': (251, 191, 36), # #fbbf24 - bright yellow 'amber': (251, 191, 36), # #fbbf24 - amber # Score grade colors - vibrant for dark theme 'score_a': (52, 211, 153), # #34d399 - bright emerald 'score_b': (34, 211, 238), # #22d3ee - bright cyan 'score_c': (251, 191, 36), # #fbbf24 - bright amber 'score_d': (251, 146, 60), # #fb923c - bright orange 'score_f': (248, 113, 113), # #f87171 - bright red # Muted score colors for secondary metrics 'score_a_muted': (74, 222, 128), # #4ade80 - muted emerald 'score_b_muted': (125, 185, 255), # #7db9ff - muted cyan 'score_c_muted': (217, 119, 6), # #d97706 - muted amber 'score_d_muted': (234, 88, 12), # #ea580c - muted orange 'score_f_muted': (220, 38, 38), # #dc2626 - muted red # Severity colors - high contrast 'severity_t1': (96, 165, 250), # #60a5fa - bright blue 'severity_t2': (251, 191, 36), # #fbbf24 - bright amber 'severity_t3': (251, 146, 60), # #fb923c - bright orange 'severity_t4': (248, 113, 113), # #f87171 - bright red } def get_score_color(self, score, muted=False): if score >= 90: return self.colors['score_a_muted'] if muted else self.colors['score_a'] elif score >= 70: return self.colors['score_b_muted'] if muted else self.colors['score_b'] elif score >= 50: return self.colors['score_c_muted'] if muted else self.colors['score_c'] elif score >= 30: return self.colors['score_d_muted'] if muted else self.colors['score_d'] else: return self.colors['score_f_muted'] if muted else self.colors['score_f'] def get_grade_color(self, grade, muted=False): grade_scores = {'A': 95, 'B': 80, 'C': 60, 'D': 40, 'F': 20} score = grade_scores.get(grade.upper(), 50) return self.get_score_color(score, muted) def get_grade_score(self, grade): """Convert grade to numeric score for visualization""" grade_scores = {'A': 95, 'B': 80, 'C': 60, 'D': 40, 'F': 20} return grade_scores.get(grade.upper(), 50) def draw_gradient_background(self, img, width, height): """Draw gradient background""" for y in range(height): ratio = y / height r = int(self.colors['bg_start'][0] * (1 - ratio) + self.colors['bg_end'][0] * ratio) g = int(self.colors['bg_start'][1] * (1 - ratio) + self.colors['bg_end'][1] * ratio) b = int(self.colors['bg_start'][2] * (1 - ratio) + self.colors['bg_end'][2] * ratio) for x in range(width): img.putpixel((x, y), (r, g, b)) def draw_glass_card(self, draw, x, y, width, height, border_radius=12, use_alt=False): """Draw glass morphism card with enhanced effects""" card_color = self.colors['card_alt'] if use_alt else self.colors['card'] # Draw rounded rectangle with shadow effect draw.rounded_rectangle( [(x+2, y+2), (x + width+2, y + height+2)], border_radius, fill=(0, 0, 0, 50) # Subtle shadow ) # Main card draw.rounded_rectangle( [(x, y), (x + width, y + height)], border_radius, fill=(*card_color, 240), outline=(*self.colors['border'], 180), width=1 ) # Add subtle gradient overlay for depth overlay_height = height // 3 for py in range(overlay_height): alpha = int(15 * (1 - py / overlay_height)) for px in range(width): if px > border_radius and px < width - border_radius: try: r, g, b, a = draw.im.getpixel((x + px, y + py)) draw.im.putpixel((x + px, y + py), (r, g, b, min(a + alpha, 255))) except: pass def draw_score_circle(self, draw, cx, cy, radius, score, label="OVERALL", is_primary=True): """Draw enhanced circular score visualization""" # Background circle with subtle border draw.ellipse([(cx-radius-2, cy-radius-2), (cx+radius+2, cy+radius+2)], fill=(*self.colors['border'], 100)) draw.ellipse([(cx-radius, cy-radius), (cx+radius, cy+radius)], fill=self.colors['card'], outline=self.colors['border']) # Progress arc with enhanced styling if score > 0: score_color = self.get_score_color(score, muted=not is_primary) percentage = score / 100.0 # Draw background arc draw.arc([(cx-radius+4, cy-radius+4), (cx+radius-4, cy+radius-4)], -90, 270, fill=self.colors['border_subtle'], width=6) # Draw progress arc start_angle = -90 end_angle = start_angle + (360 * percentage) arc_width = 8 if is_primary else 6 draw.arc([(cx-radius+4, cy-radius+4), (cx+radius-4, cy+radius-4)], start_angle, end_angle, fill=score_color, width=arc_width) # Enhanced typography try: font_large = ImageFont.truetype("arial.ttf", 32 if is_primary else 28) font_small = ImageFont.truetype("arial.ttf", 11) except: font_large = ImageFont.load_default() font_small = ImageFont.load_default() # Score text score_text = f"{int(score)}%" bbox = draw.textbbox((0, 0), score_text, font=font_large) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] text_color = self.colors['text'] if is_primary else self.colors['text_muted'] draw.text((cx - text_width//2, cy - text_height//2 - 2), score_text, fill=text_color, font=font_large) # Label label_bbox = draw.textbbox((0, 0), label, font=font_small) label_width = label_bbox[2] - label_bbox[0] draw.text((cx - label_width//2, cy + radius + 15), label, fill=self.colors['text_dim'], font=font_small) def draw_grade_badge(self, draw, x, y, grade): """Draw enhanced grade badge""" grade_color = self.get_grade_color(grade) # Badge background with shadow badge_width, badge_height = 55, 28 # Shadow draw.rounded_rectangle([(x+2, y+2), (x + badge_width+2, y + badge_height+2)], 6, fill=(0, 0, 0, 60)) # Main badge draw.rounded_rectangle([(x, y), (x + badge_width, y + badge_height)], 6, fill=grade_color, outline=self.colors['border']) # Grade text with better typography try: font = ImageFont.truetype("arial.ttf", 18) except: font = ImageFont.load_default() bbox = draw.textbbox((0, 0), grade, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] draw.text((x + badge_width//2 - text_width//2, y + badge_height//2 - text_height//2 + 1), grade, fill=(255, 255, 255), font=font) def draw_text(self, draw, text, x, y, size=14, color=None, centered=False): """Draw enhanced text with better typography""" if color is None: color = self.colors['text'] try: font = ImageFont.truetype("arial.ttf", size) except: font = ImageFont.load_default() if centered: bbox = draw.textbbox((0, 0), text, font=font) text_width = bbox[2] - bbox[0] x = x - text_width // 2 draw.text((x, y), text, fill=color, font=font) def draw_metric_card(self, draw, x, y, width, height, title, value, color): """Draw metric card""" self.draw_glass_card(draw, x, y, width, height) # Title self.draw_text(draw, title, x + 15, y + 15, size=12, color=self.colors['text_muted']) # Value self.draw_text(draw, value, x + 15, y + 40, size=20, color=color) def draw_severity_bars(self, draw, x, y, width, height, find_by_tier): """Draw enhanced severity bars""" tiers = [ ("T1", "Auto-fixable", self.colors['severity_t1']), ("T2", "Quick Manual", self.colors['severity_t2']), ("T3", "Needs Judgment", self.colors['severity_t3']), ("T4", "Major Refactor", self.colors['severity_t4']), ] bar_width = (width - 40) // len(tiers) max_count = max(find_by_tier.values()) if find_by_tier else 1 for i, (tier, label, color) in enumerate(tiers): bar_x = x + 20 + i * (bar_width + 10) count = find_by_tier.get(tier, 0) # Bar background with rounded effect bg_height = height - 35 draw.rounded_rectangle([(bar_x, y), (bar_x + bar_width, y + bg_height)], 4, fill=self.colors['card'], outline=self.colors['border_subtle']) # Bar fill with gradient effect if count > 0: fill_height = int(bg_height * (count / max_count)) fill_y = y + bg_height - fill_height # Main fill draw.rounded_rectangle([(bar_x, fill_y), (bar_x + bar_width, y + bg_height)], 4, fill=color) # Highlight at top if fill_height > 4: draw.rectangle([(bar_x, fill_y), (bar_x + bar_width, fill_y + 2)], fill=tuple(min(c + 30, 255) for c in color)) # Enhanced label self.draw_text(draw, f"{tier}", bar_x + bar_width//2, y + height - 30, size=10, color=self.colors['text_muted'], centered=True) self.draw_text(draw, f"{count}", bar_x + bar_width//2, y + height - 18, size=9, color=self.colors['text_dim'], centered=True) def generate_compact_banner(self, output_path): """Generate compact banner with enhanced design""" width, height = 1200, 400 # Consistent with Go implementation img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Enhanced gradient background self.draw_gradient_background(img, width, height) # Main content area with better spacing content_x, content_y = 60, 60 content_width, content_height = width - 120, height - 120 # Enhanced glass card self.draw_glass_card(draw, content_x, content_y, content_width, content_height, border_radius=16) # Three-circle layout matching Go implementation circle_radius = 70 circle_y = content_y + content_height // 2 circle_spacing = 40 total_circles_width = 3*circle_radius*2 + 2*circle_spacing start_x = content_x + (content_width-total_circles_width)//2 + circle_radius # Circle 1: Overall Score (primary) self.draw_score_circle(draw, start_x, circle_y, circle_radius, self.data['overall_score'], "OVERALL", is_primary=True) # Circle 2: Strict Score (secondary) x2 = start_x + circle_radius*2 + circle_spacing self.draw_score_circle(draw, x2, circle_y, circle_radius, self.data['strict_score'], "STRICT", is_primary=False) # Circle 3: Grade (special) x3 = x2 + circle_radius*2 + circle_spacing grade_score = self.get_grade_score(self.data['grade']) self.draw_score_circle(draw, x3, circle_y, circle_radius, grade_score, self.data['grade'], is_primary=True) # Grade badge positioned above circle 3 self.draw_grade_badge(draw, x3 - 27, circle_y - circle_radius - 20, self.data['grade']) # Enhanced header section header_y = content_y + 20 self.draw_text(draw, "DEVOUR SCORE", content_x + content_width//2, header_y, size=20, color=self.colors['text'], centered=True) # Project info project_name = self.data['project_name'] version_text = f"v{self.data['version']}" if self.data['version'] else "latest" project_text = f"{project_name} {version_text}" self.draw_text(draw, project_text, content_x + content_width//2, header_y + 25, size=14, color=self.colors['text_muted'], centered=True) # Timestamp time_text = self.data.get('timestamp', 'Today') self.draw_text(draw, time_text, content_x + content_width//2, content_y + content_height - 25, size=11, color=self.colors['text_dim'], centered=True) # Enhanced findings section findings_y = circle_y + circle_radius + 50 findings_bg_height = content_height - (findings_y - content_y) - 20 if findings_bg_height > 0: self.draw_glass_card(draw, content_x + 20, findings_y, content_width - 40, findings_bg_height, border_radius=8, use_alt=True) # Findings metrics in 3 columns findings_total = self.data['total_findings'] findings_open = self.data['open_findings'] findings_closed = findings_total - findings_open col_width = (content_width - 80) // 3 col_x = content_x + 40 metrics_y = findings_y + 15 # Total findings self.draw_text(draw, str(findings_total), col_x + col_width//2, metrics_y, size=18, color=self.colors['text'], centered=True) self.draw_text(draw, "TOTAL", col_x + col_width//2, metrics_y + 22, size=10, color=self.colors['text_muted'], centered=True) # Open findings self.draw_text(draw, str(findings_open), col_x + col_width + col_width//2, metrics_y, size=18, color=self.colors['orange'], centered=True) self.draw_text(draw, "OPEN", col_x + col_width + col_width//2, metrics_y + 22, size=10, color=self.colors['text_muted'], centered=True) # Resolved findings self.draw_text(draw, str(findings_closed), col_x + 2*col_width + col_width//2, metrics_y, size=18, color=self.colors['score_a'], centered=True) self.draw_text(draw, "RESOLVED", col_x + 2*col_width + col_width//2, metrics_y + 22, size=10, color=self.colors['text_muted'], centered=True) # Save image with high quality img.save(output_path, "PNG", optimize=True) return output_path def generate_detailed_banner(self, output_path): """Generate detailed banner with 3-grid data layout""" width, height = 1400, 600 # Slightly taller for grid layout img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Enhanced gradient background self.draw_gradient_background(img, width, height) # Header section header_y = 30 self.draw_text(draw, f"{self.data['project_name']} Quality Report", width//2, header_y, size=28, color=self.colors['text'], centered=True) version_text = f"v{self.data['version']}" if self.data['version'] else "latest" self.draw_text(draw, version_text, width//2, header_y + 35, size=16, color=self.colors['text_muted'], centered=True) # Main score section - single prominent score score_section_y = header_y + 80 score_x, score_y = 150, score_section_y + 60 # Large overall score circle self.draw_score_circle(draw, score_x, score_y, 70, self.data['overall_score'], "OVERALL", is_primary=True) # Grade badge above score circle self.draw_grade_badge(draw, score_x - 25, score_y - 55, self.data['grade']) # Score details score_details_y = score_y + 100 self.draw_text(draw, f"Overall: {int(self.data['overall_score'])}%", score_x, score_details_y, size=20, color=self.get_score_color(self.data['overall_score']), centered=True) self.draw_text(draw, f"Strict: {int(self.data['strict_score'])}%", score_x, score_details_y + 25, size=16, color=self.get_score_color(self.data['strict_score'], muted=True), centered=True) # Three-column grid for detailed data grid_start_y = header_y + 80 grid_start_x = 350 grid_width = 1000 grid_height = 450 col_width = 320 col_spacing = 20 # Column 1: Score Breakdown col1_x = grid_start_x self.draw_glass_card(draw, col1_x, grid_start_y, col_width, grid_height, border_radius=12) # Column 1 Header self.draw_text(draw, "Score Breakdown", col1_x + col_width//2, grid_start_y + 20, size=18, color=self.colors['text'], centered=True) # Column 1 Data score_data = [ ("Overall Score", f"{int(self.data['overall_score'])}%", self.get_score_color(self.data['overall_score'])), ("Strict Score", f"{int(self.data['strict_score'])}%", self.get_score_color(self.data['strict_score'], muted=True)), ("Grade", self.data['grade'], self.get_grade_color(self.data['grade'])), ] data_y = grid_start_y + 60 for label, value, color in score_data: # Draw metric card self.draw_glass_card(draw, col1_x + 10, data_y, col_width - 20, 70, border_radius=8, use_alt=True) # Label self.draw_text(draw, label, col1_x + 20, data_y + 10, size=12, color=self.colors['text_muted']) # Value self.draw_text(draw, value, col1_x + col_width//2, data_y + 35, size=24, color=color, centered=True) data_y += 80 # Column 2: Findings by Type col2_x = col1_x + col_width + col_spacing self.draw_glass_card(draw, col2_x, grid_start_y, col_width, grid_height, border_radius=12) # Column 2 Header self.draw_text(draw, "Findings by Type", col2_x + col_width//2, grid_start_y + 20, size=18, color=self.colors['text'], centered=True) # Column 2 Data - Top finding types type_data_y = grid_start_y + 60 type_items = list(self.data['find_by_type'].items())[:6] # Top 6 types for issue_type, count in type_items: # Type bar bar_width = int((col_width - 40) * (count / max(self.data['find_by_type'].values()))) bar_height = 22 # Bar background draw.rounded_rectangle([(col2_x + 20, type_data_y), (col2_x + col_width - 20, type_data_y + bar_height)], 4, fill=self.colors['card'], outline=self.colors['border_subtle']) # Bar fill draw.rounded_rectangle([(col2_x + 20, type_data_y), (col2_x + 20 + bar_width, type_data_y + bar_height)], 4, fill=self.colors['orange']) # Type label label_text = f"{issue_type}" if len(label_text) > 20: label_text = label_text[:17] + "..." self.draw_text(draw, label_text, col2_x + 25, type_data_y + 2, size=11, color=self.colors['text_muted']) # Count self.draw_text(draw, f"({count})", col2_x + col_width - 35, type_data_y + 2, size=10, color=self.colors['text_dim']) type_data_y += bar_height + 12 # Column 3: Findings by Severity col3_x = col2_x + col_width + col_spacing self.draw_glass_card(draw, col3_x, grid_start_y, col_width, grid_height, border_radius=12) # Column 3 Header self.draw_text(draw, "Issues by Severity", col3_x + col_width//2, grid_start_y + 20, size=18, color=self.colors['text'], centered=True) # Column 3 Data - Severity breakdown severity_data_y = grid_start_y + 60 severity_items = [ ("Critical (T4)", self.data['find_by_tier'].get('T4', 0), self.colors['score_f']), ("High (T3)", self.data['find_by_tier'].get('T3', 0), self.colors['score_d']), ("Medium (T2)", self.data['find_by_tier'].get('T2', 0), self.colors['score_c']), ("Low (T1)", self.data['find_by_tier'].get('T1', 0), self.colors['score_a']), ] for severity_name, count, color in severity_items: # Severity card self.draw_glass_card(draw, col3_x + 10, severity_data_y, col_width - 20, 60, border_radius=8, use_alt=True) # Severity indicator indicator_size = 12 draw.ellipse([(col3_x + 25, severity_data_y + 24), (col3_x + 25 + indicator_size, severity_data_y + 24 + indicator_size)], fill=color) # Severity name self.draw_text(draw, severity_name, col3_x + 50, severity_data_y + 15, size=14, color=self.colors['text']) # Count self.draw_text(draw, f"{count} issues", col3_x + 50, severity_data_y + 35, size=16, color=color) severity_data_y += 70 # Summary metrics at bottom summary_y = grid_start_y + grid_height + 20 summary_metrics = [ ("Total Findings", str(self.data['total_findings']), self.colors['text']), ("Open Issues", str(self.data['open_findings']), self.colors['orange']), ("Resolved", str(self.data['total_findings'] - self.data['open_findings']), self.colors['score_a']), ] metrics_width = 200 metrics_spacing = 30 total_metrics_width = len(summary_metrics) * metrics_width + (len(summary_metrics) - 1) * metrics_spacing summary_start_x = (width - total_metrics_width) // 2 for i, (label, value, color) in enumerate(summary_metrics): metric_x = summary_start_x + i * (metrics_width + metrics_spacing) # Summary card self.draw_glass_card(draw, metric_x, summary_y, metrics_width, 50, border_radius=8, use_alt=True) # Value self.draw_text(draw, value, metric_x + metrics_width//2, summary_y + 10, size=18, color=color, centered=True) # Label self.draw_text(draw, label, metric_x + metrics_width//2, summary_y + 30, size=10, color=self.colors['text_muted'], centered=True) # Footer with timestamp footer_y = height - 20 time_text = self.data.get('timestamp', 'Generated today') self.draw_text(draw, time_text, width//2, footer_y, size=10, color=self.colors['text_dim'], centered=True) # Save image with high quality img.save(output_path, "PNG", optimize=True) return output_path def main(): # Enhanced sample data matching Go implementation from datetime import datetime data = { 'project_name': 'Devour', 'version': '2.0.0', 'overall_score': 65.0, 'strict_score': 62.0, 'grade': 'C', 'total_findings': 7, 'open_findings': 7, 'timestamp': datetime.now().strftime('%B %d, %Y'), 'find_by_tier': {'T1': 1, 'T2': 3, 'T3': 1, 'T4': 2}, 'find_by_type': { 'complexity': 1, 'naming': 1, 'duplication': 1, 'security': 1, 'unused_import': 1, 'dead_code': 1, 'god_component': 1 } } parser = argparse.ArgumentParser(description='Generate modern PNG scorecard banners') parser.add_argument('--compact', action='store_true', help='Generate compact banner') parser.add_argument('--detailed', action='store_true', help='Generate detailed banner') parser.add_argument('--output', default='lighthouse_scorecard.png', help='Output filename') parser.add_argument('--dark-only', action='store_true', default=True, help='Generate dark theme only (default)') args = parser.parse_args() generator = ModernBannerGenerator(data) if args.compact: output_path = args.output.replace('.png', '_compact_dark.png') generator.generate_compact_banner(output_path) print(f"āœ“ Generated {output_path}") elif args.detailed: output_path = args.output.replace('.png', '_detailed_dark.png') generator.generate_detailed_banner(output_path) print(f"āœ“ Generated {output_path}") else: # Generate both by default with dark theme compact_path = args.output.replace('.png', '_compact_dark.png') detailed_path = args.output.replace('.png', '_detailed_dark.png') generator.generate_compact_banner(compact_path) generator.generate_detailed_banner(detailed_path) print(f"āœ“ Generated {compact_path}") print(f"āœ“ Generated {detailed_path}") print(f"\nšŸŽØ Enhanced Dark Theme Scorecards") print(f"šŸ“Š Project: {data['project_name']} v{data['version']}") print(f"⚔ Overall Score: {data['overall_score']:.0f}% ({data['grade']})") print(f"šŸ”’ Strict Score: {data['strict_score']:.0f}%") print(f"šŸ“‹ Total Findings: {data['total_findings']} ({data['open_findings']} open)") print(f"šŸŽØ Features: Modern dark theme, enhanced typography, glass morphism effects") if __name__ == "__main__": main()