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
+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()