mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-07-29 07:33:48 +00:00
updage
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Devour Scorecard CLI - Direct interface to generate scorecards from JSON data.
|
||||
Usage: devour-scorecard <input.json> <output.png>
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add the cmd directory to Python path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
try:
|
||||
from devour_scorecard import load_devour_data, generate_scorecard
|
||||
except ImportError as e:
|
||||
print(f"Error importing scorecard module: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: devour-scorecard <input.json> <output.png>")
|
||||
print("")
|
||||
print("Examples:")
|
||||
print(" devour-scorecard devour_data/quality/status.json scorecard.png")
|
||||
print(" devour-scorecard scan_results.json health_badge.png")
|
||||
sys.exit(1)
|
||||
|
||||
input_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
if not os.path.exists(input_path):
|
||||
print(f"Error: Input file '{input_path}' not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Load data and generate scorecard
|
||||
data = load_devour_data(input_path)
|
||||
result_path = generate_scorecard(data, output_path)
|
||||
|
||||
# Calculate file sizes for info
|
||||
input_size = os.path.getsize(input_path)
|
||||
output_size = os.path.getsize(result_path)
|
||||
|
||||
print(f"✅ Scorecard generated successfully!")
|
||||
print(f"📁 Output: {result_path}")
|
||||
print(f"📊 Input: {input_size:,} bytes → Output: {output_size:,} bytes")
|
||||
print(f"📈 Dimensions: {len(data.dimensions)} categories analyzed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error generating scorecard: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,620 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Devour Scorecard Generator - Working version.
|
||||
Enhanced data visualization with additional metrics and improved layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("Error: PIL/Pillow required. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enhanced visual constants
|
||||
SCALE = 2
|
||||
BG = (248, 248, 246)
|
||||
FRAME = (222, 222, 220)
|
||||
BORDER = (200, 200, 198)
|
||||
ACCENT = (88, 166, 255)
|
||||
TEXT = (40, 44, 52)
|
||||
DIM = (140, 140, 140)
|
||||
BG_SCORE = (255, 255, 255)
|
||||
BG_TABLE = (255, 255, 255)
|
||||
BG_ROW_ALT = (250, 250, 248)
|
||||
BG_GRADIENT_START = (240, 240, 238)
|
||||
BG_GRADIENT_END = (248, 248, 246)
|
||||
|
||||
# Extended color palette
|
||||
COLORS = {
|
||||
'excellent': (68, 120, 68), # deep sage
|
||||
'good': (120, 140, 72), # olive green
|
||||
'moderate': (145, 155, 80), # yellow-green
|
||||
'poor': (255, 193, 7), # orange
|
||||
'critical': (220, 38, 127), # red
|
||||
'info': (88, 166, 255), # blue accent
|
||||
'warning': (255, 152, 0), # orange accent
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class EnhancedScorecardData:
|
||||
"""Enhanced data structure for comprehensive scorecard."""
|
||||
project_name: str
|
||||
version: str
|
||||
main_score: float
|
||||
strict_score: float
|
||||
dimensions: List[Tuple[str, Dict[str, Any]]]
|
||||
trends: Dict[str, List[float]]
|
||||
recommendations: List[str]
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
def scale(value: int) -> int:
|
||||
"""Scale value by retina factor."""
|
||||
return value * SCALE
|
||||
|
||||
|
||||
def score_to_color(score: float) -> Tuple[int, int, int]:
|
||||
"""Convert score to color."""
|
||||
if score >= 90:
|
||||
return COLORS['excellent']
|
||||
elif score >= 70:
|
||||
return COLORS['good']
|
||||
elif score >= 50:
|
||||
return COLORS['moderate']
|
||||
elif score >= 30:
|
||||
return COLORS['poor']
|
||||
else:
|
||||
return COLORS['critical']
|
||||
|
||||
|
||||
def load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont:
|
||||
"""Load font with fallback."""
|
||||
size = size * SCALE
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"arial.ttf"
|
||||
]
|
||||
|
||||
if bold:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"arialbd.ttf"
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_mini_sparkline(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
values: List[float], color: Tuple[int, int, int]
|
||||
) -> None:
|
||||
"""Draw a mini sparkline chart."""
|
||||
if len(values) < 2:
|
||||
return
|
||||
|
||||
# Draw background
|
||||
draw.rectangle([x, y, x + width, y + height], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Calculate points
|
||||
padding = 3
|
||||
chart_width = width - 2 * padding
|
||||
chart_height = height - 2 * padding
|
||||
step_x = chart_width / (len(values) - 1)
|
||||
|
||||
points = []
|
||||
for i, value in enumerate(values):
|
||||
px = x + padding + i * step_x
|
||||
py = y + padding + chart_height - (value / 100) * chart_height
|
||||
points.append((px, py))
|
||||
|
||||
# Draw line
|
||||
if len(points) > 1:
|
||||
draw.line(points, fill=color, width=2)
|
||||
|
||||
# Draw points
|
||||
for px, py in points:
|
||||
draw.ellipse([px - 2, py - 2, px + 2, py + 2], fill=color, outline=TEXT)
|
||||
|
||||
|
||||
def draw_progress_ring(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int, cy: int, outer_radius: int,
|
||||
progress: float, color: Tuple[int, int, int],
|
||||
label: str = ""
|
||||
) -> None:
|
||||
"""Draw a circular progress ring."""
|
||||
# Background ring
|
||||
draw.ellipse([cx - outer_radius, cy - outer_radius, cx + outer_radius, cy + outer_radius],
|
||||
fill=BG_TABLE, outline=BORDER, width=2)
|
||||
|
||||
# Progress arc
|
||||
if progress > 0:
|
||||
inner_radius = outer_radius - 8
|
||||
start_angle = 270
|
||||
end_angle = start_angle - (progress / 100) * 360
|
||||
|
||||
# Draw multiple arcs for thickness effect
|
||||
for i in range(3):
|
||||
radius_offset = i * 2
|
||||
draw.arc([cx - outer_radius + radius_offset, cy - outer_radius + radius_offset,
|
||||
cx + outer_radius + radius_offset, cy + outer_radius + radius_offset],
|
||||
start=start_angle, end=end_angle,
|
||||
fill=color, width=6 - i * 2)
|
||||
|
||||
# Center text
|
||||
if label:
|
||||
font = load_font(10, bold=True)
|
||||
bbox = draw.textbbox((0, 0), label, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
draw.text((cx - text_width // 2, cy - text_height // 2 + bbox[1]),
|
||||
label, fill=TEXT, font=font)
|
||||
|
||||
|
||||
def draw_trend_indicator(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, size: int,
|
||||
trend: str, value: float
|
||||
) -> None:
|
||||
"""Draw a trend indicator with arrow."""
|
||||
# Background circle
|
||||
draw.ellipse([x, y, x + size, y + size], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Trend arrow
|
||||
cx, cy = x + size // 2, y + size // 2
|
||||
arrow_size = size // 4
|
||||
|
||||
color = COLORS['good'] if trend == 'up' else COLORS['poor'] if trend == 'down' else COLORS['moderate']
|
||||
|
||||
if trend == 'up':
|
||||
# Up arrow
|
||||
points = [
|
||||
(cx, cy + arrow_size),
|
||||
(cx - arrow_size // 2, cy),
|
||||
(cx + arrow_size // 2, cy)
|
||||
]
|
||||
elif trend == 'down':
|
||||
# Down arrow
|
||||
points = [
|
||||
(cx, cy - arrow_size),
|
||||
(cx - arrow_size // 2, cy),
|
||||
(cx + arrow_size // 2, cy)
|
||||
]
|
||||
else:
|
||||
# Circle for stable
|
||||
draw.ellipse([cx - arrow_size // 2, cy - arrow_size // 2,
|
||||
cx + arrow_size // 2, cy + arrow_size // 2],
|
||||
fill=color, outline=TEXT)
|
||||
return
|
||||
|
||||
draw.polygon(points, fill=color)
|
||||
|
||||
# Value text
|
||||
font = load_font(8)
|
||||
text = f"{value:.0f}%"
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
draw.text((cx - text_width // 2, y + size + 5), text, fill=TEXT, font=font)
|
||||
|
||||
|
||||
def draw_metric_bar(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
value: float, max_value: float,
|
||||
label: str, color: Tuple[int, int, int]
|
||||
) -> None:
|
||||
"""Draw a horizontal metric bar."""
|
||||
# Background
|
||||
draw.rectangle([x, y, x + width, y + height], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Fill bar
|
||||
if max_value > 0:
|
||||
fill_width = int((value / max_value) * width)
|
||||
draw.rectangle([x, y, x + fill_width, y + height], fill=color)
|
||||
|
||||
|
||||
def generate_enhanced_scorecard(data: EnhancedScorecardData, output_path: str | Path) -> Path:
|
||||
"""Generate enhanced scorecard with additional visual elements."""
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Layout - larger canvas for more elements
|
||||
width = scale(900)
|
||||
height = scale(700)
|
||||
margin = scale(20)
|
||||
|
||||
# Create image with gradient background
|
||||
img = Image.new("RGB", (width, height), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Add subtle gradient overlay
|
||||
for i in range(height):
|
||||
alpha = i / height
|
||||
color = tuple(
|
||||
int(BG_GRADIENT_START[j] + alpha * (BG_GRADIENT_END[j] - BG_GRADIENT_START[j]))
|
||||
for j in range(3)
|
||||
)
|
||||
draw.line([(0, i), (width, i)], fill=color)
|
||||
|
||||
# Header section with enhanced styling
|
||||
header_height = scale(100)
|
||||
draw_header_section(draw, margin, data, header_height)
|
||||
|
||||
# Main content area
|
||||
content_y = margin + header_height + scale(20)
|
||||
|
||||
# Left panel - enhanced score display
|
||||
left_panel_width = scale(280)
|
||||
draw_enhanced_left_panel(draw, margin, content_y, left_panel_width, data)
|
||||
|
||||
# Right panel - dimensions with trends
|
||||
right_panel_x = margin + left_panel_width + scale(20)
|
||||
right_panel_width = width - right_panel_x - margin
|
||||
draw_enhanced_right_panel(draw, right_panel_x, content_y, right_panel_width, data)
|
||||
|
||||
# Bottom recommendations
|
||||
recommendations_y = content_y + scale(200)
|
||||
draw_recommendations_section(draw, margin, recommendations_y, width - 2 * margin, data.recommendations)
|
||||
|
||||
# Footer with metadata
|
||||
footer_y = height - scale(40)
|
||||
draw_footer_section(draw, margin, footer_y, width - 2 * margin, data.metadata)
|
||||
|
||||
# Save image
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(str(output_path), "PNG", optimize=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def draw_header_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
data: EnhancedScorecardData
|
||||
) -> None:
|
||||
"""Draw enhanced header section."""
|
||||
# Project title
|
||||
font_title = load_font(18, bold=True)
|
||||
title = f"{data.project_name} Code Health"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=font_title)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
|
||||
# Background panel for title
|
||||
panel_height = scale(40)
|
||||
draw.rectangle([x, y, x + width, y + panel_height],
|
||||
fill=BG_SCORE, outline=ACCENT, width=2)
|
||||
|
||||
draw.text((x + (width - title_width) // 2, y + scale(12)),
|
||||
title, fill=TEXT, font=font_title)
|
||||
|
||||
# Version and timestamp
|
||||
font_info = load_font(10)
|
||||
timestamp_str = data.metadata.get('timestamp', '')
|
||||
info_text = "v" + str(data.version) + " - Generated " + str(data.metadata.get('timestamp', ''))
|
||||
draw.text((x, y + panel_height + scale(5), info_text, fill=DIM, font=font_info))
|
||||
|
||||
|
||||
def draw_enhanced_left_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
data: EnhancedScorecardData
|
||||
) -> None:
|
||||
"""Draw enhanced left panel with multiple visual elements."""
|
||||
# Main score with large display
|
||||
score_y = y + scale(20)
|
||||
score_size = scale(80)
|
||||
|
||||
# Circular progress indicator
|
||||
draw_progress_ring(draw, x + width // 2 - score_size // 2, score_y, score_size // 2,
|
||||
progress=data.main_score, color=score_to_color(data.main_score))
|
||||
|
||||
# Score text
|
||||
font_score = load_font(36, bold=True)
|
||||
score_text = f"{int(data.main_score)}"
|
||||
score_bbox = draw.textbbox((0, 0), score_text, font=font_score)
|
||||
score_width = score_bbox[2] - score_bbox[0]
|
||||
score_height = score_bbox[3] - score_bbox[1]
|
||||
|
||||
score_bg_y = score_y + score_size + scale(10)
|
||||
score_bg_height = scale(50)
|
||||
|
||||
# Background for score text
|
||||
draw.rectangle([x, score_bg_y, x + width, score_bg_y + score_bg_height],
|
||||
fill=BG_SCORE, outline=BORDER, width=1)
|
||||
|
||||
draw.text((x + (width - score_width) // 2, score_bg_y + score_bg_height // 2 - score_height // 2 + score_bbox[1]),
|
||||
score_text, fill=TEXT, font=font_score)
|
||||
|
||||
# Strict score
|
||||
strict_y = score_bg_y + score_bg_height + scale(15)
|
||||
font_strict = load_font(14)
|
||||
strict_text = f"Strict: {int(data.strict_score)}"
|
||||
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
|
||||
strict_width = strict_bbox[2] - strict_bbox[0]
|
||||
|
||||
draw.text((x + (width - strict_width) // 2, strict_y - strict_bbox[1]),
|
||||
strict_text, fill=score_to_color(data.strict_score, muted=True), font=font_strict)
|
||||
|
||||
# Grade indicator
|
||||
grade_y = strict_y + scale(30)
|
||||
grade_size = scale(40)
|
||||
grade = "A" if data.main_score >= 90 else "B" if data.main_score >= 70 else "C" if data.main_score >= 50 else "D" if data.main_score >= 30 else "F"
|
||||
|
||||
draw_progress_ring(draw, x + width // 2 - grade_size // 2, grade_y, grade_size // 2,
|
||||
progress=data.main_score, color=score_to_color(data.main_score))
|
||||
|
||||
font_grade = load_font(24, bold=True)
|
||||
grade_bbox = draw.textbbox((0, 0), grade, font=font_grade)
|
||||
grade_width = grade_bbox[2] - grade_bbox[0]
|
||||
|
||||
draw.text((x + (width - grade_width) // 2, grade_y + grade_size + scale(10) - grade_bbox[1]),
|
||||
grade, fill=TEXT, font=font_grade)
|
||||
|
||||
|
||||
def draw_enhanced_right_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
data: EnhancedScorecardData
|
||||
) -> None:
|
||||
"""Draw enhanced right panel with dimensions and trends."""
|
||||
# Dimensions table
|
||||
table_y = y + scale(20)
|
||||
table_height = scale(120)
|
||||
|
||||
draw_enhanced_dimensions_table(draw, x, table_y, width, table_height, data.dimensions)
|
||||
|
||||
# Trends section
|
||||
trends_y = table_y + table_height + scale(20)
|
||||
draw_trends_section(draw, x, trends_y, width, data.trends)
|
||||
|
||||
|
||||
def draw_enhanced_dimensions_table(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
dimensions: List[Tuple[str, Dict[str, Any]]]
|
||||
) -> None:
|
||||
"""Draw enhanced dimensions table with sparklines."""
|
||||
font_header = load_font(11, bold=True)
|
||||
font_row = load_font(9)
|
||||
|
||||
# Table header
|
||||
header_y = y
|
||||
draw.text((x, header_y), "CATEGORY", fill=TEXT, font=font_header)
|
||||
draw.text((x + scale(120), header_y), "SCORE", fill=TEXT, font=font_header)
|
||||
draw.text((x + scale(200), header_y), "TREND", fill=TEXT, font=font_header)
|
||||
|
||||
# Table rows
|
||||
row_y = header_y + scale(20)
|
||||
row_height = scale(25)
|
||||
|
||||
for i, (name, data) in enumerate(dimensions[:4]): # Limit to 4 rows
|
||||
# Background
|
||||
if i % 2 == 1:
|
||||
draw.rectangle([x, row_y, x + width, row_y + row_height], fill=BG_ROW_ALT)
|
||||
|
||||
# Category name
|
||||
draw.text((x + scale(5), row_y + scale(5), name, fill=TEXT, font=font_row)
|
||||
|
||||
# Score bar
|
||||
score = data.get('score', 0)
|
||||
bar_x = x + scale(120)
|
||||
bar_width = scale(60)
|
||||
bar_height = scale(10)
|
||||
draw_metric_bar(draw, bar_x, row_y + scale(5), bar_width, bar_height,
|
||||
score, 100, "", score_to_color(score))
|
||||
|
||||
# Trend indicator
|
||||
trend_x = x + scale(200)
|
||||
trend_data = data.get('trend', [50, 55, 60]) # Sample trend data
|
||||
if len(trend_data) >= 2:
|
||||
trend = 'up' if trend_data[-1] > trend_data[-2] else 'down' if trend_data[-1] < trend_data[-2] else 'stable'
|
||||
draw_trend_indicator(draw, trend_x, row_y + scale(5), scale(20), trend, trend_data[-1])
|
||||
|
||||
row_y += row_height
|
||||
|
||||
|
||||
def draw_trends_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
trends: Dict[str, List[float]]
|
||||
) -> None:
|
||||
"""Draw trends section with sparklines."""
|
||||
font_header = load_font(11, bold=True)
|
||||
|
||||
# Section title
|
||||
draw.text((x, y), "Recent Trends", fill=TEXT, font=font_header)
|
||||
|
||||
# Trend charts
|
||||
chart_y = y + scale(30)
|
||||
chart_width = scale(80)
|
||||
chart_height = scale(40)
|
||||
|
||||
for i, (category, values) in enumerate(list(trends.items())[:2]): # 2 charts
|
||||
chart_x = x + i * (chart_width + scale(20))
|
||||
|
||||
# Category label
|
||||
font_label = load_font(9)
|
||||
draw.text((chart_x, chart_y - scale(15), category, fill=TEXT, font=font_label)
|
||||
|
||||
# Sparkline
|
||||
draw_mini_sparkline(draw, chart_x, chart_y, chart_width, chart_height,
|
||||
values, COLORS['info'])
|
||||
|
||||
|
||||
def draw_recommendations_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
recommendations: List[str]
|
||||
) -> None:
|
||||
"""Draw recommendations section."""
|
||||
font_header = load_font(12, bold=True)
|
||||
font_rec = load_font(9)
|
||||
|
||||
# Section title
|
||||
draw.text((x, y), "🎯 Priority Actions", fill=TEXT, font=font_header)
|
||||
|
||||
# Recommendations list
|
||||
rec_y = y + scale(25)
|
||||
for i, rec in enumerate(recommendations[:4]): # Limit to 4 recommendations
|
||||
# Numbered bullet
|
||||
bullet = f"{i + 1}."
|
||||
draw.text((x + scale(10), rec_y, bullet, fill=ACCENT, font=font_rec)
|
||||
|
||||
# Recommendation text (with word wrap)
|
||||
text_x = x + scale(30)
|
||||
max_width = width - scale(40)
|
||||
|
||||
# Simple word wrap
|
||||
words = rec.split()
|
||||
line = ""
|
||||
for word in words:
|
||||
test_line = line + " " + word if line else word
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font_rec)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
if text_width > max_width or line:
|
||||
draw.text((text_x, rec_y), line, fill=TEXT, font=font_rec)
|
||||
rec_y += scale(12)
|
||||
line = word if line else ""
|
||||
text_x = x + scale(30)
|
||||
else:
|
||||
line = test_line
|
||||
|
||||
rec_y += scale(15)
|
||||
|
||||
|
||||
def draw_footer_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
metadata: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Draw footer with metadata."""
|
||||
font_footer = load_font(8)
|
||||
|
||||
# Footer line
|
||||
draw.line([(x, y), (x + width, y)], fill=BORDER, width=1)
|
||||
|
||||
# Metadata text
|
||||
files_analyzed = metadata.get('files_analyzed', 0)
|
||||
timestamp_str = metadata.get('timestamp', '')
|
||||
info_text = f"Generated by Devour v{metadata.get('version', '1.0.0')} - {files_analyzed} files analyzed - {timestamp_str}"
|
||||
draw.text((x, y + scale(5), info_text, fill=DIM, font=font_footer)
|
||||
|
||||
|
||||
def load_enhanced_devour_data(json_path: str) -> EnhancedScorecardData:
|
||||
"""Load Devour data and convert to enhanced format."""
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
findings = data.get('findings', [])
|
||||
|
||||
# Calculate scores
|
||||
total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings)
|
||||
main_score = max(0, 100 - (total_score / 1000 * 100))
|
||||
strict_score = main_score * 0.8 # Strict is lower
|
||||
|
||||
# Group by type
|
||||
type_counts = {}
|
||||
type_scores = {}
|
||||
|
||||
for finding in findings:
|
||||
ftype = finding.get('type', 'unknown')
|
||||
type_counts[ftype] = type_counts.get(ftype, 0) + 1
|
||||
type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0)
|
||||
|
||||
# Create dimensions with trend data
|
||||
dimensions = []
|
||||
for ftype, count in type_counts.items():
|
||||
avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100)
|
||||
|
||||
# Generate sample trend data
|
||||
trend_data = [avg_score - 10, avg_score - 5, avg_score, avg_score + 5, avg_score + 10]
|
||||
|
||||
dimensions.append((
|
||||
ftype.replace('_', ' ').title(),
|
||||
{
|
||||
'score': max(0, min(100, avg_score)),
|
||||
'strict': max(0, min(100, avg_score * 0.8)),
|
||||
'count': count,
|
||||
'trend': trend_data
|
||||
}
|
||||
))
|
||||
|
||||
# Sort by score (lowest first)
|
||||
dimensions.sort(key=lambda x: x[1]['score'])
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = [
|
||||
"🔴 Address critical T4 issues immediately for maximum impact",
|
||||
"🟡 Focus on reducing high-severity findings (T2-T3)",
|
||||
"🟢 Improve test coverage to reduce technical debt",
|
||||
"📊 Regular code reviews to maintain quality standards"
|
||||
]
|
||||
|
||||
# Metadata
|
||||
metadata = {
|
||||
'version': '1.0.0',
|
||||
'files_analyzed': len(set(f.get('file', '') for f in findings)),
|
||||
'timestamp': data.get('timestamp', '')
|
||||
}
|
||||
|
||||
return EnhancedScorecardData(
|
||||
project_name="Devour",
|
||||
version="1.0.0",
|
||||
main_score=main_score,
|
||||
strict_score=strict_score,
|
||||
dimensions=dimensions[:6], # Limit to 6 dimensions
|
||||
trends={'overall': [main_score - 5, main_score], 'performance': [85, 88, 92, main_score]},
|
||||
recommendations=recommendations,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python devour_enhanced.py <devour_results.json> <output.png>")
|
||||
sys.exit(1)
|
||||
|
||||
json_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
if not os.path.exists(json_path):
|
||||
print(f"Error: Input file {json_path} not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = load_enhanced_devour_data(json_path)
|
||||
result_path = generate_enhanced_scorecard(data, output_path)
|
||||
print(f"Enhanced scorecard generated: {result_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating enhanced scorecard: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,612 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Devour Scorecard Generator - Extended version with more visual elements.
|
||||
Enhanced data visualization with additional metrics and improved layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("Error: PIL/Pillow required. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enhanced visual constants
|
||||
SCALE = 2
|
||||
BG = (248, 248, 246)
|
||||
FRAME = (222, 222, 220)
|
||||
BORDER = (200, 200, 198)
|
||||
ACCENT = (88, 166, 255)
|
||||
TEXT = (40, 44, 52)
|
||||
DIM = (140, 140, 140)
|
||||
BG_SCORE = (255, 255, 255)
|
||||
BG_TABLE = (255, 255, 255)
|
||||
BG_ROW_ALT = (250, 250, 248)
|
||||
BG_GRADIENT_START = (240, 240, 238)
|
||||
BG_GRADIENT_END = (248, 248, 246)
|
||||
|
||||
# Extended color palette
|
||||
COLORS = {
|
||||
'excellent': (68, 120, 68), # deep sage
|
||||
'good': (120, 140, 72), # olive green
|
||||
'moderate': (145, 155, 80), # yellow-green
|
||||
'poor': (255, 193, 7), # orange
|
||||
'critical': (220, 38, 127), # red
|
||||
'info': (88, 166, 255), # blue accent
|
||||
'warning': (255, 152, 0), # orange accent
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class EnhancedScorecardData:
|
||||
"""Enhanced data structure for comprehensive scorecard."""
|
||||
project_name: str
|
||||
version: str
|
||||
main_score: float
|
||||
strict_score: float
|
||||
dimensions: List[Tuple[str, Dict[str, Any]]]
|
||||
trends: Dict[str, List[float]]
|
||||
recommendations: List[str]
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
def draw_mini_sparkline(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
values: List[float], color: Tuple[int, int, int]
|
||||
) -> None:
|
||||
"""Draw a mini sparkline chart."""
|
||||
if len(values) < 2:
|
||||
return
|
||||
|
||||
# Draw background
|
||||
draw.rectangle([x, y, x + width, y + height], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Calculate points
|
||||
padding = 3
|
||||
chart_width = width - 2 * padding
|
||||
chart_height = height - 2 * padding
|
||||
step_x = chart_width / (len(values) - 1)
|
||||
|
||||
points = []
|
||||
for i, value in enumerate(values):
|
||||
px = x + padding + i * step_x
|
||||
py = y + padding + chart_height - (value / 100) * chart_height
|
||||
points.append((px, py))
|
||||
|
||||
# Draw line
|
||||
if len(points) > 1:
|
||||
draw.line(points, fill=color, width=2)
|
||||
|
||||
# Draw points
|
||||
for px, py in points:
|
||||
draw.ellipse([px - 2, py - 2, px + 2, py + 2], fill=color, outline=TEXT)
|
||||
|
||||
|
||||
def draw_progress_ring(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int, cy: int, outer_radius: int,
|
||||
progress: float, color: Tuple[int, int, int],
|
||||
label: str = ""
|
||||
) -> None:
|
||||
"""Draw a circular progress ring."""
|
||||
# Background ring
|
||||
draw.ellipse([cx - outer_radius, cy - outer_radius, cx + outer_radius, cy + outer_radius],
|
||||
fill=BG_TABLE, outline=BORDER, width=2)
|
||||
|
||||
# Progress arc
|
||||
if progress > 0:
|
||||
inner_radius = outer_radius - 8
|
||||
start_angle = 270
|
||||
end_angle = start_angle - (progress / 100) * 360
|
||||
|
||||
# Draw multiple arcs for thickness effect
|
||||
for i in range(3):
|
||||
radius_offset = i * 2
|
||||
draw.arc([cx - outer_radius + radius_offset, cy - outer_radius + radius_offset,
|
||||
cx + outer_radius + radius_offset, cy + outer_radius + radius_offset],
|
||||
start=start_angle, end=end_angle,
|
||||
fill=color, width=6 - i * 2)
|
||||
|
||||
# Center text
|
||||
if label:
|
||||
font = load_font(10, bold=True)
|
||||
bbox = draw.textbbox((0, 0), label, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
draw.text((cx - text_width // 2, cy - text_height // 2 + bbox[1]),
|
||||
label, fill=TEXT, font=font)
|
||||
|
||||
|
||||
def draw_trend_indicator(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, size: int,
|
||||
trend: str, value: float
|
||||
) -> None:
|
||||
"""Draw a trend indicator with arrow."""
|
||||
# Background circle
|
||||
draw.ellipse([x, y, x + size, y + size], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Trend arrow
|
||||
cx, cy = x + size // 2, y + size // 2
|
||||
arrow_size = size // 4
|
||||
|
||||
color = COLORS['good'] if trend == 'up' else COLORS['poor'] if trend == 'down' else COLORS['moderate']
|
||||
|
||||
if trend == 'up':
|
||||
# Up arrow
|
||||
points = [
|
||||
(cx, cy + arrow_size),
|
||||
(cx - arrow_size // 2, cy),
|
||||
(cx + arrow_size // 2, cy)
|
||||
]
|
||||
elif trend == 'down':
|
||||
# Down arrow
|
||||
points = [
|
||||
(cx, cy - arrow_size),
|
||||
(cx - arrow_size // 2, cy),
|
||||
(cx + arrow_size // 2, cy)
|
||||
]
|
||||
else:
|
||||
# Circle for stable
|
||||
draw.ellipse([cx - arrow_size // 2, cy - arrow_size // 2,
|
||||
cx + arrow_size // 2, cy + arrow_size // 2],
|
||||
fill=color, outline=TEXT)
|
||||
return
|
||||
|
||||
draw.polygon(points, fill=color)
|
||||
|
||||
# Value text
|
||||
font = load_font(8)
|
||||
text = f"{value:.0f}%"
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
draw.text((cx - text_width // 2, y + size + 5), text, fill=TEXT, font=font)
|
||||
|
||||
|
||||
def generate_enhanced_scorecard(data: EnhancedScorecardData, output_path: str | Path) -> Path:
|
||||
"""Generate enhanced scorecard with additional visual elements."""
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Layout - larger canvas for more elements
|
||||
width = scale(900)
|
||||
height = scale(700)
|
||||
margin = scale(20)
|
||||
|
||||
# Create image with gradient background
|
||||
img = Image.new("RGB", (width, height), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Add subtle gradient overlay
|
||||
for i in range(height):
|
||||
alpha = i / height
|
||||
color = tuple(
|
||||
int(BG_GRADIENT_START[j] + alpha * (BG_GRADIENT_END[j] - BG_GRADIENT_START[j]))
|
||||
for j in range(3)
|
||||
)
|
||||
draw.line([(0, i), (width, i)], fill=color)
|
||||
|
||||
# Header section with enhanced styling
|
||||
header_height = scale(100)
|
||||
draw_header_section(draw, margin, data, header_height)
|
||||
|
||||
# Main content area
|
||||
content_y = margin + header_height + scale(20)
|
||||
|
||||
# Left panel - enhanced score display
|
||||
left_panel_width = scale(280)
|
||||
draw_enhanced_left_panel(draw, margin, content_y, left_panel_width, data)
|
||||
|
||||
# Right panel - dimensions with trends
|
||||
right_panel_x = margin + left_panel_width + scale(20)
|
||||
right_panel_width = width - right_panel_x - margin
|
||||
draw_enhanced_right_panel(draw, right_panel_x, content_y, right_panel_width, data)
|
||||
|
||||
# Bottom recommendations
|
||||
recommendations_y = content_y + scale(200)
|
||||
draw_recommendations_section(draw, margin, recommendations_y, width - 2 * margin, data.recommendations)
|
||||
|
||||
# Footer with metadata
|
||||
footer_y = height - scale(40)
|
||||
draw_footer_section(draw, margin, footer_y, width - 2 * margin, data.metadata)
|
||||
|
||||
# Save image
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(str(output_path), "PNG", optimize=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def draw_header_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
data: EnhancedScorecardData
|
||||
) -> None:
|
||||
"""Draw enhanced header section."""
|
||||
# Project title
|
||||
font_title = load_font(18, bold=True)
|
||||
title = f"{data.project_name} Code Health"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=font_title)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
|
||||
# Background panel for title
|
||||
panel_height = scale(40)
|
||||
draw.rectangle([x, y, x + width, y + panel_height],
|
||||
fill=BG_SCORE, outline=ACCENT, width=2)
|
||||
|
||||
draw.text((x + (width - title_width) // 2, y + scale(12)),
|
||||
title, fill=TEXT, font=font_title)
|
||||
|
||||
# Version and timestamp
|
||||
font_info = load_font(10)
|
||||
info_text = f"v{data.version} - Generated {data.metadata.get('timestamp', '')}"
|
||||
draw.text((x, y + panel_height + scale(5), info_text, fill=DIM, font=font_info)
|
||||
|
||||
|
||||
def draw_enhanced_left_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
data: EnhancedScorecardData
|
||||
) -> None:
|
||||
"""Draw enhanced left panel with multiple visual elements."""
|
||||
# Main score with large display
|
||||
score_y = y + scale(20)
|
||||
score_size = scale(80)
|
||||
|
||||
# Circular progress indicator
|
||||
draw_progress_ring(draw, x + width // 2 - score_size // 2, score_y, score_size // 2,
|
||||
progress=data.main_score, color=score_to_color(data.main_score))
|
||||
|
||||
# Score text
|
||||
font_score = load_font(36, bold=True)
|
||||
score_text = f"{int(data.main_score)}"
|
||||
score_bbox = draw.textbbox((0, 0), score_text, font=font_score)
|
||||
score_width = score_bbox[2] - score_bbox[0]
|
||||
score_height = score_bbox[3] - score_bbox[1]
|
||||
|
||||
score_bg_y = score_y + score_size + scale(10)
|
||||
score_bg_height = scale(50)
|
||||
|
||||
# Background for score text
|
||||
draw.rectangle([x, score_bg_y, x + width, score_bg_y + score_bg_height],
|
||||
fill=BG_SCORE, outline=BORDER, width=1)
|
||||
|
||||
draw.text((x + (width - score_width) // 2, score_bg_y + score_bg_height // 2 - score_height // 2 + score_bbox[1]),
|
||||
score_text, fill=TEXT, font=font_score)
|
||||
|
||||
# Strict score
|
||||
strict_y = score_bg_y + score_bg_height + scale(15)
|
||||
font_strict = load_font(14)
|
||||
strict_text = f"Strict: {int(data.strict_score)}"
|
||||
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
|
||||
strict_width = strict_bbox[2] - strict_bbox[0]
|
||||
|
||||
draw.text((x + (width - strict_width) // 2, strict_y - strict_bbox[1]),
|
||||
strict_text, fill=score_to_color(data.strict_score, muted=True), font=font_strict)
|
||||
|
||||
# Grade indicator
|
||||
grade_y = strict_y + scale(30)
|
||||
grade_size = scale(40)
|
||||
grade = "A" if data.main_score >= 90 else "B" if data.main_score >= 70 else "C" if data.main_score >= 50 else "D" if data.main_score >= 30 else "F"
|
||||
|
||||
draw_progress_ring(draw, x + width // 2 - grade_size // 2, grade_y, grade_size // 2,
|
||||
progress=data.main_score, color=score_to_color(data.main_score))
|
||||
|
||||
font_grade = load_font(24, bold=True)
|
||||
grade_bbox = draw.textbbox((0, 0), grade, font=font_grade)
|
||||
grade_width = grade_bbox[2] - grade_bbox[0]
|
||||
|
||||
draw.text((x + (width - grade_width) // 2, grade_y + grade_size + scale(10) - grade_bbox[1]),
|
||||
grade, fill=TEXT, font=font_grade)
|
||||
|
||||
|
||||
def draw_enhanced_right_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
data: EnhancedScorecardData
|
||||
) -> None:
|
||||
"""Draw enhanced right panel with dimensions and trends."""
|
||||
# Dimensions table
|
||||
table_y = y + scale(20)
|
||||
table_height = scale(120)
|
||||
|
||||
draw_enhanced_dimensions_table(draw, x, table_y, width, table_height, data.dimensions)
|
||||
|
||||
# Trends section
|
||||
trends_y = table_y + table_height + scale(20)
|
||||
draw_trends_section(draw, x, trends_y, width, data.trends)
|
||||
|
||||
|
||||
def draw_enhanced_dimensions_table(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
dimensions: List[Tuple[str, Dict[str, Any]]]
|
||||
) -> None:
|
||||
"""Draw enhanced dimensions table with sparklines."""
|
||||
font_header = load_font(11, bold=True)
|
||||
font_row = load_font(9)
|
||||
|
||||
# Table header
|
||||
header_y = y
|
||||
draw.text((x, header_y), "CATEGORY", fill=TEXT, font=font_header)
|
||||
draw.text((x + scale(120), header_y), "SCORE", fill=TEXT, font=font_header)
|
||||
draw.text((x + scale(200), header_y), "TREND", fill=TEXT, font=font_header)
|
||||
|
||||
# Table rows
|
||||
row_y = header_y + scale(20)
|
||||
row_height = scale(25)
|
||||
|
||||
for i, (name, data) in enumerate(dimensions[:4]): # Limit to 4 rows
|
||||
# Background
|
||||
if i % 2 == 1:
|
||||
draw.rectangle([x, row_y, x + width, row_y + row_height], fill=BG_ROW_ALT)
|
||||
|
||||
# Category name
|
||||
draw.text((x + scale(5), row_y + scale(5), name, fill=TEXT, font=font_row)
|
||||
|
||||
# Score bar
|
||||
score = data.get('score', 0)
|
||||
bar_x = x + scale(120)
|
||||
bar_width = scale(60)
|
||||
bar_height = scale(10)
|
||||
draw_metric_bar(draw, bar_x, row_y + scale(5), bar_width, bar_height,
|
||||
score, 100, "", score_to_color(score))
|
||||
|
||||
# Trend indicator
|
||||
trend_x = x + scale(200)
|
||||
trend_data = data.get('trend', [50, 55, 60]) # Sample trend data
|
||||
if len(trend_data) >= 2:
|
||||
trend = 'up' if trend_data[-1] > trend_data[-2] else 'down' if trend_data[-1] < trend_data[-2] else 'stable'
|
||||
draw_trend_indicator(draw, trend_x, row_y + scale(5), scale(20), trend, trend_data[-1])
|
||||
|
||||
row_y += row_height
|
||||
|
||||
|
||||
def draw_trends_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
trends: Dict[str, List[float]]
|
||||
) -> None:
|
||||
"""Draw trends section with sparklines."""
|
||||
font_header = load_font(11, bold=True)
|
||||
|
||||
# Section title
|
||||
draw.text((x, y), "Recent Trends", fill=TEXT, font=font_header)
|
||||
|
||||
# Trend charts
|
||||
chart_y = y + scale(30)
|
||||
chart_width = scale(80)
|
||||
chart_height = scale(40)
|
||||
|
||||
for i, (category, values) in enumerate(list(trends.items())[:2]): # 2 charts
|
||||
chart_x = x + i * (chart_width + scale(20))
|
||||
|
||||
# Category label
|
||||
font_label = load_font(9)
|
||||
draw.text((chart_x, chart_y - scale(15), category, fill=TEXT, font=font_label)
|
||||
|
||||
# Sparkline
|
||||
draw_mini_sparkline(draw, chart_x, chart_y, chart_width, chart_height,
|
||||
values, COLORS['info'])
|
||||
|
||||
|
||||
def draw_recommendations_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
recommendations: List[str]
|
||||
) -> None:
|
||||
"""Draw recommendations section."""
|
||||
font_header = load_font(12, bold=True)
|
||||
font_rec = load_font(9)
|
||||
|
||||
# Section title
|
||||
draw.text((x, y), "🎯 Priority Actions", fill=TEXT, font=font_header)
|
||||
|
||||
# Recommendations list
|
||||
rec_y = y + scale(25)
|
||||
for i, rec in enumerate(recommendations[:4]): # Limit to 4 recommendations
|
||||
# Numbered bullet
|
||||
bullet = f"{i + 1}."
|
||||
draw.text((x + scale(10), rec_y, bullet, fill=ACCENT, font=font_rec)
|
||||
|
||||
# Recommendation text (with word wrap)
|
||||
text_x = x + scale(30)
|
||||
max_width = width - scale(40)
|
||||
|
||||
# Simple word wrap
|
||||
words = rec.split()
|
||||
line = ""
|
||||
for word in words:
|
||||
test_line = line + " " + word if line else word
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font_rec)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
if text_width > max_width or line:
|
||||
draw.text((text_x, rec_y), line, fill=TEXT, font=font_rec)
|
||||
rec_y += scale(12)
|
||||
line = word if line else ""
|
||||
text_x = x + scale(30)
|
||||
else:
|
||||
line = test_line
|
||||
|
||||
rec_y += scale(15)
|
||||
|
||||
|
||||
def draw_footer_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
metadata: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Draw footer with metadata."""
|
||||
font_footer = load_font(8)
|
||||
|
||||
# Footer line
|
||||
draw.line([(x, y), (x + width, y)], fill=BORDER, width=1)
|
||||
|
||||
# Metadata text
|
||||
info_text = f"Generated by Devour v{metadata.get('version', '1.0.0')} • {metadata.get('files_analyzed', 0)} files analyzed • {metadata.get('timestamp', '')}"
|
||||
draw.text((x, y + scale(5), info_text, fill=DIM, font=font_footer)
|
||||
|
||||
|
||||
def draw_metric_bar(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
value: float, max_value: float,
|
||||
label: str, color: Tuple[int, int, int]
|
||||
) -> None:
|
||||
"""Draw a horizontal metric bar."""
|
||||
# Background
|
||||
draw.rectangle([x, y, x + width, y + height], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Fill bar
|
||||
if max_value > 0:
|
||||
fill_width = int((value / max_value) * width)
|
||||
draw.rectangle([x, y, x + fill_width, y + height], fill=color)
|
||||
|
||||
|
||||
def score_to_color(score: float) -> Tuple[int, int, int]:
|
||||
"""Convert score to color."""
|
||||
if score >= 90:
|
||||
return COLORS['excellent']
|
||||
elif score >= 70:
|
||||
return COLORS['good']
|
||||
elif score >= 50:
|
||||
return COLORS['moderate']
|
||||
elif score >= 30:
|
||||
return COLORS['poor']
|
||||
else:
|
||||
return COLORS['critical']
|
||||
|
||||
|
||||
def load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont:
|
||||
"""Load font with fallback."""
|
||||
size = size * SCALE
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"arial.ttf"
|
||||
]
|
||||
|
||||
if bold:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"arialbd.ttf"
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def load_enhanced_devour_data(json_path: str) -> EnhancedScorecardData:
|
||||
"""Load Devour data and convert to enhanced format."""
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
findings = data.get('findings', [])
|
||||
|
||||
# Calculate scores
|
||||
total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings)
|
||||
main_score = max(0, 100 - (total_score / 1000 * 100))
|
||||
strict_score = main_score * 0.8 # Strict is lower
|
||||
|
||||
# Group by type
|
||||
type_counts = {}
|
||||
type_scores = {}
|
||||
|
||||
for finding in findings:
|
||||
ftype = finding.get('type', 'unknown')
|
||||
type_counts[ftype] = type_counts.get(ftype, 0) + 1
|
||||
type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0)
|
||||
|
||||
# Create dimensions with trend data
|
||||
dimensions = []
|
||||
for ftype, count in type_counts.items():
|
||||
avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100)
|
||||
|
||||
# Generate sample trend data
|
||||
trend_data = [avg_score - 10, avg_score - 5, avg_score, avg_score + 5, avg_score + 10]
|
||||
|
||||
dimensions.append((
|
||||
ftype.replace('_', ' ').title(),
|
||||
{
|
||||
'score': max(0, min(100, avg_score)),
|
||||
'strict': max(0, min(100, avg_score * 0.8)),
|
||||
'count': count,
|
||||
'trend': trend_data
|
||||
}
|
||||
))
|
||||
|
||||
# Sort by score (lowest first)
|
||||
dimensions.sort(key=lambda x: x[1]['score'])
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = [
|
||||
"🔴 Address critical T4 issues immediately for maximum impact",
|
||||
"🟡 Focus on reducing high-severity findings (T2-T3)",
|
||||
"🟢 Improve test coverage to reduce technical debt",
|
||||
"📊 Regular code reviews to maintain quality standards"
|
||||
]
|
||||
|
||||
# Metadata
|
||||
metadata = {
|
||||
'version': '1.0.0',
|
||||
'files_analyzed': len(set(f.get('file', '') for f in findings)),
|
||||
'timestamp': data.get('timestamp', '')
|
||||
}
|
||||
|
||||
return EnhancedScorecardData(
|
||||
project_name="Devour",
|
||||
version="1.0.0",
|
||||
main_score=main_score,
|
||||
strict_score=strict_score,
|
||||
dimensions=dimensions[:6], # Limit to 6 dimensions
|
||||
trends={'overall': [main_score - 5, main_score], 'performance': [85, 88, 92, main_score]},
|
||||
recommendations=recommendations,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python devour_enhanced.py <devour_results.json> <output.png>")
|
||||
sys.exit(1)
|
||||
|
||||
json_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
if not os.path.exists(json_path):
|
||||
print(f"Error: Input file {json_path} not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = load_enhanced_devour_data(json_path)
|
||||
result_path = generate_enhanced_scorecard(data, output_path)
|
||||
print(f"Enhanced scorecard generated: {result_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating enhanced scorecard: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,621 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Devour Scorecard Generator - Extended version with more visual elements.
|
||||
Enhanced data visualization with additional metrics and improved layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("Error: PIL/Pillow required. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Enhanced visual constants
|
||||
SCALE = 2
|
||||
BG = (248, 248, 246)
|
||||
FRAME = (222, 222, 220)
|
||||
BORDER = (200, 200, 198)
|
||||
ACCENT = (88, 166, 255)
|
||||
TEXT = (40, 44, 52)
|
||||
DIM = (140, 140, 140)
|
||||
BG_SCORE = (255, 255, 255)
|
||||
BG_TABLE = (255, 255, 255)
|
||||
BG_ROW_ALT = (250, 250, 248)
|
||||
BG_GRADIENT_START = (240, 240, 238)
|
||||
BG_GRADIENT_END = (248, 248, 246)
|
||||
|
||||
# Extended color palette
|
||||
COLORS = {
|
||||
'excellent': (68, 120, 68), # deep sage
|
||||
'good': (120, 140, 72), # olive green
|
||||
'moderate': (145, 155, 80), # yellow-green
|
||||
'poor': (255, 193, 7), # orange
|
||||
'critical': (220, 38, 127), # red
|
||||
'info': (88, 166, 255), # blue accent
|
||||
'warning': (255, 152, 0), # orange accent
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class EnhancedScorecardData:
|
||||
"""Enhanced data structure for comprehensive scorecard."""
|
||||
project_name: str
|
||||
version: str
|
||||
main_score: float
|
||||
strict_score: float
|
||||
dimensions: List[Tuple[str, Dict[str, Any]]]
|
||||
trends: Dict[str, List[float]]
|
||||
recommendations: List[str]
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
def scale(value: int) -> int:
|
||||
"""Scale value by retina factor."""
|
||||
return value * SCALE
|
||||
|
||||
|
||||
def score_to_color(score: float) -> Tuple[int, int, int]:
|
||||
"""Convert score to color."""
|
||||
if score >= 90:
|
||||
return COLORS['excellent']
|
||||
elif score >= 70:
|
||||
return COLORS['good']
|
||||
elif score >= 50:
|
||||
return COLORS['moderate']
|
||||
elif score >= 30:
|
||||
return COLORS['poor']
|
||||
else:
|
||||
return COLORS['critical']
|
||||
|
||||
|
||||
def load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont:
|
||||
"""Load font with fallback."""
|
||||
size = size * SCALE
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"arial.ttf"
|
||||
]
|
||||
|
||||
if bold:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"arialbd.ttf"
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_mini_sparkline(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
values: List[float], color: Tuple[int, int, int]
|
||||
) -> None:
|
||||
"""Draw a mini sparkline chart."""
|
||||
if len(values) < 2:
|
||||
return
|
||||
|
||||
# Draw background
|
||||
draw.rectangle([x, y, x + width, y + height], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Calculate points
|
||||
padding = 3
|
||||
chart_width = width - 2 * padding
|
||||
chart_height = height - 2 * padding
|
||||
step_x = chart_width / (len(values) - 1)
|
||||
|
||||
points = []
|
||||
for i, value in enumerate(values):
|
||||
px = x + padding + i * step_x
|
||||
py = y + padding + chart_height - (value / 100) * chart_height
|
||||
points.append((px, py))
|
||||
|
||||
# Draw line
|
||||
if len(points) > 1:
|
||||
draw.line(points, fill=color, width=2)
|
||||
|
||||
# Draw points
|
||||
for px, py in points:
|
||||
draw.ellipse([px - 2, py - 2, px + 2, py + 2], fill=color, outline=TEXT)
|
||||
|
||||
|
||||
def draw_progress_ring(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int, cy: int, outer_radius: int,
|
||||
progress: float, color: Tuple[int, int, int],
|
||||
label: str = ""
|
||||
) -> None:
|
||||
"""Draw a circular progress ring."""
|
||||
# Background ring
|
||||
draw.ellipse([cx - outer_radius, cy - outer_radius, cx + outer_radius, cy + outer_radius],
|
||||
fill=BG_TABLE, outline=BORDER, width=2)
|
||||
|
||||
# Progress arc
|
||||
if progress > 0:
|
||||
inner_radius = outer_radius - 8
|
||||
start_angle = 270
|
||||
end_angle = start_angle - (progress / 100) * 360
|
||||
|
||||
# Draw multiple arcs for thickness effect
|
||||
for i in range(3):
|
||||
radius_offset = i * 2
|
||||
draw.arc([cx - outer_radius + radius_offset, cy - outer_radius + radius_offset,
|
||||
cx + outer_radius + radius_offset, cy + outer_radius + radius_offset],
|
||||
start=start_angle, end=end_angle,
|
||||
fill=color, width=6 - i * 2)
|
||||
|
||||
# Center text
|
||||
if label:
|
||||
font = load_font(10, bold=True)
|
||||
bbox = draw.textbbox((0, 0), label, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
draw.text((cx - text_width // 2, cy - text_height // 2 + bbox[1]),
|
||||
label, fill=TEXT, font=font)
|
||||
|
||||
|
||||
def draw_trend_indicator(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, size: int,
|
||||
trend: str, value: float
|
||||
) -> None:
|
||||
"""Draw a trend indicator with arrow."""
|
||||
# Background circle
|
||||
draw.ellipse([x, y, x + size, y + size], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Trend arrow
|
||||
cx, cy = x + size // 2, y + size // 2
|
||||
arrow_size = size // 4
|
||||
|
||||
color = COLORS['good'] if trend == 'up' else COLORS['poor'] if trend == 'down' else COLORS['moderate']
|
||||
|
||||
if trend == 'up':
|
||||
# Up arrow
|
||||
points = [
|
||||
(cx, cy + arrow_size),
|
||||
(cx - arrow_size // 2, cy),
|
||||
(cx + arrow_size // 2, cy)
|
||||
]
|
||||
elif trend == 'down':
|
||||
# Down arrow
|
||||
points = [
|
||||
(cx, cy - arrow_size),
|
||||
(cx - arrow_size // 2, cy),
|
||||
(cx + arrow_size // 2, cy)
|
||||
]
|
||||
else:
|
||||
# Circle for stable
|
||||
draw.ellipse([cx - arrow_size // 2, cy - arrow_size // 2,
|
||||
cx + arrow_size // 2, cy + arrow_size // 2],
|
||||
fill=color, outline=TEXT)
|
||||
return
|
||||
|
||||
draw.polygon(points, fill=color)
|
||||
|
||||
# Value text
|
||||
font = load_font(8)
|
||||
text = f"{value:.0f}%"
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
draw.text((cx - text_width // 2, y + size + 5), text, fill=TEXT, font=font)
|
||||
|
||||
|
||||
def generate_enhanced_scorecard(data: EnhancedScorecardData, output_path: str | Path) -> Path:
|
||||
"""Generate enhanced scorecard with additional visual elements."""
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Layout - larger canvas for more elements
|
||||
width = scale(900)
|
||||
height = scale(700)
|
||||
margin = scale(20)
|
||||
|
||||
# Create image with gradient background
|
||||
img = Image.new("RGB", (width, height), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Add subtle gradient overlay
|
||||
for i in range(height):
|
||||
alpha = i / height
|
||||
color = tuple(
|
||||
int(BG_GRADIENT_START[j] + alpha * (BG_GRADIENT_END[j] - BG_GRADIENT_START[j]))
|
||||
for j in range(3)
|
||||
)
|
||||
draw.line([(0, i), (width, i)], fill=color)
|
||||
|
||||
# Header section with enhanced styling
|
||||
header_height = scale(100)
|
||||
draw_header_section(draw, margin, data, header_height)
|
||||
|
||||
# Main content area
|
||||
content_y = margin + header_height + scale(20)
|
||||
|
||||
# Left panel - enhanced score display
|
||||
left_panel_width = scale(280)
|
||||
draw_enhanced_left_panel(draw, margin, content_y, left_panel_width, data)
|
||||
|
||||
# Right panel - dimensions with trends
|
||||
right_panel_x = margin + left_panel_width + scale(20)
|
||||
right_panel_width = width - right_panel_x - margin
|
||||
draw_enhanced_right_panel(draw, right_panel_x, content_y, right_panel_width, data)
|
||||
|
||||
# Bottom recommendations
|
||||
recommendations_y = content_y + scale(200)
|
||||
draw_recommendations_section(draw, margin, recommendations_y, width - 2 * margin, data.recommendations)
|
||||
|
||||
# Footer with metadata
|
||||
footer_y = height - scale(40)
|
||||
draw_footer_section(draw, margin, footer_y, width - 2 * margin, data.metadata)
|
||||
|
||||
# Save image
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(str(output_path), "PNG", optimize=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def draw_header_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
data: EnhancedScorecardData
|
||||
) -> None:
|
||||
"""Draw enhanced header section."""
|
||||
# Project title
|
||||
font_title = load_font(18, bold=True)
|
||||
title = f"{data.project_name} Code Health"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=font_title)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
|
||||
# Background panel for title
|
||||
panel_height = scale(40)
|
||||
draw.rectangle([x, y, x + width, y + panel_height],
|
||||
fill=BG_SCORE, outline=ACCENT, width=2)
|
||||
|
||||
draw.text((x + (width - title_width) // 2, y + scale(12)),
|
||||
title, fill=TEXT, font=font_title)
|
||||
|
||||
# Version and timestamp
|
||||
font_info = load_font(10)
|
||||
timestamp_str = data.metadata.get('timestamp', '')
|
||||
timestamp_str = data.metadata.get('timestamp', '')
|
||||
info_text = "v" + str(data.version) + " - Generated " + str(data.metadata.get('timestamp', ''))
|
||||
|
||||
|
||||
def draw_enhanced_left_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
data: EnhancedScorecardData
|
||||
) -> None:
|
||||
"""Draw enhanced left panel with multiple visual elements."""
|
||||
# Main score with large display
|
||||
score_y = y + scale(20)
|
||||
score_size = scale(80)
|
||||
|
||||
# Circular progress indicator
|
||||
draw_progress_ring(draw, x + width // 2 - score_size // 2, score_y, score_size // 2,
|
||||
progress=data.main_score, color=score_to_color(data.main_score))
|
||||
|
||||
# Score text
|
||||
font_score = load_font(36, bold=True)
|
||||
score_text = f"{int(data.main_score)}"
|
||||
score_bbox = draw.textbbox((0, 0), score_text, font=font_score)
|
||||
score_width = score_bbox[2] - score_bbox[0]
|
||||
score_height = score_bbox[3] - score_bbox[1]
|
||||
|
||||
score_bg_y = score_y + score_size + scale(10)
|
||||
score_bg_height = scale(50)
|
||||
|
||||
# Background for score text
|
||||
draw.rectangle([x, score_bg_y, x + width, score_bg_y + score_bg_height],
|
||||
fill=BG_SCORE, outline=BORDER, width=1)
|
||||
|
||||
draw.text((x + (width - score_width) // 2, score_bg_y + score_bg_height // 2 - score_height // 2 + score_bbox[1]),
|
||||
score_text, fill=TEXT, font=font_score)
|
||||
|
||||
# Strict score
|
||||
strict_y = score_bg_y + score_bg_height + scale(15)
|
||||
font_strict = load_font(14)
|
||||
strict_text = f"Strict: {int(data.strict_score)}"
|
||||
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
|
||||
strict_width = strict_bbox[2] - strict_bbox[0]
|
||||
|
||||
draw.text((x + (width - strict_width) // 2, strict_y - strict_bbox[1]),
|
||||
strict_text, fill=score_to_color(data.strict_score, muted=True), font=font_strict)
|
||||
|
||||
# Grade indicator
|
||||
grade_y = strict_y + scale(30)
|
||||
grade_size = scale(40)
|
||||
grade = "A" if data.main_score >= 90 else "B" if data.main_score >= 70 else "C" if data.main_score >= 50 else "D" if data.main_score >= 30 else "F"
|
||||
|
||||
draw_progress_ring(draw, x + width // 2 - grade_size // 2, grade_y, grade_size // 2,
|
||||
progress=data.main_score, color=score_to_color(data.main_score))
|
||||
|
||||
font_grade = load_font(24, bold=True)
|
||||
grade_bbox = draw.textbbox((0, 0), grade, font=font_grade)
|
||||
grade_width = grade_bbox[2] - grade_bbox[0]
|
||||
|
||||
draw.text((x + (width - grade_width) // 2, grade_y + grade_size + scale(10) - grade_bbox[1]),
|
||||
grade, fill=TEXT, font=font_grade)
|
||||
|
||||
|
||||
def draw_enhanced_right_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
data: EnhancedScorecardData
|
||||
) -> None:
|
||||
"""Draw enhanced right panel with dimensions and trends."""
|
||||
# Dimensions table
|
||||
table_y = y + scale(20)
|
||||
table_height = scale(120)
|
||||
|
||||
draw_enhanced_dimensions_table(draw, x, table_y, width, table_height, data.dimensions)
|
||||
|
||||
# Trends section
|
||||
trends_y = table_y + table_height + scale(20)
|
||||
draw_trends_section(draw, x, trends_y, width, data.trends)
|
||||
|
||||
|
||||
def draw_enhanced_dimensions_table(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
dimensions: List[Tuple[str, Dict[str, Any]]]
|
||||
) -> None:
|
||||
"""Draw enhanced dimensions table with sparklines."""
|
||||
font_header = load_font(11, bold=True)
|
||||
font_row = load_font(9)
|
||||
|
||||
# Table header
|
||||
header_y = y
|
||||
draw.text((x, header_y), "CATEGORY", fill=TEXT, font=font_header)
|
||||
draw.text((x + scale(120), header_y), "SCORE", fill=TEXT, font=font_header)
|
||||
draw.text((x + scale(200), header_y), "TREND", fill=TEXT, font=font_header)
|
||||
|
||||
# Table rows
|
||||
row_y = header_y + scale(20)
|
||||
row_height = scale(25)
|
||||
|
||||
for i, (name, data) in enumerate(dimensions[:4]): # Limit to 4 rows
|
||||
# Background
|
||||
if i % 2 == 1:
|
||||
draw.rectangle([x, row_y, x + width, row_y + row_height], fill=BG_ROW_ALT)
|
||||
|
||||
# Category name
|
||||
draw_metric_bar(draw, bar_x, row_y + scale(5), bar_width, bar_height,
|
||||
score, 100, "", score_to_color(score))
|
||||
# Score bar
|
||||
score = data.get('score', 0)
|
||||
bar_x = x + scale(120)
|
||||
bar_width = scale(60)
|
||||
bar_height = scale(10)
|
||||
draw_metric_bar(draw, bar_x, row_y + scale(5), bar_width, bar_height,
|
||||
score, 100, "", score_to_color(score))
|
||||
|
||||
# Trend indicator
|
||||
trend_x = x + scale(200)
|
||||
trend_data = data.get('trend', [50, 55, 60]) # Sample trend data
|
||||
if len(trend_data) >= 2:
|
||||
trend = 'up' if trend_data[-1] > trend_data[-2] else 'down' if trend_data[-1] < trend_data[-2] else 'stable'
|
||||
draw_trend_indicator(draw, trend_x, row_y + scale(5), scale(20), trend, trend_data[-1])
|
||||
|
||||
row_y += row_height
|
||||
|
||||
|
||||
def draw_trends_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
trends: Dict[str, List[float]]
|
||||
) -> None:
|
||||
"""Draw trends section with sparklines."""
|
||||
font_header = load_font(11, bold=True)
|
||||
|
||||
# Section title
|
||||
draw.text((x, y), "Recent Trends", fill=TEXT, font=font_header)
|
||||
|
||||
# Trend charts
|
||||
chart_y = y + scale(30)
|
||||
chart_width = scale(80)
|
||||
chart_height = scale(40)
|
||||
|
||||
for i, (category, values) in enumerate(list(trends.items())[:2]): # 2 charts
|
||||
chart_x = x + i * (chart_width + scale(20))
|
||||
|
||||
# Category label
|
||||
font_label = load_font(9)
|
||||
draw.text((chart_x, chart_y - scale(15), category, fill=TEXT, font=font_label)
|
||||
# Sparkline
|
||||
draw_mini_sparkline(draw, chart_x, chart_y, chart_width, chart_height,
|
||||
values, COLORS['info'])
|
||||
draw_mini_sparkline(draw, chart_x, chart_y, chart_width, chart_height,
|
||||
values, COLORS['info'])
|
||||
|
||||
|
||||
def draw_recommendations_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
recommendations: List[str]
|
||||
) -> None:
|
||||
"""Draw recommendations section."""
|
||||
font_header = load_font(12, bold=True)
|
||||
font_rec = load_font(9)
|
||||
|
||||
# Section title
|
||||
draw.text((x, y), "🎯 Priority Actions", fill=TEXT, font=font_header)
|
||||
|
||||
# Recommendations list
|
||||
rec_y = y + scale(25)
|
||||
for i, rec in enumerate(recommendations[:4]): # Limit to 4 recommendations
|
||||
# Numbered bullet
|
||||
bullet = f"{i + 1}."
|
||||
draw.text((x + scale(10), rec_y, bullet, fill=ACCENT, font=font_rec)
|
||||
|
||||
# Recommendation text (with word wrap)
|
||||
text_x = x + scale(30)
|
||||
max_width = width - scale(40)
|
||||
|
||||
# Simple word wrap
|
||||
words = rec.split()
|
||||
line = ""
|
||||
for word in words:
|
||||
test_line = line + " " + word if line else word
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font_rec)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
if text_width > max_width or line:
|
||||
draw.text((text_x, rec_y), line, fill=TEXT, font=font_rec)
|
||||
rec_y += scale(12)
|
||||
line = word if line else ""
|
||||
text_x = x + scale(30)
|
||||
else:
|
||||
line = test_line
|
||||
|
||||
rec_y += scale(15)
|
||||
|
||||
|
||||
def draw_footer_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int,
|
||||
metadata: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Draw footer with metadata."""
|
||||
font_footer = load_font(8)
|
||||
|
||||
# Footer line
|
||||
draw.line([(x, y), (x + width, y)], fill=BORDER, width=1)
|
||||
|
||||
# Metadata text
|
||||
files_analyzed = metadata.get('files_analyzed', 0)
|
||||
timestamp_str = metadata.get('timestamp', '')
|
||||
info_text = f"Generated by Devour v{metadata.get('version', '1.0.0')} - {files_analyzed} files analyzed - {timestamp_str}"
|
||||
draw.text((x, y + scale(5), info_text, fill=DIM, font=font_footer)
|
||||
|
||||
|
||||
def draw_metric_bar(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
value: float, max_value: float,
|
||||
label: str, color: Tuple[int, int, int]
|
||||
) -> None:
|
||||
"""Draw a horizontal metric bar."""
|
||||
# Background
|
||||
draw.rectangle([x, y, x + width, y + height], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Fill bar
|
||||
if max_value > 0:
|
||||
fill_width = int((value / max_value) * width)
|
||||
draw.rectangle([x, y, x + fill_width, y + height], fill=color)
|
||||
|
||||
|
||||
def load_enhanced_devour_data(json_path: str) -> EnhancedScorecardData:
|
||||
"""Load Devour data and convert to enhanced format."""
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
findings = data.get('findings', [])
|
||||
|
||||
# Calculate scores
|
||||
total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings)
|
||||
main_score = max(0, 100 - (total_score / 1000 * 100))
|
||||
strict_score = main_score * 0.8 # Strict is lower
|
||||
|
||||
# Group by type
|
||||
type_counts = {}
|
||||
type_scores = {}
|
||||
|
||||
for finding in findings:
|
||||
ftype = finding.get('type', 'unknown')
|
||||
type_counts[ftype] = type_counts.get(ftype, 0) + 1
|
||||
type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0)
|
||||
|
||||
# Create dimensions with trend data
|
||||
dimensions = []
|
||||
for ftype, count in type_counts.items():
|
||||
avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100)
|
||||
|
||||
# Generate sample trend data
|
||||
trend_data = [avg_score - 10, avg_score - 5, avg_score, avg_score + 5, avg_score + 10]
|
||||
|
||||
dimensions.append((
|
||||
ftype.replace('_', ' ').title(),
|
||||
{
|
||||
'score': max(0, min(100, avg_score)),
|
||||
'strict': max(0, min(100, avg_score * 0.8)),
|
||||
'count': count,
|
||||
'trend': trend_data
|
||||
}
|
||||
))
|
||||
|
||||
# Sort by score (lowest first)
|
||||
dimensions.sort(key=lambda x: x[1]['score'])
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = [
|
||||
"🔴 Address critical T4 issues immediately for maximum impact",
|
||||
"🟡 Focus on reducing high-severity findings (T2-T3)",
|
||||
"🟢 Improve test coverage to reduce technical debt",
|
||||
"📊 Regular code reviews to maintain quality standards"
|
||||
]
|
||||
|
||||
# Metadata
|
||||
metadata = {
|
||||
'version': '1.0.0',
|
||||
'files_analyzed': len(set(f.get('file', '') for f in findings)),
|
||||
'timestamp': data.get('timestamp', '')
|
||||
}
|
||||
|
||||
return EnhancedScorecardData(
|
||||
project_name="Devour",
|
||||
version="1.0.0",
|
||||
main_score=main_score,
|
||||
strict_score=strict_score,
|
||||
dimensions=dimensions[:6], # Limit to 6 dimensions
|
||||
trends={'overall': [main_score - 5, main_score], 'performance': [85, 88, 92, main_score]},
|
||||
recommendations=recommendations,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python devour_enhanced.py <devour_results.json> <output.png>")
|
||||
sys.exit(1)
|
||||
|
||||
json_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
if not os.path.exists(json_path):
|
||||
print(f"Error: Input file {json_path} not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = load_enhanced_devour_data(json_path)
|
||||
result_path = generate_enhanced_scorecard(data, output_path)
|
||||
print(f"Enhanced scorecard generated: {result_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating enhanced scorecard: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Devour Lighthouse-Style Scorecard Generator.
|
||||
Creates circular gauge charts and comprehensive metrics visualization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("Error: PIL/Pillow required. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Visual constants
|
||||
SCALE = 2
|
||||
BG = (248, 248, 246)
|
||||
FRAME = (222, 222, 220)
|
||||
BORDER = (200, 200, 198)
|
||||
ACCENT = (88, 166, 255)
|
||||
TEXT = (40, 44, 52)
|
||||
DIM = (140, 140, 140)
|
||||
BG_SCORE = (255, 255, 255)
|
||||
BG_TABLE = (255, 255, 255)
|
||||
BG_ROW_ALT = (250, 250, 248)
|
||||
|
||||
# Color palette for gauges
|
||||
COLORS = {
|
||||
'excellent': (68, 120, 68), # deep sage
|
||||
'good': (120, 140, 72), # olive green
|
||||
'moderate': (145, 155, 80), # yellow-green
|
||||
'poor': (255, 193, 7), # orange
|
||||
'critical': (220, 38, 127), # red
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class LighthouseData:
|
||||
"""Data structure for Lighthouse-style visualization."""
|
||||
project_name: str
|
||||
version: str
|
||||
overall_score: float
|
||||
overall_grade: str
|
||||
categories: Dict[str, Dict[str, Any]]
|
||||
metrics: Dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
|
||||
def load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont:
|
||||
"""Load font with fallback."""
|
||||
size = size * SCALE
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"arial.ttf"
|
||||
]
|
||||
|
||||
if bold:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"arialbd.ttf"
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def scale(value: int) -> int:
|
||||
"""Scale value by retina factor."""
|
||||
return value * SCALE
|
||||
|
||||
|
||||
def score_to_color(score: float) -> Tuple[int, int, int]:
|
||||
"""Convert score to color."""
|
||||
if score >= 90:
|
||||
return COLORS['excellent']
|
||||
elif score >= 70:
|
||||
return COLORS['good']
|
||||
elif score >= 50:
|
||||
return COLORS['moderate']
|
||||
elif score >= 30:
|
||||
return COLORS['poor']
|
||||
else:
|
||||
return COLORS['critical']
|
||||
|
||||
|
||||
def draw_circular_gauge(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int, cy: int, radius: int,
|
||||
score: float, max_score: float = 100,
|
||||
label: str = "SCORE"
|
||||
) -> None:
|
||||
"""Draw a circular gauge like Lighthouse."""
|
||||
# Background circle
|
||||
draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius],
|
||||
fill=BG_TABLE, outline=BORDER, width=2)
|
||||
|
||||
# Calculate angle for score (270° to -90° range)
|
||||
start_angle = 270
|
||||
score_angle = start_angle - (score / max_score) * 360
|
||||
|
||||
# Draw colored arc
|
||||
if score > 0:
|
||||
draw.pieslice([cx - radius, cy - radius, cx + radius, cy + radius],
|
||||
start=start_angle, end=score_angle,
|
||||
fill=score_to_color(score))
|
||||
|
||||
# Inner circle
|
||||
inner_radius = radius * 0.7
|
||||
draw.ellipse([cx - inner_radius, cy - inner_radius, cx + inner_radius, cy + inner_radius],
|
||||
fill=BG_SCORE, outline=BORDER, width=1)
|
||||
|
||||
# Score text
|
||||
font_score = load_font(24, bold=True)
|
||||
score_text = f"{int(score)}"
|
||||
bbox = draw.textbbox((0, 0), score_text, font=font_score)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
draw.text((cx - text_width // 2, cy - text_height // 2 + bbox[1]),
|
||||
score_text, fill=TEXT, font=font_score)
|
||||
|
||||
# Label text
|
||||
font_label = load_font(10)
|
||||
label_bbox = draw.textbbox((0, 0), label, font=font_label)
|
||||
label_width = label_bbox[2] - label_bbox[0]
|
||||
|
||||
draw.text((cx - label_width // 2, cy + radius * 0.4),
|
||||
label, fill=DIM, font=font_label)
|
||||
|
||||
|
||||
def draw_metric_bar(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
value: float, max_value: float,
|
||||
label: str, color: Tuple[int, int, int]
|
||||
) -> None:
|
||||
"""Draw a horizontal metric bar."""
|
||||
# Background
|
||||
draw.rectangle([x, y, x + width, y + height], fill=BG_TABLE, outline=BORDER)
|
||||
|
||||
# Fill bar
|
||||
if max_value > 0:
|
||||
fill_width = int((value / max_value) * width)
|
||||
draw.rectangle([x, y, x + fill_width, y + height], fill=color)
|
||||
|
||||
# Label
|
||||
font = load_font(9)
|
||||
text = f"{label}: {int(value)}/{int(max_value)}"
|
||||
draw.text((x + 5, y + 2), text, fill=TEXT, font=font)
|
||||
|
||||
|
||||
def draw_category_section(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int, y: int, width: int, height: int,
|
||||
title: str, score: float, issues: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Draw a category section with gauge and issues."""
|
||||
# Title
|
||||
font_title = load_font(12, bold=True)
|
||||
draw.text((x, y), title, fill=TEXT, font=font_title)
|
||||
|
||||
# Gauge
|
||||
gauge_y = y + 25
|
||||
gauge_size = 60
|
||||
draw_circular_gauge(draw, x + gauge_size // 2, gauge_y + gauge_size // 2,
|
||||
gauge_size // 2, score, label="")
|
||||
|
||||
# Issues list
|
||||
issues_y = gauge_y + gauge_size + 20
|
||||
font_issue = load_font(8)
|
||||
|
||||
for i, issue in enumerate(issues[:5]): # Limit to 5 issues
|
||||
issue_text = f"• {issue.get('title', 'Unknown')}"
|
||||
if len(issue_text) > 35:
|
||||
issue_text = issue_text[:32] + "..."
|
||||
|
||||
draw.text((x, issues_y + i * 12), issue_text, fill=DIM, font=font_issue)
|
||||
|
||||
|
||||
def generate_lighthouse_scorecard(data: LighthouseData, output_path: str | Path) -> Path:
|
||||
"""Generate Lighthouse-style scorecard with circular gauges."""
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Layout
|
||||
width = scale(800)
|
||||
height = scale(600)
|
||||
margin = scale(20)
|
||||
|
||||
# Create image
|
||||
img = Image.new("RGB", (width, height), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Header
|
||||
font_header = load_font(20, bold=True)
|
||||
title = f"{data.project_name} - Code Health Report"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=font_header)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
|
||||
draw.text((margin, margin), title, fill=TEXT, font=font_header)
|
||||
|
||||
# Overall score gauge (large, centered)
|
||||
overall_x = width // 2
|
||||
overall_y = margin + 50
|
||||
overall_radius = scale(80)
|
||||
|
||||
draw_circular_gauge(draw, overall_x, overall_y, overall_radius,
|
||||
score=data.overall_score, label="OVERALL")
|
||||
|
||||
# Grade text
|
||||
font_grade = load_font(32, bold=True)
|
||||
grade_text = data.overall_grade
|
||||
grade_bbox = draw.textbbox((0, 0), grade_text, font=font_grade)
|
||||
grade_width = grade_bbox[2] - grade_bbox[0]
|
||||
grade_height = grade_bbox[3] - grade_bbox[1]
|
||||
|
||||
grade_y = overall_y + overall_radius + scale(30)
|
||||
draw.text((overall_x - grade_width // 2, grade_y - grade_bbox[1]),
|
||||
grade_text, fill=score_to_color(data.overall_score), font=font_grade)
|
||||
|
||||
# Category gauges (2x2 grid)
|
||||
categories_start_y = grade_y + grade_height + scale(40)
|
||||
category_width = scale(180)
|
||||
category_height = scale(150)
|
||||
category_spacing = scale(20)
|
||||
|
||||
col_x = margin
|
||||
row_y = categories_start_y
|
||||
|
||||
for i, (category_name, category_data) in enumerate(data.categories.items()):
|
||||
if i > 0 and i % 2 == 0:
|
||||
col_x += category_width + category_spacing
|
||||
row_y = categories_start_y
|
||||
|
||||
if i % 2 == 1:
|
||||
row_y += category_height + category_spacing
|
||||
|
||||
draw_category_section(
|
||||
draw, col_x, row_y, category_width, category_height,
|
||||
title=category_name.replace('_', ' ').title(),
|
||||
score=category_data.get('score', 0),
|
||||
issues=category_data.get('issues', [])
|
||||
)
|
||||
|
||||
# Metrics summary
|
||||
metrics_y = row_y + category_height + scale(40)
|
||||
font_metrics = load_font(10)
|
||||
|
||||
metrics_text = [
|
||||
f"Generated: {data.timestamp}",
|
||||
f"Total Issues: {data.metrics.get('total_issues', 0)}",
|
||||
f"Critical: {data.metrics.get('critical_issues', 0)}",
|
||||
f"Resolution Rate: {data.metrics.get('resolution_rate', 0):.1f}%"
|
||||
]
|
||||
|
||||
for i, text in enumerate(metrics_text):
|
||||
draw.text((margin, metrics_y + i * 15), text, fill=DIM, font=font_metrics)
|
||||
|
||||
# Save
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(str(output_path), "PNG", optimize=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont:
|
||||
"""Load font with fallback."""
|
||||
size = size * SCALE
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"arial.ttf"
|
||||
]
|
||||
|
||||
if bold:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"arialbd.ttf"
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def load_devour_lighthouse_data(json_path: str) -> LighthouseData:
|
||||
"""Load Devour data and convert to Lighthouse format."""
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
findings = data.get('findings', [])
|
||||
|
||||
# Calculate overall score
|
||||
total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings)
|
||||
overall_score = max(0, 100 - (total_score / 1000 * 100))
|
||||
|
||||
# Grade
|
||||
if overall_score >= 90:
|
||||
grade = "A"
|
||||
elif overall_score >= 80:
|
||||
grade = "B"
|
||||
elif overall_score >= 70:
|
||||
grade = "C"
|
||||
elif overall_score >= 60:
|
||||
grade = "D"
|
||||
else:
|
||||
grade = "F"
|
||||
|
||||
# Group by category
|
||||
categories = {}
|
||||
type_counts = {}
|
||||
type_scores = {}
|
||||
|
||||
for finding in findings:
|
||||
ftype = finding.get('type', 'unknown')
|
||||
type_counts[ftype] = type_counts.get(ftype, 0) + 1
|
||||
type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0)
|
||||
|
||||
# Create categories with scores and sample issues
|
||||
for ftype, count in type_counts.items():
|
||||
avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100)
|
||||
category_score = max(0, min(100, avg_score))
|
||||
|
||||
# Get sample issues for this category
|
||||
category_issues = [
|
||||
{
|
||||
'title': f.get('title', 'Unknown'),
|
||||
'score': f.get('score', 0)
|
||||
}
|
||||
for f in findings
|
||||
if f.get('type') == ftype
|
||||
][:3] # Top 3 issues
|
||||
|
||||
categories[ftype.replace('_', ' ').title()] = {
|
||||
'score': category_score,
|
||||
'issues': category_issues
|
||||
}
|
||||
|
||||
# Metrics
|
||||
metrics = {
|
||||
'total_issues': len(findings),
|
||||
'critical_issues': len([f for f in findings if f.get('severity') == 4]),
|
||||
'resolution_rate': 0.0 # Would calculate from fixed/resolved
|
||||
}
|
||||
|
||||
return LighthouseData(
|
||||
project_name="Devour",
|
||||
version="1.0.0",
|
||||
overall_score=overall_score,
|
||||
overall_grade=grade,
|
||||
categories=categories,
|
||||
metrics=metrics,
|
||||
timestamp=data.get('timestamp', '')
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python devour_lighthouse.py <devour_results.json> <output.png>")
|
||||
sys.exit(1)
|
||||
|
||||
json_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
if not os.path.exists(json_path):
|
||||
print(f"Error: Input file {json_path} not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = load_devour_lighthouse_data(json_path)
|
||||
result_path = generate_lighthouse_scorecard(data, output_path)
|
||||
print(f"Lighthouse scorecard generated: {result_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating Lighthouse scorecard: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,518 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Devour Scorecard Generator - 1:1 recreation of desloppify scorecard style.
|
||||
Generates visual health summary PNG with the exact same data structure and visual design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("Error: PIL/Pillow required. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Visual constants matching desloppify theme
|
||||
SCALE = 2 # 2x for retina/high-DPI
|
||||
BG = (248, 248, 246) # Light gray background
|
||||
FRAME = (222, 222, 220) # Border frame
|
||||
BORDER = (200, 200, 198) # Inner border
|
||||
ACCENT = (88, 166, 255) # Blue accent
|
||||
TEXT = (40, 44, 52) # Dark text
|
||||
DIM = (140, 140, 140) # Dimmed text
|
||||
BG_SCORE = (255, 255, 255) # Score background
|
||||
BG_TABLE = (255, 255, 255) # Table background
|
||||
BG_ROW_ALT = (250, 250, 248) # Alternating row background
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScorecardData:
|
||||
"""Data structure matching desloppify scorecard format."""
|
||||
project_name: str
|
||||
version: str
|
||||
main_score: float
|
||||
strict_score: float
|
||||
dimensions: List[Tuple[str, Dict[str, Any]]]
|
||||
|
||||
|
||||
def score_color(score: float, *, muted: bool = False) -> Tuple[int, int, int]:
|
||||
"""Color-code a score: deep sage >= 90, mustard 70-90, dusty rose < 70.
|
||||
|
||||
muted=True returns a desaturated variant for secondary display (strict column).
|
||||
"""
|
||||
if score >= 90:
|
||||
base = (68, 120, 68) # deep sage
|
||||
elif score >= 70:
|
||||
base = (120, 140, 72) # olive green
|
||||
else:
|
||||
base = (145, 155, 80) # yellow-green
|
||||
|
||||
if not muted:
|
||||
return base
|
||||
# Pastel orange shades for strict column
|
||||
if score >= 90:
|
||||
return (195, 160, 115) # light sandy peach
|
||||
if score >= 70:
|
||||
return (200, 148, 100) # warm apricot
|
||||
return (195, 125, 95) # soft coral
|
||||
|
||||
|
||||
def fmt_score(score: float) -> str:
|
||||
"""Format score with one decimal place, dropping .0 for integers."""
|
||||
if score == int(score):
|
||||
return str(int(score))
|
||||
return f"{score:.1f}"
|
||||
|
||||
|
||||
def scale(value: int) -> int:
|
||||
"""Scale value by retina factor."""
|
||||
return value * SCALE
|
||||
|
||||
|
||||
def load_font(size: int, *, serif: bool = False, bold: bool = False, mono: bool = False) -> ImageFont.ImageFont:
|
||||
"""Load a font with cross-platform fallback."""
|
||||
size = size * SCALE
|
||||
candidates = []
|
||||
|
||||
if mono:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFNSMono.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
||||
"DejaVuSansMono.ttf",
|
||||
]
|
||||
elif serif and bold:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/Supplemental/Georgia Bold.ttf",
|
||||
"/System/Library/Fonts/NewYork.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
|
||||
"DejaVuSerif-Bold.ttf",
|
||||
]
|
||||
elif serif:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/Supplemental/Georgia.ttf",
|
||||
"/System/Library/Fonts/NewYork.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
|
||||
"DejaVuSerif.ttf",
|
||||
]
|
||||
elif bold:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/System/Library/Fonts/HelveticaNeue.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||
"DejaVuSans-Bold.ttf",
|
||||
]
|
||||
else:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/System/Library/Fonts/HelveticaNeue.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"DejaVuSans.ttf",
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Fallback to default font
|
||||
try:
|
||||
return ImageFont.load_default()
|
||||
except:
|
||||
return ImageFont.truetype("arial.ttf", size)
|
||||
|
||||
|
||||
def draw_left_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
main_score: float,
|
||||
strict_score: float,
|
||||
project_name: str,
|
||||
version: str,
|
||||
*,
|
||||
lp_left: int,
|
||||
lp_right: int,
|
||||
lp_top: int,
|
||||
lp_bot: int,
|
||||
) -> None:
|
||||
"""Draw left panel with title, scores, and project info."""
|
||||
# Fonts
|
||||
font_title = load_font(16, bold=True)
|
||||
font_big = load_font(48, bold=True)
|
||||
font_version = load_font(11)
|
||||
font_strict = load_font(12, bold=True)
|
||||
|
||||
# Title
|
||||
title = "CODE HEALTH"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=font_title)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
title_y = lp_top + scale(8)
|
||||
center_x = (lp_left + lp_right) // 2
|
||||
|
||||
draw.text(
|
||||
(center_x - title_width / 2, title_y - title_bbox[1]),
|
||||
title,
|
||||
fill=TEXT,
|
||||
font=font_title,
|
||||
)
|
||||
|
||||
# Main score
|
||||
score_y = title_y + scale(35)
|
||||
score_text = fmt_score(main_score)
|
||||
score_bbox = draw.textbbox((0, 0), score_text, font=font_big)
|
||||
score_width = score_bbox[2] - score_bbox[0]
|
||||
|
||||
# Score background
|
||||
score_bg_y = score_y - scale(5)
|
||||
score_bg_h = scale(55)
|
||||
draw.rectangle(
|
||||
(lp_left + scale(10), score_bg_y, lp_right - scale(10), score_bg_y + score_bg_h),
|
||||
fill=BG_SCORE,
|
||||
outline=BORDER,
|
||||
width=1,
|
||||
)
|
||||
|
||||
draw.text(
|
||||
(center_x - score_width / 2, score_y - score_bbox[1]),
|
||||
score_text,
|
||||
fill=score_color(main_score),
|
||||
font=font_big,
|
||||
)
|
||||
|
||||
# Strict score
|
||||
strict_y = score_bg_y + score_bg_h + scale(12)
|
||||
strict_text = f"Strict: {fmt_score(strict_score)}"
|
||||
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
|
||||
strict_width = strict_bbox[2] - strict_bbox[0]
|
||||
|
||||
draw.text(
|
||||
(center_x - strict_width / 2, strict_y - strict_bbox[1]),
|
||||
strict_text,
|
||||
fill=score_color(strict_score, muted=True),
|
||||
font=font_strict,
|
||||
)
|
||||
|
||||
# Project info
|
||||
info_y = strict_y + scale(25)
|
||||
|
||||
# Project name
|
||||
name_text = project_name.upper()
|
||||
name_bbox = draw.textbbox((0, 0), name_text, font=font_version)
|
||||
name_width = name_bbox[2] - name_bbox[0]
|
||||
|
||||
draw.text(
|
||||
(center_x - name_width / 2, info_y - name_bbox[1]),
|
||||
name_text,
|
||||
fill=DIM,
|
||||
font=font_version,
|
||||
)
|
||||
|
||||
# Version
|
||||
version_y = info_y + scale(18)
|
||||
version_text = f"v{version}" if version else "dev"
|
||||
version_bbox = draw.textbbox((0, 0), version_text, font=font_version)
|
||||
version_width = version_bbox[2] - version_bbox[0]
|
||||
|
||||
draw.text(
|
||||
(center_x - version_width / 2, version_y - version_bbox[1]),
|
||||
version_text,
|
||||
fill=DIM,
|
||||
font=font_version,
|
||||
)
|
||||
|
||||
|
||||
def draw_vert_rule_with_ornament(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int,
|
||||
y1: int,
|
||||
y2: int,
|
||||
mid_y: int,
|
||||
color: Tuple[int, int, int],
|
||||
accent: Tuple[int, int, int],
|
||||
) -> None:
|
||||
"""Draw vertical divider with ornament at center."""
|
||||
# Vertical lines
|
||||
draw.line([(x, y1), (x, mid_y - scale(8))], fill=color, width=1)
|
||||
draw.line([(x, mid_y + scale(8)), (x, y2)], fill=color, width=1)
|
||||
|
||||
# Ornament circle
|
||||
ornament_size = scale(6)
|
||||
ellipse1_coords = (
|
||||
x - ornament_size // 2, mid_y - ornament_size // 2,
|
||||
x + ornament_size // 2, mid_y + ornament_size // 2
|
||||
)
|
||||
draw.ellipse(ellipse1_coords, outline=accent, width=2)
|
||||
|
||||
ellipse2_coords = (
|
||||
x - ornament_size // 2 + 2, mid_y - ornament_size // 2 + 2,
|
||||
x + ornament_size // 2 - 2, mid_y + ornament_size // 2 - 2
|
||||
)
|
||||
draw.ellipse(ellipse2_coords, outline=color, width=1)
|
||||
|
||||
|
||||
def draw_right_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
active_dims: List[Tuple[str, Dict[str, Any]]],
|
||||
row_h: int,
|
||||
*,
|
||||
table_x1: int,
|
||||
table_x2: int,
|
||||
table_top: int,
|
||||
table_bot: int,
|
||||
) -> None:
|
||||
"""Draw right panel: two separate dimension tables side by side."""
|
||||
font_row = load_font(11, mono=True)
|
||||
font_strict = load_font(9, mono=True)
|
||||
row_count = len(active_dims)
|
||||
|
||||
cols = 2
|
||||
rows_per_col = (row_count + cols - 1) // cols
|
||||
table_width = table_x2 - table_x1
|
||||
grid_gap = scale(8)
|
||||
grid_width = (table_width - grid_gap) // cols
|
||||
|
||||
for col_index in range(cols):
|
||||
grid_x1 = table_x1 + col_index * (grid_width + grid_gap)
|
||||
grid_x2 = grid_x1 + grid_width
|
||||
draw.rounded_rectangle(
|
||||
(grid_x1, table_top, grid_x2, table_bot),
|
||||
radius=scale(4),
|
||||
fill=BG_TABLE,
|
||||
outline=BORDER,
|
||||
width=1,
|
||||
)
|
||||
|
||||
name_col_width = scale(120)
|
||||
value_col_gap = scale(4)
|
||||
value_col_width = scale(34)
|
||||
total_content_width = (
|
||||
name_col_width
|
||||
+ value_col_gap
|
||||
+ value_col_width
|
||||
+ value_col_gap
|
||||
+ value_col_width
|
||||
)
|
||||
block_left = grid_x1 + (grid_width - total_content_width) // 2
|
||||
name_col_x = block_left
|
||||
health_col_x = name_col_x + name_col_width + value_col_gap
|
||||
strict_col_x = health_col_x + value_col_width + value_col_gap + scale(4)
|
||||
|
||||
rows_this_col = min(rows_per_col, row_count - col_index * rows_per_col)
|
||||
content_height = rows_this_col * row_h
|
||||
content_top = (table_top + table_bot) // 2 - content_height // 2
|
||||
|
||||
sample_bbox = draw.textbbox((0, 0), "Xg", font=font_row)
|
||||
row_text_height = sample_bbox[3] - sample_bbox[1]
|
||||
row_text_offset = sample_bbox[1]
|
||||
start_idx = col_index * rows_per_col
|
||||
|
||||
for row_index in range(rows_this_col):
|
||||
dim_idx = start_idx + row_index
|
||||
if dim_idx >= row_count:
|
||||
break
|
||||
name, data = active_dims[dim_idx]
|
||||
band_top = content_top + row_index * row_h
|
||||
band_bottom = band_top + row_h
|
||||
if row_index % 2 == 1:
|
||||
draw.rectangle(
|
||||
(grid_x1 + 1, band_top, grid_x2 - 1, band_bottom), fill=BG_ROW_ALT
|
||||
)
|
||||
text_y = band_top + (row_h - row_text_height) // 2 - row_text_offset + scale(1)
|
||||
score = data.get("score", 100)
|
||||
strict = data.get("strict", score)
|
||||
|
||||
max_name_width = name_col_width - scale(2)
|
||||
while (
|
||||
name
|
||||
and draw.textlength(name + "\u2026", font=font_row) > max_name_width
|
||||
):
|
||||
name = name[:-1]
|
||||
if draw.textlength(name, font=font_row) > max_name_width:
|
||||
name = name.rstrip() + "\u2026"
|
||||
|
||||
draw.text((name_col_x, text_y), name, fill=TEXT, font=font_row)
|
||||
draw.text(
|
||||
(health_col_x, text_y),
|
||||
f"{fmt_score(score)}%",
|
||||
fill=score_color(score),
|
||||
font=font_row,
|
||||
)
|
||||
|
||||
strict_text = f"{fmt_score(strict)}%"
|
||||
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
|
||||
strict_text_height = strict_bbox[3] - strict_bbox[1]
|
||||
strict_y = band_top + (row_h - strict_text_height) // 2 - strict_bbox[1]
|
||||
draw.text(
|
||||
(strict_col_x, strict_y),
|
||||
strict_text,
|
||||
fill=score_color(strict, muted=True),
|
||||
font=font_strict,
|
||||
)
|
||||
|
||||
|
||||
def generate_scorecard(data: ScorecardData, output_path: str | Path) -> Path:
|
||||
"""Render a landscape scorecard PNG from scorecard data. Returns output path."""
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Layout — landscape (wide), dimensions first
|
||||
row_count = len(data.dimensions)
|
||||
row_h = scale(20)
|
||||
width = scale(780)
|
||||
divider_x = scale(260)
|
||||
frame_inset = scale(5)
|
||||
|
||||
cols = 2
|
||||
rows_per_col = (row_count + cols - 1) // cols
|
||||
table_content_h = scale(14) + scale(4) + scale(6) + rows_per_col * row_h
|
||||
content_h = max(table_content_h + scale(28), scale(150))
|
||||
height = scale(12) + content_h
|
||||
|
||||
# Create image
|
||||
img = Image.new("RGB", (width, height), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Double frame
|
||||
draw.rectangle((0, 0, width - 1, height - 1), outline=FRAME, width=scale(2))
|
||||
draw.rectangle(
|
||||
(frame_inset, frame_inset, width - frame_inset - 1, height - frame_inset - 1),
|
||||
outline=BORDER,
|
||||
width=1,
|
||||
)
|
||||
|
||||
content_top = frame_inset + scale(1)
|
||||
content_bot = height - frame_inset - scale(1)
|
||||
content_mid_y = (content_top + content_bot) // 2
|
||||
|
||||
# Left panel: title + score + project name
|
||||
draw_left_panel(
|
||||
draw,
|
||||
data.main_score,
|
||||
data.strict_score,
|
||||
data.project_name,
|
||||
data.version,
|
||||
lp_left=frame_inset + scale(11),
|
||||
lp_right=divider_x - scale(11),
|
||||
lp_top=content_top + scale(4),
|
||||
lp_bot=content_bot - scale(4),
|
||||
)
|
||||
|
||||
# Vertical divider with ornament
|
||||
draw_vert_rule_with_ornament(
|
||||
draw,
|
||||
divider_x,
|
||||
content_top + scale(12),
|
||||
content_bot - scale(12),
|
||||
content_mid_y,
|
||||
BORDER,
|
||||
ACCENT,
|
||||
)
|
||||
|
||||
# Right panel: dimension table
|
||||
draw_right_panel(
|
||||
draw,
|
||||
data.dimensions,
|
||||
row_h,
|
||||
table_x1=divider_x + scale(11),
|
||||
table_x2=width - frame_inset - scale(11),
|
||||
table_top=content_top + scale(4),
|
||||
table_bot=content_bot - scale(4),
|
||||
)
|
||||
|
||||
# Save image
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(str(output_path), "PNG", optimize=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def load_devour_data(json_path: str) -> ScorecardData:
|
||||
"""Load Devour scan results and convert to scorecard format."""
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Extract findings
|
||||
findings = data.get('findings', [])
|
||||
|
||||
# Calculate scores
|
||||
total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings)
|
||||
strict_score = total_score # Simplified - would use strict scoring logic
|
||||
|
||||
# Convert to percentage (inverted)
|
||||
main_score = max(0, 100 - (total_score / 1000 * 100))
|
||||
strict_score_pct = max(0, 100 - (strict_score / 1000 * 100))
|
||||
|
||||
# Group by type for dimensions
|
||||
type_counts = {}
|
||||
type_scores = {}
|
||||
for finding in findings:
|
||||
ftype = finding.get('type', 'unknown')
|
||||
type_counts[ftype] = type_counts.get(ftype, 0) + 1
|
||||
type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0)
|
||||
|
||||
# Create dimensions list
|
||||
dimensions = []
|
||||
for ftype, count in type_counts.items():
|
||||
avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100)
|
||||
dimensions.append((
|
||||
ftype.replace('_', ' ').title(),
|
||||
{
|
||||
'score': max(0, min(100, avg_score)),
|
||||
'strict': max(0, min(100, avg_score * 0.8)), # Strict is lower
|
||||
'count': count
|
||||
}
|
||||
))
|
||||
|
||||
# Sort by score (lowest first)
|
||||
dimensions.sort(key=lambda x: x[1]['score'])
|
||||
|
||||
return ScorecardData(
|
||||
project_name="Devour",
|
||||
version="1.0.0",
|
||||
main_score=main_score,
|
||||
strict_score=strict_score_pct,
|
||||
dimensions=dimensions[:8], # Limit to 8 dimensions
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python devour_scorecard.py <devour_results.json> <output.png>")
|
||||
sys.exit(1)
|
||||
|
||||
json_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
if not os.path.exists(json_path):
|
||||
print(f"Error: Input file {json_path} not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Load and convert data
|
||||
data = load_devour_data(json_path)
|
||||
|
||||
# Generate scorecard
|
||||
result_path = generate_scorecard(data, output_path)
|
||||
print(f"Scorecard generated: {result_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating scorecard: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"github.com/yourorg/devour/internal/quality/scorecard"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create sample quality state with some findings
|
||||
state := &quality.State{
|
||||
Findings: []quality.Finding{
|
||||
{
|
||||
ID: "complexity-1",
|
||||
Type: "complexity",
|
||||
Title: "High cyclomatic complexity",
|
||||
Description: "Function has too many nested loops and conditionals",
|
||||
File: "src/main.go",
|
||||
Line: 42,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: 15,
|
||||
Status: quality.StatusOpen,
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-2 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "naming-1",
|
||||
Type: "naming",
|
||||
Title: "Inconsistent naming convention",
|
||||
Description: "Variable name doesn't follow project conventions",
|
||||
File: "src/utils.go",
|
||||
Line: 15,
|
||||
Severity: quality.SeverityT2,
|
||||
Score: 8,
|
||||
Status: quality.StatusOpen,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-1 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "duplication-1",
|
||||
Type: "duplication",
|
||||
Title: "Code duplication detected",
|
||||
Description: "Similar code blocks found in multiple files",
|
||||
File: "src/helper.go",
|
||||
Line: 78,
|
||||
Severity: quality.SeverityT2,
|
||||
Score: 10,
|
||||
Status: quality.StatusOpen,
|
||||
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||
},
|
||||
{
|
||||
ID: "security-1",
|
||||
Type: "security",
|
||||
Title: "Potential security vulnerability",
|
||||
Description: "SQL injection possibility detected",
|
||||
File: "src/database.go",
|
||||
Line: 120,
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 25,
|
||||
Status: quality.StatusOpen,
|
||||
CreatedAt: time.Now().Add(-4 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-4 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "unused_import-1",
|
||||
Type: "unused_import",
|
||||
Title: "Unused import detected",
|
||||
Description: "Import statement is not used in the file",
|
||||
File: "src/types.go",
|
||||
Line: 5,
|
||||
Severity: quality.SeverityT1,
|
||||
Score: 3,
|
||||
Status: quality.StatusOpen,
|
||||
CreatedAt: time.Now().Add(-15 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(-15 * time.Minute),
|
||||
},
|
||||
},
|
||||
LastScan: time.Now(),
|
||||
Scorecard: &quality.Scorecard{
|
||||
TotalScore: 72,
|
||||
StrictScore: 68,
|
||||
FindingsByType: map[string]int{
|
||||
"complexity": 1,
|
||||
"naming": 1,
|
||||
"duplication": 1,
|
||||
"security": 1,
|
||||
"unused_import": 1,
|
||||
},
|
||||
FindingsByTier: map[quality.Severity]int{
|
||||
quality.SeverityT1: 1,
|
||||
quality.SeverityT2: 2,
|
||||
quality.SeverityT3: 1,
|
||||
quality.SeverityT4: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate scorecard data
|
||||
scoreData := scorecard.FromQualityState(state, "Devour Demo", "1.0.0")
|
||||
|
||||
// Generate different types of scorecards
|
||||
fmt.Println("Generating sample scorecards...")
|
||||
|
||||
// Generate standard badge
|
||||
if err := scorecard.Generate(scoreData, "scorecard_badge.png"); err != nil {
|
||||
fmt.Printf("Error generating badge: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Generated scorecard_badge.png")
|
||||
}
|
||||
|
||||
// Generate compact scorecard
|
||||
if err := scorecard.GenerateCompact(scoreData, "scorecard_compact.png"); err != nil {
|
||||
fmt.Printf("Error generating compact: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Generated scorecard_compact.png")
|
||||
}
|
||||
|
||||
// Generate detailed scorecard
|
||||
if err := scorecard.GenerateDetailed(scoreData, "scorecard_detailed.png"); err != nil {
|
||||
fmt.Printf("Error generating detailed: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Generated scorecard_detailed.png")
|
||||
}
|
||||
|
||||
// Generate dark theme versions
|
||||
if err := scorecard.GenerateDark(scoreData, "scorecard_badge_dark.png"); err != nil {
|
||||
fmt.Printf("Error generating dark badge: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Generated scorecard_badge_dark.png")
|
||||
}
|
||||
|
||||
if err := scorecard.GenerateCompactDark(scoreData, "scorecard_compact_dark.png"); err != nil {
|
||||
fmt.Printf("Error generating dark compact: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Generated scorecard_compact_dark.png")
|
||||
}
|
||||
|
||||
if err := scorecard.GenerateDetailedDark(scoreData, "scorecard_detailed_dark.png"); err != nil {
|
||||
fmt.Printf("Error generating dark detailed: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Generated scorecard_detailed_dark.png")
|
||||
}
|
||||
|
||||
fmt.Println("\nScorecard generation complete!")
|
||||
fmt.Printf("Project: %s\n", scoreData.ProjectName)
|
||||
fmt.Printf("Version: %s\n", scoreData.Version)
|
||||
fmt.Printf("Overall Score: %.1f\n", scoreData.OverallScore)
|
||||
fmt.Printf("Strict Score: %.1f\n", scoreData.StrictScore)
|
||||
fmt.Printf("Grade: %s\n", scoreData.Grade)
|
||||
fmt.Printf("Total Findings: %d\n", scoreData.FindingsTotal)
|
||||
fmt.Printf("Open Findings: %d\n", scoreData.FindingsOpen)
|
||||
}
|
||||
+8
-49
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/yourorg/devour/internal/quality/plugins"
|
||||
"github.com/yourorg/devour/internal/quality/plugins/go/fixers"
|
||||
"github.com/yourorg/devour/internal/quality/review"
|
||||
"github.com/yourorg/devour/internal/quality/scorecard"
|
||||
|
||||
_ "github.com/yourorg/devour/internal/quality/plugins/go"
|
||||
)
|
||||
@@ -104,7 +103,6 @@ var explain bool
|
||||
var tier int
|
||||
var resolveNote string
|
||||
var attest string
|
||||
var qualityUseAST bool
|
||||
var statusNarrative bool
|
||||
var fixDryRun bool
|
||||
var fixAll bool
|
||||
@@ -145,13 +143,13 @@ func init() {
|
||||
scanCmd.Flags().IntVar(&qualityThreshold, "threshold", 15, "Minimum score to flag an issue")
|
||||
scanCmd.Flags().IntVar(&qualityMinLOC, "min-loc", 50, "Minimum lines of code to analyze")
|
||||
scanCmd.Flags().IntVar(&qualityTargetScore, "target-score", 95, "Target health score")
|
||||
scanCmd.Flags().StringVar(&qualityFormat, "format", "text", "Output format (text, json)")
|
||||
scanCmd.Flags().StringVar(&qualityFormat, "format", "text", "Output format (text, json, strict)")
|
||||
scanCmd.Flags().BoolVar(&qualityResetSubjective, "reset-subjective", false, "Reset subjective baseline")
|
||||
scanCmd.Flags().BoolVar(&qualityNoBadge, "no-badge", false, "Skip badge generation")
|
||||
scanCmd.Flags().StringVar(&qualityBadgePath, "badge-path", "scorecard.png", "Badge output path")
|
||||
|
||||
// Status flags
|
||||
qualityStatusCmd.Flags().StringVar(&qualityFormat, "format", "text", "Output format (text, json)")
|
||||
qualityStatusCmd.Flags().StringVar(&qualityFormat, "format", "text", "Output format (text, json, strict)")
|
||||
qualityStatusCmd.Flags().BoolVar(&statusNarrative, "narrative", false, "Include narrative analysis")
|
||||
|
||||
// Next flags
|
||||
@@ -256,6 +254,9 @@ func runQualityStatus(cmd *cobra.Command, args []string) error {
|
||||
switch qualityFormat {
|
||||
case "json":
|
||||
return json.NewEncoder(os.Stdout).Encode(scorecard)
|
||||
case "strict":
|
||||
fmt.Println(scorer.FormatStrictScorecard(findings, lastScan))
|
||||
return nil
|
||||
default:
|
||||
fmt.Println(scorer.FormatScorecard(scorecard))
|
||||
return nil
|
||||
@@ -454,13 +455,9 @@ func outputScanResult(result *quality.ScanResult, format string) error {
|
||||
return fmt.Errorf("failed to save results: %w", err)
|
||||
}
|
||||
|
||||
// Generate scorecard badge if not disabled
|
||||
// Note: Scorecard generation is now handled by the dedicated 'devour scorecard' command
|
||||
if !qualityNoBadge && qualityBadgePath != "" {
|
||||
if err := generateScorecardBadge(result, qualityBadgePath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to generate scorecard badge: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Scorecard badge generated: %s\n", qualityBadgePath)
|
||||
}
|
||||
fmt.Printf("💡 Use 'devour scorecard' to generate beautiful scorecard banners\n")
|
||||
}
|
||||
|
||||
// Output based on format
|
||||
@@ -472,43 +469,6 @@ func outputScanResult(result *quality.ScanResult, format string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func generateScorecardBadge(result *quality.ScanResult, outputPath string) error {
|
||||
// Calculate grade from score
|
||||
scorer := quality.NewScorer(qualityTargetScore)
|
||||
grade := scorer.GetHealthGrade(result.StrictScore)
|
||||
|
||||
// Group findings by type and tier
|
||||
findByType := make(map[string]int)
|
||||
findByTier := make(map[string]int)
|
||||
for _, f := range result.Findings {
|
||||
findByType[f.Type]++
|
||||
tierName := fmt.Sprintf("T%d", int(f.Severity))
|
||||
findByTier[tierName]++
|
||||
}
|
||||
|
||||
// Get project name from current directory
|
||||
projectName := "devour"
|
||||
if dir, err := os.Getwd(); err == nil {
|
||||
projectName = filepath.Base(dir)
|
||||
}
|
||||
|
||||
// Prepare scorecard data
|
||||
scoreData := &scorecard.ScorecardData{
|
||||
ProjectName: projectName,
|
||||
Version: "", // Could be extracted from version info
|
||||
OverallScore: float64(result.Score),
|
||||
StrictScore: float64(result.StrictScore),
|
||||
Grade: grade,
|
||||
FindingsTotal: len(result.Findings),
|
||||
FindingsOpen: len(result.Findings), // All findings are open initially
|
||||
LastScan: result.Timestamp,
|
||||
FindByType: findByType,
|
||||
FindByTier: findByTier,
|
||||
}
|
||||
|
||||
return scorecard.Generate(scoreData, outputPath)
|
||||
}
|
||||
|
||||
func formatScanResultText(result *quality.ScanResult) error {
|
||||
fmt.Println("Code Quality Scan Results")
|
||||
fmt.Println("=======================================")
|
||||
@@ -706,13 +666,12 @@ func prepareReviewPacket(dataDir string) error {
|
||||
func importReviewResponses(dataDir string, filename string) error {
|
||||
gen := review.NewPacketGenerator(dataDir)
|
||||
|
||||
responses := make(map[string]string)
|
||||
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read responses file: %w", err)
|
||||
}
|
||||
|
||||
var responses map[string]string
|
||||
var respData struct {
|
||||
Responses map[string]string `json:"responses"`
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func main() {
|
||||
if s != nil {
|
||||
fmt.Printf(" ✓ %s\n", st)
|
||||
} else {
|
||||
fmt.Printf(" ✗ %s (not implemented)\n", st)
|
||||
fmt.Printf(" %s (not implemented)\n", st)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ func init() {
|
||||
rootCmd.AddCommand(syncCmd)
|
||||
rootCmd.AddCommand(pushCmd)
|
||||
rootCmd.AddCommand(logoCmd)
|
||||
rootCmd.AddCommand(scorecardCmd)
|
||||
}
|
||||
|
||||
// logoCmd displays the Devour character
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
scorecardCompact bool
|
||||
scorecardDetailed bool
|
||||
scorecardOutput string
|
||||
)
|
||||
|
||||
var scorecardCmd = &cobra.Command{
|
||||
Use: "scorecard",
|
||||
Short: "Generate Devour quality scorecards",
|
||||
Long: `Generate beautiful dark-themed scorecards showing code quality metrics.
|
||||
|
||||
Creates both compact and detailed PNG banners with:
|
||||
- Modern dark theme design
|
||||
- Glass morphism effects
|
||||
- Devour brand colors
|
||||
- Professional typography
|
||||
|
||||
Examples:
|
||||
devour scorecard # Generate both compact and detailed
|
||||
devour scorecard --compact # Generate only compact banner
|
||||
devour scorecard --detailed # Generate only detailed banner
|
||||
devour scorecard --output custom # Custom output filename`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
generateScorecards()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(scorecardCmd)
|
||||
scorecardCmd.Flags().BoolVar(&scorecardCompact, "compact", false, "Generate compact banner only")
|
||||
scorecardCmd.Flags().BoolVar(&scorecardDetailed, "detailed", false, "Generate detailed banner only")
|
||||
scorecardCmd.Flags().StringVarP(&scorecardOutput, "output", "o", "lighthouse_scorecard", "Output filename prefix")
|
||||
}
|
||||
|
||||
func generateScorecards() {
|
||||
// Get the current working directory (project root)
|
||||
workingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting working directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Path to the Python script relative to working directory
|
||||
pythonScriptPath := filepath.Join(workingDir, "cmd", "banner_generator", "main.py")
|
||||
|
||||
// Check if Python script exists
|
||||
if _, err := os.Stat(pythonScriptPath); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "Error: Python scorecard generator not found at %s\n", pythonScriptPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build Python command arguments
|
||||
args := []string{pythonScriptPath}
|
||||
|
||||
if scorecardCompact {
|
||||
args = append(args, "--compact")
|
||||
} else if scorecardDetailed {
|
||||
args = append(args, "--detailed")
|
||||
}
|
||||
|
||||
if scorecardOutput != "lighthouse_scorecard" {
|
||||
args = append(args, "--output", scorecardOutput)
|
||||
}
|
||||
|
||||
// Execute Python script
|
||||
fmt.Println("🎨 Generating Devour Scorecards...")
|
||||
fmt.Printf("📂 Using generator: %s\n", pythonScriptPath)
|
||||
|
||||
cmd := exec.Command("python3", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Dir = workingDir
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating scorecards: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Scorecard generation complete!")
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Devour Scorecard Generator - 1:1 recreation of desloppify scorecard style.
|
||||
Generates visual health summary PNG with the exact same data structure and visual design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("Error: PIL/Pillow required. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Visual constants matching desloppify theme
|
||||
SCALE = 2 # 2x for retina/high-DPI
|
||||
BG = (248, 248, 246) # Light gray background
|
||||
FRAME = (222, 222, 220) # Border frame
|
||||
BORDER = (200, 200, 198) # Inner border
|
||||
ACCENT = (88, 166, 255) # Blue accent
|
||||
TEXT = (40, 44, 52) # Dark text
|
||||
DIM = (140, 140, 140) # Dimmed text
|
||||
BG_SCORE = (255, 255, 255) # Score background
|
||||
BG_TABLE = (255, 255, 255) # Table background
|
||||
BG_ROW_ALT = (250, 250, 248) # Alternating row background
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScorecardData:
|
||||
"""Data structure matching desloppify scorecard format."""
|
||||
project_name: str
|
||||
version: str
|
||||
main_score: float
|
||||
strict_score: float
|
||||
dimensions: List[Tuple[str, Dict[str, Any]]]
|
||||
|
||||
|
||||
def score_color(score: float, *, muted: bool = False) -> Tuple[int, int, int]:
|
||||
"""Color-code a score: deep sage >= 90, mustard 70-90, dusty rose < 70.
|
||||
|
||||
muted=True returns a desaturated variant for secondary display (strict column).
|
||||
"""
|
||||
if score >= 90:
|
||||
base = (68, 120, 68) # deep sage
|
||||
elif score >= 70:
|
||||
base = (120, 140, 72) # olive green
|
||||
else:
|
||||
base = (145, 155, 80) # yellow-green
|
||||
|
||||
if not muted:
|
||||
return base
|
||||
# Pastel orange shades for strict column
|
||||
if score >= 90:
|
||||
return (195, 160, 115) # light sandy peach
|
||||
if score >= 70:
|
||||
return (200, 148, 100) # warm apricot
|
||||
return (195, 125, 95) # soft coral
|
||||
|
||||
|
||||
def fmt_score(score: float) -> str:
|
||||
"""Format score with one decimal place, dropping .0 for integers."""
|
||||
if score == int(score):
|
||||
return str(int(score))
|
||||
return f"{score:.1f}"
|
||||
|
||||
|
||||
def scale(value: int) -> int:
|
||||
"""Scale value by retina factor."""
|
||||
return value * SCALE
|
||||
|
||||
|
||||
def load_font(size: int, *, serif: bool = False, bold: bool = False, mono: bool = False) -> ImageFont.ImageFont:
|
||||
"""Load a font with cross-platform fallback."""
|
||||
size = size * SCALE
|
||||
candidates = []
|
||||
|
||||
if mono:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFNSMono.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
||||
"DejaVuSansMono.ttf",
|
||||
]
|
||||
elif serif and bold:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/Supplemental/Georgia Bold.ttf",
|
||||
"/System/Library/Fonts/NewYork.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
|
||||
"DejaVuSerif-Bold.ttf",
|
||||
]
|
||||
elif serif:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/Supplemental/Georgia.ttf",
|
||||
"/System/Library/Fonts/NewYork.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
|
||||
"DejaVuSerif.ttf",
|
||||
]
|
||||
elif bold:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/System/Library/Fonts/HelveticaNeue.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||
"DejaVuSans-Bold.ttf",
|
||||
]
|
||||
else:
|
||||
candidates = [
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/System/Library/Fonts/HelveticaNeue.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"DejaVuSans.ttf",
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Fallback to default font
|
||||
try:
|
||||
return ImageFont.load_default()
|
||||
except:
|
||||
return ImageFont.truetype("arial.ttf", size)
|
||||
|
||||
|
||||
def draw_left_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
main_score: float,
|
||||
strict_score: float,
|
||||
project_name: str,
|
||||
version: str,
|
||||
*,
|
||||
lp_left: int,
|
||||
lp_right: int,
|
||||
lp_top: int,
|
||||
lp_bot: int,
|
||||
) -> None:
|
||||
"""Draw left panel with title, scores, and project info."""
|
||||
# Fonts
|
||||
font_title = load_font(16, bold=True)
|
||||
font_big = load_font(48, bold=True)
|
||||
font_version = load_font(11)
|
||||
font_strict = load_font(12, bold=True)
|
||||
|
||||
# Title
|
||||
title = "CODE HEALTH"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=font_title)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
title_y = lp_top + scale(8)
|
||||
center_x = (lp_left + lp_right) // 2
|
||||
|
||||
draw.text(
|
||||
(center_x - title_width / 2, title_y - title_bbox[1]),
|
||||
title,
|
||||
fill=TEXT,
|
||||
font=font_title,
|
||||
)
|
||||
|
||||
# Main score
|
||||
score_y = title_y + scale(35)
|
||||
score_text = fmt_score(main_score)
|
||||
score_bbox = draw.textbbox((0, 0), score_text, font=font_big)
|
||||
score_width = score_bbox[2] - score_bbox[0]
|
||||
|
||||
# Score background
|
||||
score_bg_y = score_y - scale(5)
|
||||
score_bg_h = scale(55)
|
||||
draw.rectangle(
|
||||
(lp_left + scale(10), score_bg_y, lp_right - scale(10), score_bg_y + score_bg_h),
|
||||
fill=BG_SCORE,
|
||||
outline=BORDER,
|
||||
width=1,
|
||||
)
|
||||
|
||||
draw.text(
|
||||
(center_x - score_width / 2, score_y - score_bbox[1]),
|
||||
score_text,
|
||||
fill=score_color(main_score),
|
||||
font=font_big,
|
||||
)
|
||||
|
||||
# Strict score
|
||||
strict_y = score_bg_y + score_bg_h + scale(12)
|
||||
strict_text = f"Strict: {fmt_score(strict_score)}"
|
||||
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
|
||||
strict_width = strict_bbox[2] - strict_bbox[0]
|
||||
|
||||
draw.text(
|
||||
(center_x - strict_width / 2, strict_y - strict_bbox[1]),
|
||||
strict_text,
|
||||
fill=score_color(strict_score, muted=True),
|
||||
font=font_strict,
|
||||
)
|
||||
|
||||
# Project info
|
||||
info_y = strict_y + scale(25)
|
||||
|
||||
# Project name
|
||||
name_text = project_name.upper()
|
||||
name_bbox = draw.textbbox((0, 0), name_text, font=font_version)
|
||||
name_width = name_bbox[2] - name_bbox[0]
|
||||
|
||||
draw.text(
|
||||
(center_x - name_width / 2, info_y - name_bbox[1]),
|
||||
name_text,
|
||||
fill=DIM,
|
||||
font=font_version,
|
||||
)
|
||||
|
||||
# Version
|
||||
version_y = info_y + scale(18)
|
||||
version_text = f"v{version}" if version else "dev"
|
||||
version_bbox = draw.textbbox((0, 0), version_text, font=font_version)
|
||||
version_width = version_bbox[2] - version_bbox[0]
|
||||
|
||||
draw.text(
|
||||
(center_x - version_width / 2, version_y - version_bbox[1]),
|
||||
version_text,
|
||||
fill=DIM,
|
||||
font=font_version,
|
||||
)
|
||||
|
||||
|
||||
def draw_vert_rule_with_ornament(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: int,
|
||||
y1: int,
|
||||
y2: int,
|
||||
mid_y: int,
|
||||
color: Tuple[int, int, int],
|
||||
accent: Tuple[int, int, int],
|
||||
) -> None:
|
||||
"""Draw vertical divider with ornament at center."""
|
||||
# Vertical lines
|
||||
draw.line([(x, y1), (x, mid_y - scale(8))], fill=color, width=1)
|
||||
draw.line([(x, mid_y + scale(8)), (x, y2)], fill=color, width=1)
|
||||
|
||||
# Ornament circle
|
||||
ornament_size = scale(6)
|
||||
draw.ellipse(
|
||||
(x - ornament_size // 2, mid_y - ornament_size // 2,
|
||||
x + ornament_size // 2, mid_y + ornament_size // 2),
|
||||
outline=accent,
|
||||
width=2,
|
||||
)
|
||||
draw.ellipse(
|
||||
(x - ornament_size // 2 + 2, mid_y - ornament_size // 2 + 2,
|
||||
x + ornament_size // 2 - 2, mid_y + ornament_size // 2 - 2),
|
||||
outline=color,
|
||||
width=1,
|
||||
)
|
||||
|
||||
|
||||
def draw_right_panel(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
active_dims: List[Tuple[str, Dict[str, Any]]],
|
||||
row_h: int,
|
||||
*,
|
||||
table_x1: int,
|
||||
table_x2: int,
|
||||
table_top: int,
|
||||
table_bot: int,
|
||||
) -> None:
|
||||
"""Draw right panel: two separate dimension tables side by side."""
|
||||
font_row = load_font(11, mono=True)
|
||||
font_strict = load_font(9, mono=True)
|
||||
row_count = len(active_dims)
|
||||
|
||||
cols = 2
|
||||
rows_per_col = (row_count + cols - 1) // cols
|
||||
table_width = table_x2 - table_x1
|
||||
grid_gap = scale(8)
|
||||
grid_width = (table_width - grid_gap) // cols
|
||||
|
||||
for col_index in range(cols):
|
||||
grid_x1 = table_x1 + col_index * (grid_width + grid_gap)
|
||||
grid_x2 = grid_x1 + grid_width
|
||||
draw.rounded_rectangle(
|
||||
(grid_x1, table_top, grid_x2, table_bot),
|
||||
radius=scale(4),
|
||||
fill=BG_TABLE,
|
||||
outline=BORDER,
|
||||
width=1,
|
||||
)
|
||||
|
||||
name_col_width = scale(120)
|
||||
value_col_gap = scale(4)
|
||||
value_col_width = scale(34)
|
||||
total_content_width = (
|
||||
name_col_width
|
||||
+ value_col_gap
|
||||
+ value_col_width
|
||||
+ value_col_gap
|
||||
+ value_col_width
|
||||
)
|
||||
block_left = grid_x1 + (grid_width - total_content_width) // 2
|
||||
name_col_x = block_left
|
||||
health_col_x = name_col_x + name_col_width + value_col_gap
|
||||
strict_col_x = health_col_x + value_col_width + value_col_gap + scale(4)
|
||||
|
||||
rows_this_col = min(rows_per_col, row_count - col_index * rows_per_col)
|
||||
content_height = rows_this_col * row_h
|
||||
content_top = (table_top + table_bot) // 2 - content_height // 2
|
||||
|
||||
sample_bbox = draw.textbbox((0, 0), "Xg", font=font_row)
|
||||
row_text_height = sample_bbox[3] - sample_bbox[1]
|
||||
row_text_offset = sample_bbox[1]
|
||||
start_idx = col_index * rows_per_col
|
||||
|
||||
for row_index in range(rows_this_col):
|
||||
dim_idx = start_idx + row_index
|
||||
if dim_idx >= row_count:
|
||||
break
|
||||
name, data = active_dims[dim_idx]
|
||||
band_top = content_top + row_index * row_h
|
||||
band_bottom = band_top + row_h
|
||||
if row_index % 2 == 1:
|
||||
draw.rectangle(
|
||||
(grid_x1 + 1, band_top, grid_x2 - 1, band_bottom), fill=BG_ROW_ALT
|
||||
)
|
||||
text_y = band_top + (row_h - row_text_height) // 2 - row_text_offset + scale(1)
|
||||
score = data.get("score", 100)
|
||||
strict = data.get("strict", score)
|
||||
|
||||
max_name_width = name_col_width - scale(2)
|
||||
while (
|
||||
name
|
||||
and draw.textlength(name + "\u2026", font=font_row) > max_name_width
|
||||
):
|
||||
name = name[:-1]
|
||||
if draw.textlength(name, font=font_row) > max_name_width:
|
||||
name = name.rstrip() + "\u2026"
|
||||
|
||||
draw.text((name_col_x, text_y), name, fill=TEXT, font=font_row)
|
||||
draw.text(
|
||||
(health_col_x, text_y),
|
||||
f"{fmt_score(score)}%",
|
||||
fill=score_color(score),
|
||||
font=font_row,
|
||||
)
|
||||
|
||||
strict_text = f"{fmt_score(strict)}%"
|
||||
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
|
||||
strict_text_height = strict_bbox[3] - strict_bbox[1]
|
||||
strict_y = band_top + (row_h - strict_text_height) // 2 - strict_bbox[1]
|
||||
draw.text(
|
||||
(strict_col_x, strict_y),
|
||||
strict_text,
|
||||
fill=score_color(strict, muted=True),
|
||||
font=font_strict,
|
||||
)
|
||||
|
||||
|
||||
def generate_scorecard(data: ScorecardData, output_path: str | Path) -> Path:
|
||||
"""Render a landscape scorecard PNG from scorecard data. Returns output path."""
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Layout — landscape (wide), dimensions first
|
||||
row_count = len(data.dimensions)
|
||||
row_h = scale(20)
|
||||
width = scale(780)
|
||||
divider_x = scale(260)
|
||||
frame_inset = scale(5)
|
||||
|
||||
cols = 2
|
||||
rows_per_col = (row_count + cols - 1) // cols
|
||||
table_content_h = scale(14) + scale(4) + scale(6) + rows_per_col * row_h
|
||||
content_h = max(table_content_h + scale(28), scale(150))
|
||||
height = scale(12) + content_h
|
||||
|
||||
# Create image
|
||||
img = Image.new("RGB", (width, height), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Double frame
|
||||
draw.rectangle((0, 0, width - 1, height - 1), outline=FRAME, width=scale(2))
|
||||
draw.rectangle(
|
||||
(frame_inset, frame_inset, width - frame_inset - 1, height - frame_inset - 1),
|
||||
outline=BORDER,
|
||||
width=1,
|
||||
)
|
||||
|
||||
content_top = frame_inset + scale(1)
|
||||
content_bot = height - frame_inset - scale(1)
|
||||
content_mid_y = (content_top + content_bot) // 2
|
||||
|
||||
# Left panel: title + score + project name
|
||||
draw_left_panel(
|
||||
draw,
|
||||
data.main_score,
|
||||
data.strict_score,
|
||||
data.project_name,
|
||||
data.version,
|
||||
lp_left=frame_inset + scale(11),
|
||||
lp_right=divider_x - scale(11),
|
||||
lp_top=content_top + scale(4),
|
||||
lp_bot=content_bot - scale(4),
|
||||
)
|
||||
|
||||
# Vertical divider with ornament
|
||||
draw_vert_rule_with_ornament(
|
||||
draw,
|
||||
divider_x,
|
||||
content_top + scale(12),
|
||||
content_bot - scale(12),
|
||||
content_mid_y,
|
||||
BORDER,
|
||||
ACCENT,
|
||||
)
|
||||
|
||||
# Right panel: dimension table
|
||||
draw_right_panel(
|
||||
draw,
|
||||
data.dimensions,
|
||||
row_h,
|
||||
table_x1=divider_x + scale(11),
|
||||
table_x2=width - frame_inset - scale(11),
|
||||
table_top=content_top + scale(4),
|
||||
table_bot=content_bot - scale(4),
|
||||
)
|
||||
|
||||
# Save image
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(str(output_path), "PNG", optimize=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def load_devour_data(json_path: str) -> ScorecardData:
|
||||
"""Load Devour scan results and convert to scorecard format."""
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Extract findings
|
||||
findings = data.get('findings', [])
|
||||
|
||||
# Calculate scores
|
||||
total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings)
|
||||
strict_score = total_score # Simplified - would use strict scoring logic
|
||||
|
||||
# Convert to percentage (inverted)
|
||||
main_score = max(0, 100 - (total_score / 1000 * 100))
|
||||
strict_score_pct = max(0, 100 - (strict_score / 1000 * 100))
|
||||
|
||||
# Group by type for dimensions
|
||||
type_counts = {}
|
||||
type_scores = {}
|
||||
for finding in findings:
|
||||
ftype = finding.get('type', 'unknown')
|
||||
type_counts[ftype] = type_counts.get(ftype, 0) + 1
|
||||
type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0)
|
||||
|
||||
# Create dimensions list
|
||||
dimensions = []
|
||||
for ftype, count in type_counts.items():
|
||||
avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100)
|
||||
dimensions.append((
|
||||
ftype.replace('_', ' ').title(),
|
||||
{
|
||||
'score': max(0, min(100, avg_score)),
|
||||
'strict': max(0, min(100, avg_score * 0.8)), # Strict is lower
|
||||
'count': count
|
||||
}
|
||||
))
|
||||
|
||||
# Sort by score (lowest first)
|
||||
dimensions.sort(key=lambda x: x[1]['score'])
|
||||
|
||||
return ScorecardData(
|
||||
project_name="Devour",
|
||||
version="1.0.0",
|
||||
main_score=main_score,
|
||||
strict_score=strict_score_pct,
|
||||
dimensions=dimensions[:8], # Limit to 8 dimensions
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python devour_scorecard.py <devour_results.json> <output.png>")
|
||||
sys.exit(1)
|
||||
|
||||
json_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
if not os.path.exists(json_path):
|
||||
print(f"Error: Input file {json_path} not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Load and convert data
|
||||
data = load_devour_data(json_path)
|
||||
|
||||
# Generate scorecard
|
||||
result_path = generate_scorecard(data, output_path)
|
||||
print(f"Scorecard generated: {result_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating scorecard: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user