actions-template/.gitea/ci/render_stats_svgs.py

279 lines
8.7 KiB
Python

#!/usr/bin/env python3
import argparse
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import List
LABEL_BG = "#555555"
DEFAULT_TOTAL_COLOR = "#1f6feb"
DEFAULT_FILES_COLOR = "#2da44e"
DEFAULT_LANGUAGE_COUNT_COLOR = "#8250df"
TEXT_COLOR = "#ffffff"
LIGHT_TEXT_COLOR = "#24292f"
LIGHT_BADGE_BG = "#f6f8fa"
LIGHT_BADGE_BORDER = "#d0d7de"
@dataclass
class LanguageStat:
lang_id: str
display_name: str
code_lines: int
formatted_lines: str
file_count: int
color: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--output-dir", required=True)
parser.add_argument("--readme-path", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--generated-by", required=True)
parser.add_argument("--updated-at", required=True)
parser.add_argument("--total-color", default=DEFAULT_TOTAL_COLOR)
parser.add_argument("--files-color", default=DEFAULT_FILES_COLOR)
parser.add_argument("--language-count-color", default=DEFAULT_LANGUAGE_COUNT_COLOR)
parser.add_argument("--total-code", required=True, type=int)
parser.add_argument("--formatted-total-code", required=True)
parser.add_argument("--total-files", required=True, type=int)
parser.add_argument("--formatted-total-files", required=True)
parser.add_argument("--language-count", required=True, type=int)
parser.add_argument("--exclude-dirs", required=True)
parser.add_argument("--min-lines-threshold", required=True, type=int)
parser.add_argument("--lang-summary", required=True)
return parser.parse_args()
def escape_xml(value: str) -> str:
return (
value.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
def display_units(value: str) -> int:
units = 0
for char in value:
if unicodedata.east_asian_width(char) in {"F", "W"}:
units += 2
else:
units += 1
return units
def text_width(value: str) -> int:
return display_units(value) * 7 + 20
def normalize_color(value: str, fallback: str) -> str:
if not value:
return fallback
return value if value.startswith("#") else f"#{value}"
def parse_language_summary(path: Path) -> List[LanguageStat]:
stats: List[LanguageStat] = []
if not path.exists():
return stats
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line:
continue
parts = line.split("|")
if len(parts) < 6:
continue
while len(parts) < 7:
parts.append("")
lang_id, display_name, code_lines, formatted_lines, file_count, color, _icon = parts[:7]
stats.append(
LanguageStat(
lang_id=lang_id,
display_name=display_name,
code_lines=int(code_lines),
formatted_lines=formatted_lines,
file_count=int(file_count),
color=normalize_color(color, DEFAULT_TOTAL_COLOR),
)
)
return stats
def split_badge_svg(label: str, message: str, color: str) -> str:
height = 20
left_width = max(46, text_width(label))
right_width = max(46, text_width(message))
total_width = left_width + right_width
left_text_x = left_width / 2
right_text_x = left_width + right_width / 2
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="{height}" role="img" aria-label="{escape_xml(label)}: {escape_xml(message)}">
<title>{escape_xml(label)}: {escape_xml(message)}</title>
<rect width="{left_width}" height="{height}" fill="{LABEL_BG}" />
<rect x="{left_width}" width="{right_width}" height="{height}" fill="{escape_xml(color)}" />
<rect width="{total_width}" height="{height}" rx="3" fill="transparent" />
<g fill="{TEXT_COLOR}" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="{left_text_x:.1f}" y="14">{escape_xml(label)}</text>
<text x="{right_text_x:.1f}" y="14">{escape_xml(message)}</text>
</g>
</svg>
"""
def flat_badge_svg(label: str, message: str, color: str) -> str:
height = 20
gap = 10
label_width = max(34, text_width(label) - 4)
message_width = max(38, text_width(message) - 4)
total_width = label_width + message_width + gap + 20
label_x = 10
message_x = label_x + label_width + gap
label_text_x = label_x + label_width / 2
message_text_x = message_x + message_width / 2
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="{height}" role="img" aria-label="{escape_xml(label)}: {escape_xml(message)}">
<title>{escape_xml(label)}: {escape_xml(message)}</title>
<rect width="{total_width}" height="{height}" rx="10" fill="{LIGHT_BADGE_BG}" stroke="{LIGHT_BADGE_BORDER}" />
<rect x="{message_x}" y="3" width="{message_width}" height="14" rx="7" fill="{escape_xml(color)}" />
<g text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="{label_text_x:.1f}" y="14" fill="{LIGHT_TEXT_COLOR}">{escape_xml(label)}</text>
<text x="{message_text_x:.1f}" y="14" fill="{TEXT_COLOR}">{escape_xml(message)}</text>
</g>
</svg>
"""
def write_badge(path: Path, label: str, message: str, color: str, variant: str = "split") -> None:
if variant == "flat":
svg = flat_badge_svg(label, message, color)
else:
svg = split_badge_svg(label, message, color)
path.write_text(svg, encoding="utf-8")
def render_readme(args: argparse.Namespace, languages: List[LanguageStat], badge_dir_name: str) -> str:
language_rows = []
for stat in languages:
share = (stat.code_lines * 100 / args.total_code) if args.total_code else 0.0
language_rows.append(
f"| **{stat.display_name}** | {stat.formatted_lines} | {stat.file_count} | {share:.1f}% | "
f"![{stat.display_name}](./{badge_dir_name}/{stat.lang_id}-lines.svg) |"
)
if language_rows:
language_table = "\n".join(language_rows)
else:
language_table = "| _未找到符合条件的语言_ | - | - | - | - |"
return f"""# 📊 代码统计详细报告
> 此分支由 {args.generated_by} 自动生成和维护
>
> ⚠️ **请勿手动修改此分支的内容!**
>
> 🤖 由 {args.generated_by} 自动生成
>
> 📅 更新时间: {args.updated_at}
## 📈 总体统计
| 统计项 | 数值 | 徽章 |
|--------|------|------|
| 💻 总代码行数 | **{args.formatted_total_code}** 行 | ![总代码](./{badge_dir_name}/total-lines.svg) |
| 📁 总文件数 | **{args.formatted_total_files}** 个 | ![总文件](./{badge_dir_name}/total-files.svg) |
| 🌐 语言种类 | **{args.language_count}** 种 | ![语言种类](./{badge_dir_name}/language-count.svg) |
## 🌈 语言分布
| 语言 | 代码行数 | 文件数 | 占比 | 徽章 |
|------|----------|--------|------|------|
{language_table}
---
## ⚙️ 配置说明
### Token 配置
- 当前使用: **WORKFLOW**
- 需要在 Settings -> Actions -> Secrets 中配置 `WORKFLOW`
### 排除规则
当前排除的目录:
```
{args.exclude_dirs}
```
### 阈值设置
- 最小代码行数阈值: **{args.min_lines_threshold}** 行
- 低于此阈值的语言不会生成徽章
---
*📊 统计仓库:`{args.repo}`*
"""
def main() -> None:
args = parse_args()
output_dir = Path(args.output_dir)
readme_path = Path(args.readme_path)
output_dir.mkdir(parents=True, exist_ok=True)
readme_path.parent.mkdir(parents=True, exist_ok=True)
languages = parse_language_summary(Path(args.lang_summary))
badge_dir_name = output_dir.name
write_badge(
output_dir / "total-lines.svg",
"代码",
f"{args.formatted_total_code}",
normalize_color(args.total_color, DEFAULT_TOTAL_COLOR),
variant="flat",
)
write_badge(
output_dir / "total-files.svg",
"文件",
f"{args.formatted_total_files}",
normalize_color(args.files_color, DEFAULT_FILES_COLOR),
variant="flat",
)
write_badge(
output_dir / "language-count.svg",
"语言",
f"{args.language_count}",
normalize_color(args.language_count_color, DEFAULT_LANGUAGE_COUNT_COLOR),
variant="flat",
)
for stat in languages:
write_badge(
output_dir / f"{stat.lang_id}-lines.svg",
stat.display_name,
f"{stat.formatted_lines}",
stat.color,
variant="split",
)
readme_path.write_text(render_readme(args, languages, badge_dir_name), encoding="utf-8")
if __name__ == "__main__":
main()