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