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