This commit is contained in:
Tomas Dvorak
2026-02-22 15:41:27 +01:00
parent 0b88627e54
commit 409acd2e08
84 changed files with 65382 additions and 27475 deletions
+614
View File
@@ -0,0 +1,614 @@
#!/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()