mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-04 04:23:02 +00:00
updage
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user