mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-04 04:23:02 +00:00
703 lines
31 KiB
Python
703 lines
31 KiB
Python
#!/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
|
|
self.fonts = self._init_fonts()
|
|
|
|
# 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 _init_fonts(self):
|
|
"""Initialize font candidates and cache."""
|
|
# Prefer widely-available fonts on Linux/macOS/Windows.
|
|
font_candidates = {
|
|
"regular": [
|
|
"arial.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
|
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
|
"/Library/Fonts/Arial.ttf",
|
|
],
|
|
"bold": [
|
|
"arialbd.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
|
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
|
"/Library/Fonts/Arial Bold.ttf",
|
|
],
|
|
}
|
|
|
|
return {
|
|
"candidates": font_candidates,
|
|
"cache": {},
|
|
}
|
|
|
|
def get_font(self, size, weight="regular"):
|
|
"""Get a cached font or fall back to the default."""
|
|
key = (size, weight)
|
|
if key in self.fonts["cache"]:
|
|
return self.fonts["cache"][key]
|
|
|
|
for path in self.fonts["candidates"].get(weight, []):
|
|
try:
|
|
font = ImageFont.truetype(path, size)
|
|
self.fonts["cache"][key] = font
|
|
return font
|
|
except:
|
|
continue
|
|
|
|
font = ImageFont.load_default()
|
|
self.fonts["cache"][key] = font
|
|
return font
|
|
|
|
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))
|
|
|
|
# Add subtle radial glows for depth
|
|
self.draw_glow(img, width * 0.15, height * 0.2, 220, (71, 85, 105), 40)
|
|
self.draw_glow(img, width * 0.85, height * 0.75, 260, (251, 146, 60), 35)
|
|
|
|
def draw_glow(self, img, cx, cy, radius, color, max_alpha):
|
|
"""Draw a soft radial glow."""
|
|
draw = ImageDraw.Draw(img)
|
|
steps = 12
|
|
for i in range(steps):
|
|
r = radius - (radius * i / steps)
|
|
alpha = int(max_alpha * (1 - i / steps))
|
|
draw.ellipse(
|
|
[(cx - r, cy - r), (cx + r, cy + r)],
|
|
fill=(*color, alpha),
|
|
)
|
|
|
|
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 = 9 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)
|
|
|
|
# Inner glow ring
|
|
if is_primary:
|
|
draw.arc([(cx-radius+10, cy-radius+10), (cx+radius-10, cy+radius-10)],
|
|
start_angle, end_angle, fill=score_color, width=2)
|
|
|
|
# Enhanced typography
|
|
font_large = self.get_font(34 if is_primary else 28, weight="bold")
|
|
font_small = self.get_font(11, weight="regular")
|
|
|
|
# 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
|
|
font = self.get_font(18, weight="bold")
|
|
|
|
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, max_width=None, min_size=9, weight="regular"):
|
|
"""Draw enhanced text with better typography"""
|
|
if color is None:
|
|
color = self.colors['text']
|
|
|
|
font = self.get_font(size, weight=weight)
|
|
if max_width is not None:
|
|
font = self.fit_font(draw, text, font, max_width, min_size=min_size, weight=weight)
|
|
|
|
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 fit_font(self, draw, text, font, max_width, min_size=9, weight="regular"):
|
|
"""Shrink font until text fits max width."""
|
|
if font == ImageFont.load_default():
|
|
return font
|
|
size = font.size if hasattr(font, "size") else min_size
|
|
current = font
|
|
while size > min_size:
|
|
bbox = draw.textbbox((0, 0), text, font=current)
|
|
if (bbox[2] - bbox[0]) <= max_width:
|
|
return current
|
|
size -= 1
|
|
current = self.get_font(size, weight=weight)
|
|
return current
|
|
|
|
def truncate_text(self, draw, text, font, max_width):
|
|
"""Truncate text with ellipsis to fit width."""
|
|
if max_width <= 0:
|
|
return ""
|
|
if draw.textbbox((0, 0), text, font=font)[2] <= max_width:
|
|
return text
|
|
ellipsis = "..."
|
|
for i in range(len(text), 0, -1):
|
|
candidate = text[:i] + ellipsis
|
|
if draw.textbbox((0, 0), candidate, font=font)[2] <= max_width:
|
|
return candidate
|
|
return ellipsis
|
|
|
|
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 + 14, size=12, color=self.colors['text_muted'])
|
|
|
|
# Value
|
|
self.draw_text(draw, value, x + 15, y + 38, size=20, color=color, weight="bold")
|
|
|
|
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, weight="bold")
|
|
|
|
# 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, max_width=content_width - 120)
|
|
|
|
# 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, weight="bold")
|
|
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, weight="bold")
|
|
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, weight="bold")
|
|
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, weight="bold", max_width=width - 80)
|
|
|
|
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, weight="bold")
|
|
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, weight="bold")
|
|
|
|
# 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, weight="bold")
|
|
|
|
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, weight="bold")
|
|
|
|
# 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
|
|
max_type_count = max(self.data['find_by_type'].values()) if self.data['find_by_type'] else 1
|
|
|
|
if not type_items:
|
|
self.draw_text(draw, "No findings", col2_x + col_width//2, grid_start_y + 110,
|
|
size=14, color=self.colors['text_dim'], centered=True)
|
|
for issue_type, count in type_items:
|
|
# Type bar
|
|
bar_width = int((col_width - 40) * (count / max_type_count))
|
|
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}".replace("_", " ")
|
|
font_label = self.get_font(11, weight="regular")
|
|
label_text = self.truncate_text(draw, label_text, font_label, col_width - 90)
|
|
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, weight="bold")
|
|
|
|
# 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'], max_width=col_width - 70)
|
|
|
|
# Count
|
|
self.draw_text(draw, f"{count} issues", col3_x + 50, severity_data_y + 35,
|
|
size=16, color=color, weight="bold")
|
|
|
|
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, weight="bold")
|
|
|
|
# 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()
|