887 lines
37 KiB
Python
887 lines
37 KiB
Python
from __future__ import annotations
|
|
|
|
import csv
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
from PIL import Image
|
|
from pptx import Presentation
|
|
from pptx.dml.color import RGBColor
|
|
from pptx.enum.shapes import MSO_SHAPE
|
|
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
|
from pptx.util import Inches, Pt
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
AUDIT_DIR = ROOT / "artifacts" / "cut-file-audit" / "full_20260509_114655"
|
|
STABLE_AUDIT_DIR = ROOT / "artifacts" / "cut-file-audit" / "stable_pgm_dwm_20260510_10s"
|
|
RESULTS_CSV = AUDIT_DIR / "results.csv"
|
|
CAPTURE_DIR = AUDIT_DIR / "captures"
|
|
THUMB_DIR = ROOT / "Tornado3_2026Election" / "Assets" / "Thumbnail"
|
|
LIVE_DIR = ROOT / "artifacts" / "live-cut-validation" / "20260418_232730"
|
|
MEDIA_DIR = ROOT / "artifacts" / "design_issue_ppt_media_clean"
|
|
OUT_PATH = ROOT / "artifacts" / "design_tag_color_issues_20260510_clean_v2.pptx"
|
|
|
|
FONT = "Malgun Gothic"
|
|
|
|
|
|
SECTIONS = [
|
|
{
|
|
"title": "1-2위_ani_광역단체장",
|
|
"keys": ["1-2위_ani_광역단체장"],
|
|
"mode": "pair",
|
|
"badges": ["색상/RGB", "확인 필요"],
|
|
"bullets": [
|
|
"정당명 좌/우 색상 적용 지침이 없음.",
|
|
"RGB txt는 정당판/정당바/득표율 중심으로만 안내되어 있음.",
|
|
"RGB txt와 기본 컷 색상 차이가 존재함.",
|
|
],
|
|
},
|
|
{
|
|
"title": "1-2위_ani_기초단체장",
|
|
"keys": ["1-2위_ani_기초단체장"],
|
|
"mode": "pair",
|
|
"badges": ["색상/RGB", "확인 필요"],
|
|
"bullets": [
|
|
"득표수 색상값 지침이 없음.",
|
|
"정당색 RGB를 차용하려 했으나 실제 화면 색상과 차이가 있음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "1-2위 시도별영상 계열",
|
|
"keys": ["1-2위_광역단체장_시도별영상", "1-2위_기초단체장_시도별영상"],
|
|
"mode": "grid",
|
|
"badges": ["색상/RGB", "운영 확인"],
|
|
"bullets": [
|
|
"RGB txt와 기본 컷 색상 차이가 존재함.",
|
|
"득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
"시도별 영상이 있는 지역은 해당 지역 컷을 로드해 활용하면 되는지 확인 필요.",
|
|
"예: 시도별_02_부산, 시도별_03_대구 등 지역별 컷 사용 여부.",
|
|
],
|
|
},
|
|
{
|
|
"title": "접전/초접전 단체장",
|
|
"keys": ["접전_광역단체장", "접전_기초단체장", "초접전_광역단체장", "초접전_기초단체장"],
|
|
"mode": "grid",
|
|
"badges": ["색상/RGB"],
|
|
"bullets": [
|
|
"득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
"정당바 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "1-3위_ani_광역단체장 / 1-3위_보궐선거",
|
|
"keys": ["1-3위_ani_광역단체장", "1-3위_보궐선거"],
|
|
"mode": "grid",
|
|
"badges": ["태그 의심", "색상/RGB"],
|
|
"bullets": [
|
|
"공백/잘못된 suffix 의심 태그: '순위01 2'.",
|
|
"'순위03' 태그 누락 의심. '순위01 2'를 '순위03'으로 변경 후 재검증 필요.",
|
|
"그룹, 득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "1-3위_ani_기초단체장",
|
|
"keys": ["1-3위_ani_기초단체장"],
|
|
"mode": "pair",
|
|
"badges": ["색상/RGB", "확인 필요"],
|
|
"bullets": [
|
|
"순위 하늘색 그라데이션이 모든 정당에 적용되는 지침인지 확인 필요.",
|
|
"그룹, 득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
"기타 RGB txt 참조 시 색상 차이가 다소 존재함.",
|
|
],
|
|
},
|
|
{
|
|
"title": "Bottom 1-3위/1위 단체장",
|
|
"keys": ["1-3위_광역단체장", "1-3위_기초단체장", "1위_광역단체장", "1위_기초단체장"],
|
|
"mode": "grid",
|
|
"badges": ["태그 의심", "색상/RGB"],
|
|
"bullets": [
|
|
"Bottom 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1', 'data01 2'.",
|
|
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "1-3위_기초단체장 확장 계열",
|
|
"keys": ["1-3위_기초단체장_5760", "1-3위_기초단체장_L", "2880_1-3위_기초단체장", "8316_1-3위_기초단체장"],
|
|
"mode": "grid",
|
|
"badges": ["색상/RGB"],
|
|
"bullets": [
|
|
"1-3위_기초단체장_5760, L, 2880/810/8316 계열 대상.",
|
|
"그룹, 득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "2880_1위 단체장",
|
|
"keys": ["2880_1위_광역단체장", "2880_1위_기초단체장"],
|
|
"mode": "grid",
|
|
"badges": ["RGB 매핑", "확인 필요"],
|
|
"bullets": [
|
|
"RGB 기준 파일이 heuristic으로 연결됨.",
|
|
"각각 이시각1위 계열 RGB txt를 기준으로 봐도 되는지 안내 필요.",
|
|
"그룹, 득표율, 정당명, 정당바/정당색 색상 지침 일부가 RGB txt에 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "당선 계열",
|
|
"keys": ["당선_광역단체장", "당선_광역의원", "당선_교육감", "당선_기초단체장", "당선_기초의원"],
|
|
"mode": "grid",
|
|
"badges": ["태그 의심", "색상/RGB", "색상 미적용"],
|
|
"bullets": [
|
|
"Bottom loop 포함 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1', 'data01 2'.",
|
|
"하단 RGB/당선.txt에 당선바(정당바) 색상 기준이 있으나 실제 하단 당선 컷에 적용되지 않음.",
|
|
"같은 파일에 득표수/득표율 색상 기준도 있으므로 적용 대상 태그와 매핑 방식 확인 필요.",
|
|
"2880/810/8316 및 HD/L 계열도 득표율 색상 지침 확인 필요.",
|
|
],
|
|
},
|
|
{
|
|
"title": "광역의원표 / 기초의원표",
|
|
"keys": ["광역의원표", "기초의원표", "광역의원표_HD", "광역의원표_L_1"],
|
|
"mode": "grid",
|
|
"badges": ["색상/RGB", "태그 체계", "캡처 실패"],
|
|
"bullets": [
|
|
"정당바 색상 태그가 있으나 RGB 기준 파일 또는 같은 섹션 지침이 없음.",
|
|
"loop/HD/L 계열에서 의석수 태그 체계가 기준과 다름.",
|
|
"의석수01A~04A 누락, 의석수0101A 등 추가 태그 확인 필요.",
|
|
"광역의원표_HD, 광역의원표_HD_loop, 광역의원표_L_1은 index out of range 캡처 실패 확인 필요.",
|
|
],
|
|
},
|
|
{
|
|
"title": "모든후보 계열",
|
|
"keys": ["모든후보_광역단체장", "모든후보_기초단체장", "모든후보_교육감", "모든후보_기초단체장_L"],
|
|
"mode": "grid",
|
|
"badges": ["색상/RGB", "태그 체계", "캡처 실패"],
|
|
"bullets": [
|
|
"득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
"값이 0.3%처럼 매우 적은 경우 음수/역방향 표기 가능성 확인 필요.",
|
|
"5760/L/2880/810/8316 계열에서 그룹01~03, 득표수02~03, 득표율02~03, 순위02~03 등 추가 태그가 기준과 다름.",
|
|
"2880_모든후보_기초단체장은 '개표율01 1', '선거구명01 1' 태그가 있어 기준 태그와 불일치함.",
|
|
"모든후보_기초단체장_L은 index out of range 캡처 실패 확인 필요.",
|
|
],
|
|
},
|
|
{
|
|
"title": "전후보 계열",
|
|
"keys": ["전후보_광역단체장", "전후보_교육감", "전후보_기초단체장"],
|
|
"mode": "grid",
|
|
"badges": ["태그 의심", "색상/RGB"],
|
|
"bullets": [
|
|
"공백/잘못된 suffix 의심 태그: 'data01 1', 'data01 2'.",
|
|
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
"값이 0.3%처럼 매우 적은 경우 음수/역방향 표기 가능성 확인 필요.",
|
|
],
|
|
},
|
|
{
|
|
"title": "경력 계열",
|
|
"keys": ["경력_광역단체장_in", "경력_기초단체장_in"],
|
|
"mode": "grid",
|
|
"badges": ["레이아웃", "RGB 매핑"],
|
|
"bullets": [
|
|
"기호가 두 자리(11/12)일 때 영역 침범 여부 확인 필요.",
|
|
"loop 컷은 RGB 기준 파일이 heuristic으로 '경력.txt'에 연결되어 있어 해당 기준 사용 가능 여부 안내 필요.",
|
|
],
|
|
},
|
|
{
|
|
"title": "사전_역대투표율",
|
|
"keys": ["사전_역대투표율", "사전_역대투표율_loop"],
|
|
"mode": "grid",
|
|
"badges": ["태그 누락", "알파 확인"],
|
|
"bullets": [
|
|
"원01~원06 태그 누락 의심.",
|
|
"애니메이션 mid/final PGM 캡처 기준 알파 처리 확인 필요.",
|
|
],
|
|
},
|
|
{
|
|
"title": "사전투표율",
|
|
"keys": ["사전투표율"],
|
|
"mode": "pair",
|
|
"badges": ["태그 의심", "색상/RGB"],
|
|
"bullets": [
|
|
"Bottom 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1'~'data01 7'.",
|
|
"그룹 색상 태그가 있으나 RGB 기준 파일 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "사전_역대당선자 계열",
|
|
"keys": ["사전_역대당선자", "사전_역대당선자_교육감", "사전_역대당선자_기초단체장"],
|
|
"mode": "grid",
|
|
"badges": ["색상/RGB"],
|
|
"bullets": [
|
|
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "시도별 지역 컷",
|
|
"keys": ["시도별_02_부산", "시도별_03_대구", "시도별_05_전남광주", "시도별_16_경남"],
|
|
"mode": "grid",
|
|
"badges": ["RGB 기준 없음"],
|
|
"bullets": [
|
|
"대상: 부산, 대구, 전남광주, 대전, 세종, 울산, 강원, 충남, 전북, 경북, 경남.",
|
|
"득표율, 정당명, 정당바 색상 관련 태그가 있으나 RGB 기준 파일 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "역대시도판세 계열",
|
|
"keys": ["역대시도판세_광역단체장", "역대시도판세_기초단체장"],
|
|
"mode": "grid",
|
|
"badges": ["RGB 매핑", "색상/RGB"],
|
|
"bullets": [
|
|
"RGB 기준 파일이 heuristic으로 연결됨.",
|
|
"광역은 '판세_광역단체장.txt', 기초는 '1-2위_ani_기초단체장.txt' 기준 사용 가능 여부 안내 필요.",
|
|
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "이시각1위_광역단체장",
|
|
"keys": ["이시각1위_광역단체장", "이시각1위_광역단체장_HD"],
|
|
"mode": "grid",
|
|
"badges": ["색상/RGB", "태그 불일치"],
|
|
"bullets": [
|
|
"그룹, 득표율, 정당명, 정당바, 정당색 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
"HD 컷은 기준 대비 개표율02~03, 그룹01~03, 득표수02~03, 득표율02~03, 선거구명02 등이 누락되어 태그 불일치 확인 필요.",
|
|
],
|
|
},
|
|
{
|
|
"title": "이시각1위_기초단체장",
|
|
"keys": ["이시각1위_기초단체장", "이시각1위_기초단체장_HD"],
|
|
"mode": "grid",
|
|
"badges": ["색상/RGB", "태그 불일치"],
|
|
"bullets": [
|
|
"그룹, 득표율 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
"HD 컷은 기준 대비 3위 관련 태그가 누락되어 확인 필요.",
|
|
"누락 의심: 개표율03, 그룹03, 득표수03, 득표율03, 선거구명03, 시도명03, 유확당03, 정당명03, 정당바03, 정당색03.",
|
|
],
|
|
},
|
|
{
|
|
"title": "판세_광역단체장",
|
|
"keys": ["판세_광역단체장"],
|
|
"mode": "pair",
|
|
"badges": ["색상/RGB"],
|
|
"bullets": [
|
|
"정당명, 정당바 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "판세_기초단체장",
|
|
"keys": ["판세_기초단체장"],
|
|
"mode": "pair",
|
|
"badges": ["색상/RGB"],
|
|
"bullets": [
|
|
"득표율 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "판세 좌상단 계열",
|
|
"keys": ["판세_광역단체장", "판세_광역의원", "판세_교육감", "판세_기초단체장", "판세_기초의원"],
|
|
"mode": "grid",
|
|
"badges": ["RGB 기준 없음"],
|
|
"bullets": [
|
|
"좌상단 판세 계열 대상.",
|
|
"정당명, 정당바 색상 관련 태그가 있으나 RGB 기준 파일 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "투표율",
|
|
"keys": ["투표율", "투표율_loop"],
|
|
"mode": "grid",
|
|
"badges": ["태그 의심", "태그 불일치", "색상/RGB"],
|
|
"bullets": [
|
|
"Bottom 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1'~'data01 7'.",
|
|
"loop 컷은 기준시 태그가 빠지고 기준시01/기준시02가 추가되어 태그 불일치 확인 필요.",
|
|
"그룹 색상 태그가 있으나 RGB 기준 파일 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "투표율_사진",
|
|
"keys": ["투표율_사진"],
|
|
"mode": "pair",
|
|
"badges": ["태그 누락"],
|
|
"bullets": [
|
|
"텍스트 '(14시 기준)' 태그 누락 의심.",
|
|
],
|
|
},
|
|
{
|
|
"title": "투표율_선거구별 (좌상단)",
|
|
"keys": ["투표율_선거구별"],
|
|
"mode": "pair",
|
|
"badges": ["태그 의심", "색상/RGB"],
|
|
"bullets": [
|
|
"잘못된 태그 형태로 되어 있으나 사용자 요청에 따라 미변경.",
|
|
"변경 시 재작업 필요 여부 별도 언급 필요.",
|
|
"공백/잘못된 suffix 의심 태그: '시도명01 1'~'시도명01 3', '투표율01 1'~'투표율01 2'.",
|
|
"그룹 색상 태그가 있으나 RGB 기준 파일 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "투표율_시도별",
|
|
"keys": ["투표율_시도별", "투표율_시도별_L"],
|
|
"mode": "grid",
|
|
"badges": ["태그 불일치"],
|
|
"bullets": [
|
|
"loop 계열에서 기준 대비 '유권자수' 태그가 추가되어 태그 불일치 확인 필요.",
|
|
],
|
|
},
|
|
{
|
|
"title": "2인 후보 좌상단 계열",
|
|
"keys": ["광역단체장_2인", "기초단체장_2인", "기초단체장_2인_텍스트"],
|
|
"mode": "grid",
|
|
"badges": ["태그 누락", "색상/RGB"],
|
|
"bullets": [
|
|
"광역단체장_2인, 기초단체장_2인, 기초단체장_2인_텍스트 대상.",
|
|
"후보명01, 후보명02 태그 누락 의심.",
|
|
"정당심볼 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "광역단체장_2인_텍스트",
|
|
"keys": ["광역단체장_2인_텍스트"],
|
|
"mode": "pair",
|
|
"badges": ["색상/RGB"],
|
|
"bullets": [
|
|
"득표율, 정당심볼 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
|
],
|
|
},
|
|
{
|
|
"title": "8316 계열",
|
|
"keys": ["8316_광역단체장", "8316_광역의원"],
|
|
"mode": "grid",
|
|
"badges": ["RGB 매핑", "색상/RGB"],
|
|
"bullets": [
|
|
"8316_광역단체장은 RGB 기준 파일이 heuristic으로 '1-2위_ani_광역단체장.txt'에 연결되어 있어 기준 사용 가능 여부 안내 필요.",
|
|
"8316_광역의원은 득표율, 정당명 색상 관련 태그가 있으나 RGB 기준 파일 없음.",
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
NO_ISSUE_ITEMS = [
|
|
"1-2위_광역단체장 (Bottom/Normal 5760/L 포함)",
|
|
"1-2위_광역단체장_loop (Bottom)",
|
|
"1-2위_기초단체장",
|
|
"1-2위_기초단체장 (Bottom)",
|
|
"1-2위_기초단체장_loop (Bottom)",
|
|
"1-2위_교육감",
|
|
"1-2위_보궐선거",
|
|
"1-2위_ani_기초단체장_5760",
|
|
"1-2위_ani_기초단체장_L",
|
|
"2880_1-2위_ani_기초단체장",
|
|
"810_1-2위_ani_기초단체장",
|
|
"2880_1-2위_광역단체장",
|
|
"810_1-2위_광역단체장",
|
|
"8316_1-2위_광역단체장",
|
|
"민방_타이틀 계열",
|
|
"투표율_02_부산, 투표율_03_대구, 투표율_05_전남광주",
|
|
"투표율_06_대전, 투표율_07_세종, 투표율_08_울산",
|
|
"투표율_10_강원, 투표율_12_충남, 투표율_14_전북",
|
|
"투표율_15_경북, 투표율_16_경남",
|
|
"투표율_선거구별 사전",
|
|
"투표율_시도별",
|
|
"투표율_시도별_L",
|
|
"투표율_영상",
|
|
"투표율 (좌상단)",
|
|
]
|
|
|
|
|
|
def load_rows(paths: Iterable[Path]) -> list[dict[str, str]]:
|
|
rows: list[dict[str, str]] = []
|
|
for path in paths:
|
|
if not path.exists():
|
|
continue
|
|
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
|
rows.extend(csv.DictReader(f))
|
|
return rows
|
|
|
|
|
|
def all_pngs(*dirs: Path) -> list[Path]:
|
|
paths: list[Path] = []
|
|
for d in dirs:
|
|
if d.exists():
|
|
paths.extend(d.rglob("*.png"))
|
|
return paths
|
|
|
|
|
|
def parse_captures(value: str) -> list[tuple[str, Path]]:
|
|
captures: list[tuple[str, Path]] = []
|
|
if not value:
|
|
return captures
|
|
for token in value.split(" | "):
|
|
parts = token.split(":", 2)
|
|
if len(parts) != 3:
|
|
continue
|
|
label, _stage, path = parts
|
|
p = Path(path)
|
|
if p.exists():
|
|
captures.append((label, p))
|
|
return captures
|
|
|
|
|
|
def row_sort_key(row: dict[str, str], key: str) -> tuple[int, int, int, int]:
|
|
status = row.get("Status", "")
|
|
basename = row.get("BaseName", "")
|
|
folder = row.get("Folder", "")
|
|
exact = 0 if basename == key else 1
|
|
status_rank = {"issue": 0, "needs-guidance": 1, "capture-failed": 2, "pass": 3}.get(status, 4)
|
|
folder_rank = 0 if "Normal" in folder else (1 if "Bottom" in folder else 2)
|
|
return exact, status_rank, folder_rank, len(basename)
|
|
|
|
|
|
def matching_rows(rows: list[dict[str, str]], key: str) -> list[dict[str, str]]:
|
|
exact = [r for r in rows if r.get("BaseName") == key]
|
|
contains = [
|
|
r for r in rows
|
|
if r not in exact and (key in r.get("BaseName", "") or r.get("BaseName", "") in key)
|
|
]
|
|
return sorted(exact + contains, key=lambda r: row_sort_key(r, key))
|
|
|
|
|
|
def first_thumb(pngs: list[Path], key: str) -> Path | None:
|
|
exact = [p for p in pngs if p.stem == key]
|
|
if exact:
|
|
return sorted(exact, key=lambda p: len(str(p)))[0]
|
|
contains = [p for p in pngs if key in p.stem or p.stem in key]
|
|
if contains:
|
|
return sorted(contains, key=lambda p: len(str(p)))[0]
|
|
return None
|
|
|
|
|
|
def image_items_for_section(section: dict, rows: list[dict[str, str]], pngs: list[Path]) -> list[tuple[Path, str]]:
|
|
items: list[tuple[Path, str]] = []
|
|
keys = section["keys"]
|
|
mode = section.get("mode", "pair")
|
|
|
|
if mode == "pair":
|
|
for key in keys:
|
|
for row in matching_rows(rows, key):
|
|
captures = parse_captures(row.get("Captures", ""))
|
|
if captures:
|
|
preferred = []
|
|
for label in ("baseline", "Basic", "Variant", "Stress"):
|
|
preferred.extend([(p, f"{row['Folder']} / {row['BaseName']} - {label}") for lbl, p in captures if lbl == label])
|
|
if preferred:
|
|
return preferred[:2]
|
|
thumb = first_thumb(pngs, key)
|
|
if thumb:
|
|
return [(thumb, f"thumbnail / {key}")]
|
|
return []
|
|
|
|
for key in keys:
|
|
added = False
|
|
for row in matching_rows(rows, key):
|
|
captures = parse_captures(row.get("Captures", ""))
|
|
if captures:
|
|
label, path = next(((lbl, p) for lbl, p in captures if lbl in ("Basic", "Variant")), captures[0])
|
|
items.append((path, f"{row['Folder']} / {row['BaseName']} - {label}"))
|
|
added = True
|
|
break
|
|
if not added:
|
|
thumb = first_thumb(pngs, key)
|
|
if thumb:
|
|
items.append((thumb, f"thumbnail / {key}"))
|
|
if len(items) >= 4:
|
|
break
|
|
return items[:4]
|
|
|
|
|
|
def preprocess_image(path: Path) -> Path:
|
|
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
|
digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
|
|
out = MEDIA_DIR / f"{digest}_{path.name}"
|
|
if out.exists():
|
|
return out
|
|
with Image.open(path) as im:
|
|
im = im.convert("RGBA")
|
|
bg = Image.new("RGBA", im.size, (24, 29, 39, 255))
|
|
composed = Image.alpha_composite(bg, im)
|
|
composed.convert("RGB").save(out, quality=95)
|
|
return out
|
|
|
|
|
|
def set_run_font(run, size: float, color: tuple[int, int, int] = (20, 25, 35), bold: bool = False):
|
|
run.font.name = FONT
|
|
run.font.size = Pt(size)
|
|
run.font.bold = bold
|
|
run.font.color.rgb = RGBColor(*color)
|
|
|
|
|
|
def add_text(slide, text: str, x: float, y: float, w: float, h: float, size: float = 14,
|
|
color: tuple[int, int, int] = (20, 25, 35), bold: bool = False,
|
|
align=PP_ALIGN.LEFT):
|
|
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
|
|
tf = box.text_frame
|
|
tf.clear()
|
|
tf.margin_left = Inches(0.05)
|
|
tf.margin_right = Inches(0.05)
|
|
tf.margin_top = Inches(0.02)
|
|
tf.margin_bottom = Inches(0.02)
|
|
p = tf.paragraphs[0]
|
|
p.alignment = align
|
|
run = p.add_run()
|
|
run.text = text
|
|
set_run_font(run, size, color, bold)
|
|
return box
|
|
|
|
|
|
def add_badges(slide, badges: Iterable[str], x: float, y: float):
|
|
colors = {
|
|
"색상/RGB": (28, 98, 177),
|
|
"RGB 기준 없음": (179, 87, 23),
|
|
"RGB 매핑": (110, 73, 190),
|
|
"태그 의심": (184, 51, 74),
|
|
"태그 누락": (184, 51, 74),
|
|
"태그 불일치": (184, 51, 74),
|
|
"태그 체계": (184, 51, 74),
|
|
"캡처 실패": (153, 52, 52),
|
|
"확인 필요": (76, 115, 42),
|
|
"운영 확인": (76, 115, 42),
|
|
"레이아웃": (77, 91, 112),
|
|
"알파 확인": (77, 91, 112),
|
|
}
|
|
cx = x
|
|
for badge in badges:
|
|
fill = colors.get(badge, (80, 90, 105))
|
|
width = max(0.8, min(1.45, 0.24 + len(badge) * 0.16))
|
|
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(cx), Inches(y), Inches(width), Inches(0.28))
|
|
shape.fill.solid()
|
|
shape.fill.fore_color.rgb = RGBColor(*fill)
|
|
shape.line.fill.background()
|
|
tf = shape.text_frame
|
|
tf.clear()
|
|
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
|
p = tf.paragraphs[0]
|
|
p.alignment = PP_ALIGN.CENTER
|
|
run = p.add_run()
|
|
run.text = badge
|
|
set_run_font(run, 8.5, (255, 255, 255), True)
|
|
cx += width + 0.12
|
|
|
|
|
|
def add_bullets(slide, bullets: list[str], x: float, y: float, w: float, h: float):
|
|
panel = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
|
|
panel.fill.solid()
|
|
panel.fill.fore_color.rgb = RGBColor(246, 248, 251)
|
|
panel.line.color.rgb = RGBColor(218, 224, 232)
|
|
panel.line.width = Pt(1)
|
|
tf = panel.text_frame
|
|
tf.clear()
|
|
tf.margin_left = Inches(0.22)
|
|
tf.margin_right = Inches(0.16)
|
|
tf.margin_top = Inches(0.15)
|
|
tf.margin_bottom = Inches(0.12)
|
|
tf.word_wrap = True
|
|
for idx, bullet in enumerate(bullets):
|
|
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
|
p.text = f"• {bullet}"
|
|
p.level = 0
|
|
p.space_after = Pt(4)
|
|
p.font.name = FONT
|
|
p.font.size = Pt(11.2 if len(bullets) <= 3 else 10.4)
|
|
p.font.color.rgb = RGBColor(31, 41, 55)
|
|
|
|
|
|
def fit_box(img_path: Path, x: float, y: float, w: float, h: float) -> tuple[float, float, float, float]:
|
|
with Image.open(img_path) as im:
|
|
iw, ih = im.size
|
|
scale = min(w / iw, h / ih)
|
|
nw = iw * scale
|
|
nh = ih * scale
|
|
return x + (w - nw) / 2, y + (h - nh) / 2, nw, nh
|
|
|
|
|
|
def add_image(slide, path: Path, caption: str, x: float, y: float, w: float, h: float):
|
|
bg = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
|
|
bg.fill.solid()
|
|
bg.fill.fore_color.rgb = RGBColor(24, 29, 39)
|
|
bg.line.color.rgb = RGBColor(200, 207, 217)
|
|
bg.line.width = Pt(0.8)
|
|
prepped = preprocess_image(path)
|
|
ix, iy, iw, ih = fit_box(prepped, x + 0.04, y + 0.04, w - 0.08, h - 0.36)
|
|
slide.shapes.add_picture(str(prepped), Inches(ix), Inches(iy), Inches(iw), Inches(ih))
|
|
add_text(slide, caption, x + 0.08, y + h - 0.28, w - 0.16, 0.18, 6.8, (207, 216, 226), False, PP_ALIGN.CENTER)
|
|
|
|
|
|
def add_issue_slide(prs: Presentation, section: dict, rows: list[dict[str, str]], pngs: list[Path]):
|
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
set_background(slide, (255, 255, 255))
|
|
add_header(slide, section["title"])
|
|
add_badges(slide, section.get("badges", []), 0.62, 0.72)
|
|
|
|
images = image_items_for_section(section, rows, pngs)
|
|
if len(images) <= 2:
|
|
left_w = 7.15
|
|
if len(images) == 1:
|
|
add_image(slide, images[0][0], images[0][1], 0.62, 1.22, left_w, 4.75)
|
|
elif len(images) == 2:
|
|
add_image(slide, images[0][0], images[0][1], 0.62, 1.22, left_w, 2.28)
|
|
add_image(slide, images[1][0], images[1][1], 0.62, 3.72, left_w, 2.28)
|
|
else:
|
|
add_empty_image_note(slide, 0.62, 1.22, left_w, 4.75)
|
|
else:
|
|
boxes = [(0.62, 1.22), (4.25, 1.22), (0.62, 3.72), (4.25, 3.72)]
|
|
for (path, caption), (x, y) in zip(images, boxes):
|
|
add_image(slide, path, caption, x, y, 3.35, 2.28)
|
|
|
|
add_bullets(slide, section["bullets"], 8.18, 1.22, 4.55, 4.75)
|
|
add_footer(slide, "Source: PGM DWM 10s recapture first, full_20260509_114655 and Assets/Thumbnail fallback")
|
|
|
|
|
|
def add_empty_image_note(slide, x: float, y: float, w: float, h: float):
|
|
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
|
|
shape.fill.solid()
|
|
shape.fill.fore_color.rgb = RGBColor(246, 248, 251)
|
|
shape.line.color.rgb = RGBColor(218, 224, 232)
|
|
tf = shape.text_frame
|
|
tf.clear()
|
|
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
|
p = tf.paragraphs[0]
|
|
p.alignment = PP_ALIGN.CENTER
|
|
run = p.add_run()
|
|
run.text = "매칭 가능한 캡처/썸네일 없음"
|
|
set_run_font(run, 16, (95, 106, 122), True)
|
|
|
|
|
|
def set_background(slide, color: tuple[int, int, int]):
|
|
bg = slide.background
|
|
bg.fill.solid()
|
|
bg.fill.fore_color.rgb = RGBColor(*color)
|
|
|
|
|
|
def add_header(slide, title: str):
|
|
add_text(slide, title, 0.55, 0.28, 9.2, 0.38, 20, (22, 27, 36), True)
|
|
line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(0.98), Inches(12.25), Inches(0.02))
|
|
line.fill.solid()
|
|
line.fill.fore_color.rgb = RGBColor(222, 227, 235)
|
|
line.line.fill.background()
|
|
|
|
|
|
def add_footer(slide, text: str):
|
|
add_text(slide, text, 0.6, 7.1, 12.1, 0.2, 7.5, (115, 126, 143))
|
|
|
|
|
|
def add_title_slide(prs: Presentation):
|
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
set_background(slide, (18, 24, 35))
|
|
add_text(slide, "Tornado3 2026 Election", 0.75, 1.2, 11.8, 0.45, 18, (172, 190, 214), True)
|
|
add_text(slide, "디자인 태그/색상 이슈 정리", 0.75, 1.72, 11.8, 0.82, 34, (255, 255, 255), True)
|
|
add_text(slide, "스크린샷 포함 검토용 PPT · 2026-05-09", 0.78, 2.66, 9.2, 0.35, 15, (226, 232, 240))
|
|
add_text(slide, "범위: RGB txt 지침 누락, 기본 컷 색상 차이, 태그 suffix/누락 의심, 계열별 태그 불일치, 캡처 실패", 0.78, 3.22, 11.6, 0.5, 13, (202, 213, 226))
|
|
|
|
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(0.82), Inches(4.6), Inches(11.68), Inches(1.35))
|
|
card.fill.solid()
|
|
card.fill.fore_color.rgb = RGBColor(30, 41, 59)
|
|
card.line.color.rgb = RGBColor(64, 82, 110)
|
|
add_text(slide, "사용 캡처", 1.08, 4.83, 2.0, 0.25, 12, (148, 163, 184), True)
|
|
add_text(slide, "artifacts/cut-file-audit/stable_pgm_dwm_20260510_10s/captures + Assets/Thumbnail fallback", 1.08, 5.18, 10.8, 0.28, 12, (241, 245, 249))
|
|
|
|
|
|
def add_summary_slide(prs: Presentation, rows: list[dict[str, str]]):
|
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
set_background(slide, (255, 255, 255))
|
|
add_header(slide, "요약")
|
|
counts: dict[str, int] = {}
|
|
for row in rows:
|
|
counts[row.get("Status", "")] = counts.get(row.get("Status", ""), 0) + 1
|
|
|
|
cards = [
|
|
("이슈/확인 필요 섹션", str(len(SECTIONS)), (28, 98, 177)),
|
|
("issue", str(counts.get("issue", 0)), (184, 51, 74)),
|
|
("needs-guidance", str(counts.get("needs-guidance", 0)), (179, 87, 23)),
|
|
("capture-failed", str(counts.get("capture-failed", 0)), (153, 52, 52)),
|
|
("pass", str(counts.get("pass", 0)), (76, 115, 42)),
|
|
]
|
|
x = 0.62
|
|
for title, value, color in cards:
|
|
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(1.35), Inches(2.3), Inches(1.18))
|
|
card.fill.solid()
|
|
card.fill.fore_color.rgb = RGBColor(247, 249, 252)
|
|
card.line.color.rgb = RGBColor(218, 224, 232)
|
|
add_text(slide, title, x + 0.18, 1.55, 1.95, 0.24, 9.5, (88, 101, 120), True, PP_ALIGN.CENTER)
|
|
add_text(slide, value, x + 0.18, 1.88, 1.95, 0.4, 24, color, True, PP_ALIGN.CENTER)
|
|
x += 2.45
|
|
|
|
add_text(slide, "핵심 판단 축", 0.7, 3.02, 4.0, 0.3, 17, (22, 27, 36), True)
|
|
summary_bullets = [
|
|
"RGB txt에 색상 섹션이 없거나, heuristic 매핑 기준 확인이 필요한 컷이 다수 있음.",
|
|
"공백이 포함된 suffix 태그('data01 1', '순위01 2' 등)는 정식 태그인지 재검증 필요.",
|
|
"HD/L/loop/2880/810/8316 계열에서 기준 컷과 태그 체계가 달라지는 항목이 있음.",
|
|
"일부 컷은 index out of range 캡처 실패가 발생하여 런타임 쪽 확인이 필요함.",
|
|
]
|
|
add_bullets(slide, summary_bullets, 0.7, 3.45, 12.0, 2.4)
|
|
add_footer(slide, "Counts are from results.csv; issue list follows the user-provided review notes.")
|
|
|
|
|
|
def add_no_issue_slides(prs: Presentation):
|
|
for idx in range(0, len(NO_ISSUE_ITEMS), 12):
|
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
set_background(slide, (255, 255, 255))
|
|
suffix = "" if idx == 0 else f" ({idx // 12 + 1})"
|
|
add_header(slide, f"특이사항 발견 못함{suffix}")
|
|
chunk = NO_ISSUE_ITEMS[idx:idx + 12]
|
|
add_bullets(slide, chunk, 0.85, 1.35, 11.7, 5.25)
|
|
add_footer(slide, "사용자 기록 기준: 별도 이상 징후 없음으로 분류된 컷")
|
|
|
|
|
|
def add_badges(slide, badges: Iterable[str], x: float, y: float):
|
|
if not badges:
|
|
return
|
|
add_text(slide, ", ".join(badges), x, y, 7.8, 0.24, 9.5, (104, 116, 133))
|
|
|
|
|
|
def add_bullets(slide, bullets: list[str], x: float, y: float, w: float, h: float):
|
|
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
|
|
tf = box.text_frame
|
|
tf.clear()
|
|
tf.margin_left = Inches(0.02)
|
|
tf.margin_right = Inches(0.02)
|
|
tf.margin_top = Inches(0.02)
|
|
tf.margin_bottom = Inches(0.02)
|
|
tf.word_wrap = True
|
|
|
|
size = 11.4 if len(bullets) <= 3 else 10.4
|
|
for idx, bullet in enumerate(bullets):
|
|
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
|
p.text = f"- {bullet}"
|
|
p.space_after = Pt(6)
|
|
p.font.name = FONT
|
|
p.font.size = Pt(size)
|
|
p.font.color.rgb = RGBColor(31, 41, 55)
|
|
|
|
|
|
def preprocess_image(path: Path) -> Path:
|
|
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
|
digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
|
|
out = MEDIA_DIR / f"clean_{digest}_{path.name}"
|
|
if out.exists():
|
|
return out
|
|
with Image.open(path) as im:
|
|
if im.mode in ("RGBA", "LA") or ("transparency" in im.info):
|
|
im = im.convert("RGBA")
|
|
bg = Image.new("RGBA", im.size, (255, 255, 255, 255))
|
|
im = Image.alpha_composite(bg, im).convert("RGB")
|
|
else:
|
|
im = im.convert("RGB")
|
|
im.save(out, quality=95)
|
|
return out
|
|
|
|
|
|
def add_image(slide, path: Path, caption: str, x: float, y: float, w: float, h: float):
|
|
prepped = preprocess_image(path)
|
|
caption_h = 0.28
|
|
ix, iy, iw, ih = fit_box(prepped, x, y, w, h - caption_h)
|
|
slide.shapes.add_picture(str(prepped), Inches(ix), Inches(iy), Inches(iw), Inches(ih))
|
|
add_text(slide, caption, x, y + h - caption_h + 0.05, w, 0.18, 6.8, (92, 103, 118), False, PP_ALIGN.CENTER)
|
|
|
|
|
|
def add_issue_slide(prs: Presentation, section: dict, rows: list[dict[str, str]], pngs: list[Path]):
|
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
set_background(slide, (255, 255, 255))
|
|
add_header(slide, section["title"])
|
|
add_badges(slide, section.get("badges", []), 0.67, 0.73)
|
|
|
|
images = image_items_for_section(section, rows, pngs)
|
|
if not images:
|
|
add_empty_image_note(slide, 0.68, 1.28, 7.25, 4.85)
|
|
elif len(images) == 1:
|
|
add_image(slide, images[0][0], images[0][1], 0.68, 1.28, 7.25, 4.85)
|
|
else:
|
|
boxes = [
|
|
(0.68, 1.28, 3.55, 2.45),
|
|
(4.40, 1.28, 3.55, 2.45),
|
|
(0.68, 4.08, 3.55, 2.45),
|
|
(4.40, 4.08, 3.55, 2.45),
|
|
]
|
|
for (path, caption), (x, y, w, h) in zip(images, boxes):
|
|
add_image(slide, path, caption, x, y, w, h)
|
|
|
|
add_text(slide, "확인 내용", 8.45, 1.28, 3.95, 0.3, 13, (22, 27, 36), True)
|
|
add_bullets(slide, section["bullets"], 8.45, 1.72, 4.15, 4.65)
|
|
add_footer(slide, "Source: PGM DWM 10s recapture first, full_20260509_114655 and Assets/Thumbnail fallback")
|
|
|
|
|
|
def add_empty_image_note(slide, x: float, y: float, w: float, h: float):
|
|
add_text(slide, "매칭 가능한 캡처/썸네일 없음", x, y + h / 2 - 0.18, w, 0.35, 14, (95, 106, 122), True, PP_ALIGN.CENTER)
|
|
|
|
|
|
def add_header(slide, title: str):
|
|
add_text(slide, title, 0.62, 0.28, 11.9, 0.42, 21, (22, 27, 36), True)
|
|
|
|
|
|
def add_footer(slide, text: str):
|
|
add_text(slide, text, 0.66, 7.08, 12.1, 0.2, 7.3, (130, 140, 154))
|
|
|
|
|
|
def add_title_slide(prs: Presentation):
|
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
set_background(slide, (255, 255, 255))
|
|
add_text(slide, "Tornado3 2026 Election", 0.78, 1.25, 11.6, 0.4, 17, (91, 105, 124), True)
|
|
add_text(slide, "디자인 태그/색상 이슈 정리", 0.78, 1.78, 11.6, 0.72, 33, (22, 27, 36), True)
|
|
add_text(slide, "PGM 재캡처 적용본 · 2026-05-10", 0.8, 2.82, 8.8, 0.35, 14, (80, 92, 110))
|
|
add_text(slide, "캡처 기준: artifacts/cut-file-audit/stable_pgm_dwm_20260510_10s", 0.8, 3.32, 10.8, 0.3, 11.5, (104, 116, 133))
|
|
add_text(slide, "RGB txt 지침 누락, 기본 컷 색상 차이, 태그 suffix/누락 의심, 계열별 태그 불일치, 캡처 실패 항목을 정리했습니다.", 0.8, 4.08, 11.4, 0.55, 13, (49, 60, 75))
|
|
|
|
|
|
def add_summary_slide(prs: Presentation, rows: list[dict[str, str]]):
|
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
set_background(slide, (255, 255, 255))
|
|
add_header(slide, "요약")
|
|
|
|
counts: dict[str, int] = {}
|
|
for row in rows:
|
|
status = row.get("Status", "")
|
|
counts[status] = counts.get(status, 0) + 1
|
|
|
|
summary_lines = [
|
|
f"이슈/확인 필요 섹션: {len(SECTIONS)}",
|
|
f"issue: {counts.get('issue', 0)}",
|
|
f"needs-guidance: {counts.get('needs-guidance', 0)}",
|
|
f"capture-failed: {counts.get('capture-failed', 0)}",
|
|
f"pass: {counts.get('pass', 0)}",
|
|
]
|
|
for idx, line in enumerate(summary_lines):
|
|
add_text(slide, line, 0.82, 1.28 + idx * 0.44, 4.8, 0.32, 15, (31, 41, 55), idx == 0)
|
|
|
|
add_text(slide, "핵심 판단 축", 0.82, 4.0, 4.0, 0.3, 17, (22, 27, 36), True)
|
|
summary_bullets = [
|
|
"RGB txt에 색상 섹션이 없거나 heuristic 매핑 기준 확인이 필요한 컷이 다수 있음.",
|
|
"공백이 포함된 suffix 태그와 누락 의심 태그는 정식 태그인지 재검증 필요.",
|
|
"HD/L/loop/2880/810/8316 계열에서 기준 컷과 태그 체계가 달라지는 항목이 있음.",
|
|
"일부 컷은 index out of range 캡처 실패가 발생해 원본 쪽 확인 필요.",
|
|
]
|
|
add_bullets(slide, summary_bullets, 0.82, 4.48, 11.5, 1.7)
|
|
add_footer(slide, "Counts are from results.csv; issue list follows the user-provided review notes.")
|
|
|
|
|
|
def add_no_issue_slides(prs: Presentation):
|
|
for idx in range(0, len(NO_ISSUE_ITEMS), 12):
|
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
set_background(slide, (255, 255, 255))
|
|
suffix = "" if idx == 0 else f" ({idx // 12 + 1})"
|
|
add_header(slide, f"특이사항 발견 못함{suffix}")
|
|
chunk = NO_ISSUE_ITEMS[idx:idx + 12]
|
|
add_bullets(slide, chunk, 0.86, 1.28, 11.5, 5.25)
|
|
add_footer(slide, "사용자 기록 기준: 별도 이상 징후 없음으로 분류된 컷")
|
|
|
|
|
|
def build():
|
|
summary_rows = load_rows([STABLE_AUDIT_DIR / "results.csv"])
|
|
image_rows = load_rows([STABLE_AUDIT_DIR / "results.csv", RESULTS_CSV])
|
|
pngs = all_pngs(STABLE_AUDIT_DIR / "captures", CAPTURE_DIR, THUMB_DIR, LIVE_DIR)
|
|
prs = Presentation()
|
|
prs.slide_width = Inches(13.333)
|
|
prs.slide_height = Inches(7.5)
|
|
|
|
add_title_slide(prs)
|
|
add_summary_slide(prs, summary_rows)
|
|
for section in SECTIONS:
|
|
add_issue_slide(prs, section, image_rows, pngs)
|
|
add_no_issue_slides(prs)
|
|
|
|
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
prs.save(OUT_PATH)
|
|
print(OUT_PATH)
|
|
print(f"slides={len(prs.slides)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
build()
|