v1.0.0: PPT → HTML Evaluation Benchmark

main
Eunchan Lee 2025-11-14 10:58:27 +09:00
commit e09b04c4f2
57 changed files with 17628 additions and 0 deletions

14
Makefile Normal file
View File

@ -0,0 +1,14 @@
.PHONY: install preprocess metrics full
install:
poetry install
poetry run playwright install chromium
preprocess:
poetry run ppt2html-eval preprocess data/raw/samples.csv --work-dir data
metrics:
poetry run ppt2html-eval metrics data/processed/json --output-csv data/reports/metrics.csv
full:
poetry run ppt2html-eval full-run data/raw/samples.csv --work-dir data

133
README.md Normal file
View File

@ -0,0 +1,133 @@
# PPT → HTML Evaluation Benchmark
이 프로젝트는 **input PPTX****변환된 HTML(Output)** 간의
텍스트 / 레이아웃 / 시각적 유사도를 평가하는 로컬 평가 도구입니다.
---
## Requirements
- Python 3.10+
- pip (Windows 환경 기준)
- 필요한 패키지는 requirements.txt로 설치
```bash
pip install -r requirements.txt
pip install beautifulsoup4
```
---
## How to Run
### 1) Set PYTHONPATH
```bash
set PYTHONPATH=src
```
---
### 2) Prepare Sample List
`data/raw/samples.csv` 파일 예:
```
id,pptx_path,html_path
input_vs_v1,data/raw/pptx/input.pptx,data/raw/html/output_001.html
input_vs_v2,data/raw/pptx/input.pptx,data/raw/html/output_002.html
```
---
### 3) Preprocess
```bash
python -m eval_ppt2html.cli preprocess data/raw/samples.csv --work-dir data
```
---
### 4) Compute Metrics
```bash
python -m eval_ppt2html.cli metrics data/processed/json --output-csv data/reports/metrics.csv
```
---
## Project Structure
```
ppt2html-eval-benchmark/
src/eval_ppt2html/
cli.py
models.py
preprocess/
metrics/
captions/
utils/
data/
raw/
processed/
reports/
```
---
## 📊 Metrics
- Text BLEU-like
- Text Length Ratio
- Layout IoU
- Final Score (0~100)
------
## Expected Evaluation Output (예상 평가 결과)
아래는 본 평가 도구를 실행했을 때 생성되는
`data/reports/metrics.csv`**예상 출력 예시**입니다.
### 평가 결과 예시
| sample_id | text_bleu | text_length_ratio | layout_iou | ssim | final_score |
|---------------|-----------|-------------------|-------------|------|--------------|
| input_vs_v1 | 82.3 | 0.97 | 0.71 | 0.00 | 78.1 |
| input_vs_v2 | 76.4 | 1.05 | 0.62 | 0.00 | 72.0 |
---
### 컬럼 설명
| 컬럼명 | 설명 |
|--------|------|
| **sample_id** | 비교한 샘플 이름 (input vs output) |
| **text_bleu** | 텍스트 유사도 (BLEU-like, 0~100) |
| **text_length_ratio** | 텍스트 길이 비율 (1.0에 가까울수록 유사) |
| **layout_iou** | 레이아웃 IoU 평균값 (0~1) |
| **ssim** | 이미지 SSIM (현재 0으로 표시됨 — 동적 분석 미사용) |
| **final_score** | 가중치 기반 종합 점수 (0~100) |
---
### Final Score 계산식
```(yaml)
final_score = (
0.4 * (text_bleu / 100)
0.3 * layout_iou
0.3 * ssim
) * 100
```
### 해석 예시
- `input_vs_v1` 의 종합 점수 78.1
- `input_vs_v2` 의 종합 점수 72.0
→ *input_vs_v1(output_001.html)이 원본 PPT와 더 유사함을 의미.*

0
configs/default.yaml Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,434 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>9x16 모바일 리디자인 슬라이드</title>
<style>
:root{
/* Brand palette (DB 계열 유사 톤) */
--brand-green:#0a8a63;
--brand-green-700:#086b4d;
--brand-green-50:#e8f6f0;
--brand-orange:#ff7a00;
--brand-yellow:#ffd24d;
--brand-sky:#e8f4ff;
--ink:#111111;
--ink-2:#333333;
--ink-3:#555e65;
--line:#e6eaee;
--bg:#f6f8f9;
--surface:#ffffff;
/* Typography */
--font-sans: -apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo","Noto Sans KR","Malgun Gothic",Segoe UI,Roboto,system-ui,Helvetica,Arial,sans-serif;
--fs-xxl: clamp(20px, 3.5vw, 26px);
--fs-xl: clamp(18px, 3.2vw, 22px);
--fs-lg: clamp(16px, 3vw, 20px);
--fs-md: clamp(14px, 2.8vw, 18px);
--fs-sm: clamp(12px, 2.4vw, 16px);
--fs-xs: clamp(11px, 2.1vw, 14px);
/* Spacing scale */
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
/* Radius and shadow */
--radius-2: 10px;
--radius-3: 16px;
--shadow-1: 0 6px 18px rgba(0,0,0,0.06);
--shadow-2: 0 10px 28px rgba(0,0,0,0.10);
}
/* Utility classes */
.stack{ display:flex; flex-direction:column; }
.row{ display:flex; gap:var(--space-3); align-items:center; flex-wrap:wrap; }
.center{ display:grid; place-items:center; }
.mt-0{ margin-top:var(--space-0);}
.mt-1{ margin-top:var(--space-1);}
.mt-2{ margin-top:var(--space-2);}
.mt-3{ margin-top:var(--space-3);}
.mt-4{ margin-top:var(--space-4);}
.mt-5{ margin-top:var(--space-5);}
.mt-6{ margin-top:var(--space-6);}
.mt-7{ margin-top:var(--space-7);}
.mt-8{ margin-top:var(--space-8);}
.mb-0{ margin-bottom:var(--space-0);}
.mb-1{ margin-bottom:var(--space-1);}
.mb-2{ margin-bottom:var(--space-2);}
.mb-3{ margin-bottom:var(--space-3);}
.mb-4{ margin-bottom:var(--space-4);}
.mb-5{ margin-bottom:var(--space-5);}
.mb-6{ margin-bottom:var(--space-6);}
.mb-7{ margin-bottom:var(--space-7);}
.mb-8{ margin-bottom:var(--space-8);}
.p-0{ padding:var(--space-0);}
.p-2{ padding:var(--space-2);}
.p-3{ padding:var(--space-3);}
.p-4{ padding:var(--space-4);}
.p-5{ padding:var(--space-5);}
.px-4{ padding-left:var(--space-4); padding-right:var(--space-4);}
.py-4{ padding-top:var(--space-4); padding-bottom:var(--space-4);}
.chip{ display:inline-block; padding:4px 10px; border-radius:999px; background:var(--brand-green-50); color:var(--brand-green); font-weight:600; font-size:var(--fs-xs); }
/* App shell */
body{ margin:0; background:var(--bg); color:var(--ink); font-family:var(--font-sans); }
.deck{ width:100%; max-width:560px; margin:0 auto; padding: min(4vw, 18px); }
/* Slide card 9:16 portrait */
.slide{
background:var(--surface);
border-radius:var(--radius-3);
box-shadow:var(--shadow-1);
overflow:hidden;
margin: clamp(14px, 2.6vw, 22px) auto;
width:100%;
aspect-ratio:9/16;
display:flex;
flex-direction:column;
}
.slide-header{
background: linear-gradient(135deg, var(--brand-green) 0%, var(--brand-green-700) 100%);
color:#fff;
padding: clamp(12px, 2.6vw, 18px) clamp(14px, 3vw, 20px);
}
.slide-title{
font-size: var(--fs-xl);
line-height:1.2;
font-weight:800;
letter-spacing:-0.2px;
}
.slide-sub{ font-size: var(--fs-sm); opacity:0.95; margin-top:6px; }
.slide-body{
padding: clamp(14px, 3.2vw, 22px);
display:flex;
flex-direction:column;
gap:12px;
overflow:auto;
}
/* Content blocks */
.card{
border:1px solid var(--line);
border-radius:var(--radius-2);
background:#fff;
padding:12px 14px;
}
.accent{
border-left:6px solid var(--brand-green);
padding-left:12px;
}
.note{ font-size:var(--fs-xs); color:var(--ink-3); }
.mono{ font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; }
/* Text scaling inside content */
.t-lg{ font-size: var(--fs-lg); line-height:1.5; }
.t-md{ font-size: var(--fs-md); line-height:1.6; }
.t-sm{ font-size: var(--fs-sm); line-height:1.6; }
/* Responsive tweaks */
@media (min-width: 900px){
.deck{ max-width:880px; }
.slide{ max-width:420px; }
.deck.grid{
display:grid;
grid-template-columns: repeat(2, minmax(300px, 1fr));
gap:24px;
}
}
</style>
</head>
<body>
<main class="deck grid">
<!-- Slide 1 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">위자료 상해 급수별로 정액을 지급함 ( 염좌는 15만원 )</div>
<div class="slide-sub">정책/보상 개요</div>
</header>
<div class="slide-body">
<div class="card accent t-md">
휴업손해<br>
소득 신고 및 입증 되는 소득의 85%를 입원기간 동안 인정<br>
( 15% 공제 이유는? 경제활동 과정에서의 비용을 차감 ) 3,144,413원
</div>
<div class="card t-md">
실제 치료비 말 그대로의 실제로 발생한 치료비, 요건 병원에다가<br>
보험사에서 납부 해주겠죠?
</div>
<div class="card t-md">
향후 치료비<br>
Point, 합의라는 과정은 상호간의 협의.<br>
지금까지 100만원 어치 치료받아서 차도가 있으니, 향후에<br>
받을 치료비를 예를들어 50만원 지급하기로 하고 합의하는 항목<br>
(약관상에 항목에는 없음, 단 합의를 위한 조정 항목)
</div>
<div class="card t-md">
기타 손해배상금 통원시에 교통비, 1회 통원시마다 8천원의 교통비 지급.<br>
입원은 해당 안됨, 만일 하루에 병원을 2곳가면 1만6천원
</div>
</div>
</section>
<!-- Slide 2 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">염좌 12급 / 6일입원 / 2일 통원 / 무과실 / 주부 / 120만</div>
<div class="slide-sub"></div>
</header>
<div class="slide-body">
<div class="row">
<span class="chip">위자료</span>
<span class="chip">휴업손해</span>
<span class="chip">기타손배금</span>
<span class="chip">향후치료비</span>
</div>
<div class="card mono t-md">
12급 : 150,000원
</div>
<div class="card mono t-md">
3,144,413 / 30일 * 85% * 6일 = 534,546원
</div>
<div class="card mono t-md">
8,000 * 2일 = 16,000원
</div>
<div class="card mono t-lg" style="border-color:var(--brand-orange)">
499,454원
</div>
<p class="note">계산 수치는 원문 값 그대로 표기</p>
</div>
</section>
<!-- Slide 3 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">활동 이슈 - 신담보 출시</div>
<div class="slide-sub">상품 라인업</div>
</header>
<div class="slide-body">
<div class="card t-lg">
하이클래스 암주요치료비 2종<br>
암주요치료비(상급종합병원플러스)
</div>
<div class="card t-sm">
모바일 포맷에 최적화된 요약 카드로 구성
</div>
</div>
</section>
<!-- Slide 4 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">지표 데이터</div>
<div class="slide-sub">원문 수치 보전</div>
</header>
<div class="slide-body">
<div class="card mono t-md">
16.3 31.5 37.1 12.8 24.9 28.2<br>
24.1 40.6 45.3 20.2 31.4 33.3<br>
12.7 28.3 32.8 9.5 23.7 26.7<br>
9.9 17.5 22.8 2.8 8.0 11.9<br>
24.3 35.8 43.6 11.7 22.0 28.6<br>
24.4 47.9 57.8 13.7 31.8 40.4<br>
23.3 30.1 34.6 11.2 17.2 20.2<br>
33.6 31.8 34.7 7.0 8.7 11.7
</div>
<p class="note">그래프 대신 수치 그대로를 가독성 있게 배치</p>
</div>
</section>
<!-- Slide 5 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">암은 우연히 생기는 질병</div>
<div class="slide-sub">이다?</div>
</header>
<div class="slide-body">
<div class="card t-md">
자료 : 존스홉킨스대 사이언스 논문
</div>
<div class="card t-md">
암은 재수없는 사람이 걸린다? 암은 누가 걸릴지 모른다는 것이 모범답안
</div>
</div>
</section>
<!-- Slide 6 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">01-05 06-10 11-15 16-20 18-22</div>
<div class="slide-sub">구간별 동향</div>
</header>
<div class="slide-body">
<div class="card t-md">
전체 남자(폐암/위암) 여자(유방암/대장암)<br>
(단위 : %)
</div>
<div class="card mono t-md">
54.2%(01-05)  65.5%(06-10)70.8%(11-15)72.9%(18-22)
</div>
<div class="card t-md">
2025년 1월 2일 22년 국가암등록통계 발표
</div>
</div>
</section>
<!-- Slide 7 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">암종 목록</div>
<div class="slide-sub">첫번째 세트</div>
</header>
<div class="slide-body">
<div class="card t-md">
갑상선암<br>
폐암<br>
대장암<br>
위암<br>
유방암<br>
전립선암<br>
간암<br>
췌장암<br>
담낭 및 기타담도 암<br>
신장암
</div>
</div>
</section>
<!-- Slide 8 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">암종 목록</div>
<div class="slide-sub">두번째 세트</div>
</header>
<div class="slide-body">
<div class="card t-md">
갑상선암<br>
대장암<br>
폐암<br>
위암<br>
유방암<br>
전립선암<br>
간암<br>
췌장암<br>
담낭 및 기타담도 암<br>
신장암
</div>
</div>
</section>
<!-- Slide 9 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">암종/순위 주석</div>
<div class="slide-sub">메모</div>
</header>
<div class="slide-body">
<div class="card t-md">
갑상선암(여성2위)<br>
대장암(남/여성3<br>
위)<br>
폐암(남성1위/여성4<br>
위)<br>
유방암(여성1위)<br>
위암<br>
전립선암(남성2위)<br>
간암(남성5위)<br>
췌장암<br>
신장암<br>
담낭 및 기타담도 암
</div>
<div class="card t-md">
남성 比 여성3배 ↑<br>
사망률 1위 / 감각신경 X<br>
뒤끝 있는 암 / 10년 후 재발 25% / 5위-&gt;4위<br>
서양형 암 → 한국형 암<br>
21년 남성 4위
</div>
</div>
</section>
<!-- Slide 10 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">구 분 마스토체크</div>
<div class="slide-sub">검사 정보</div>
</header>
<div class="slide-body">
<div class="card t-md">
대 상 전연령<br>
정확도 특허기준 92%<br>
1ml의 혈액채혈로 검사 가능<br>
치밀유방의 경우 높은 정확도<br>
특 징<br>
비 용 8~10만원
</div>
</div>
</section>
<!-- Slide 11 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">PSA 수치 전립선암 진단 확률</div>
<div class="slide-sub">수치표</div>
</header>
<div class="slide-body">
<div class="card mono t-md">
0~4 12.4~16.0<br>
4~10 15.9~19.6<br>
10~20 33.0~34.1<br>
20~100 76.0~77.6
</div>
</div>
</section>
<!-- Slide 12 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">과거</div>
<div class="slide-sub">현재</div>
</header>
<div class="slide-body">
<div class="row">
<div class="card t-lg" style="flex:1">
<br>
뇌/심
</div>
<div class="card t-lg" style="flex:1">
<br>
뇌/심
</div>
</div>
</div>
</section>
<!-- Slide 13 -->
<section class="slide">
<header class="slide-header">
<div class="slide-title">건강보험 통계분석해보니. 서울 원정 암 환자, 더 늘었다</div>
<div class="slide-sub">이데일리 24.12.3</div>
</header>
<div class="slide-body">
<div class="card t-md">
국립암센터(고양<br>
시) 원자력병원(서울/노원구)
</div>
<div class="card t-sm">
원문 문구와 줄바꿈을 유지하여 모바일 가독성 강화
</div>
</div>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,434 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>9x16 모바일 리디자인 데크</title>
<style>
:root{
/* Color system */
--brand-primary: #0a8f55; /* DB green */
--brand-primary-2: #0b6f44; /* darker green */
--brand-accent: #ff7a00; /* accent orange */
--brand-accent-2: #ffd9b3; /* soft accent */
--text-strong: #111111;
--text-normal: #222222;
--text-muted: #606873;
--bg-page: #f5f7f8;
--bg-slide: #ffffff;
--border: #e5e8eb;
--success: #12b886;
--warning: #f08c00;
--info: #1971c2;
/* Typography */
--font-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Helvetica Neue", Arial, "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
--fs-xxl: 28px;
--fs-xl: 22px;
--fs-lg: 18px;
--fs-md: 16px;
--fs-sm: 14px;
--fs-xs: 12px;
/* Spacing scale (4px grid) */
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* Elevation & Radius */
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--shadow-sm: 0 2px 8px rgba(0,0,0,0.06);
--shadow-md: 0 6px 20px rgba(0,0,0,0.10);
}
/* Reset-ish */
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font-family:var(--font-sans);
color:var(--text-normal);
background:linear-gradient(180deg, #f6fbf8, #f5f7f8);
}
/* Utilities */
.stack{display:flex;flex-direction:column;gap:var(--space-4)}
.row{display:flex;gap:var(--space-4);align-items:flex-start;flex-wrap:wrap}
.center{display:flex;justify-content:center;align-items:center}
.mt-0{margin-top:0}.mt-1{margin-top:var(--space-1)}.mt-2{margin-top:var(--space-2)}.mt-3{margin-top:var(--space-3)}.mt-4{margin-top:var(--space-4)}.mt-6{margin-top:var(--space-6)}.mt-8{margin-top:var(--space-8)}
.mb-0{margin-bottom:0}.mb-2{margin-bottom:var(--space-2)}.mb-4{margin-bottom:var(--space-4)}.mb-6{margin-bottom:var(--space-6)}
.p-0{padding:0}.p-2{padding:var(--space-2)}.p-3{padding:var(--space-3)}.p-4{padding:var(--space-4)}.p-6{padding:var(--space-6)}.p-8{padding:var(--space-8)}
.text-xs{font-size:var(--fs-xs)}.text-sm{font-size:var(--fs-sm)}.text-md{font-size:var(--fs-md)}.text-lg{font-size:var(--fs-lg)}.text-xl{font-size:var(--fs-xl)}.text-xxl{font-size:var(--fs-xxl)}
.muted{color:var(--text-muted)}
.strong{font-weight:700}
.accent{color:var(--brand-accent)}
.chip{display:inline-block;border-radius:999px;background:var(--brand-accent-2);padding:4px 10px;color:#7a3e00;font-weight:600}
.badge{display:inline-block;border-radius:6px;background:#eef6f0;color:var(--brand-primary-2);padding:3px 8px;font-weight:600}
/* Deck & Slides */
.deck{
width:100%;
max-width:900px;
margin:0 auto;
padding:var(--space-8) var(--space-4) var(--space-12);
}
.slides{
display:grid;
grid-template-columns:1fr;
gap:var(--space-8);
}
.slide{
width:min(100%, 460px);
aspect-ratio:9/16;
background:var(--bg-slide);
border-radius:var(--radius-lg);
box-shadow:var(--shadow-md);
margin:0 auto;
display:flex;
flex-direction:column;
overflow:hidden;
}
.slide .bar{
height:64px;
background:linear-gradient(90deg, var(--brand-primary), var(--brand-primary-2));
}
.slide .content{
flex:1;
padding:20px 18px 14px 18px;
display:flex;
flex-direction:column;
}
.slide h2{
font-size:var(--fs-xl);
line-height:1.25;
margin:0 0 var(--space-3) 0;
color:var(--text-strong);
letter-spacing:-0.2px;
}
.slide h3{
font-size:var(--fs-lg);
line-height:1.3;
margin:var(--space-3) 0 var(--space-2) 0;
color:var(--text-strong);
}
.slide p{
font-size:var(--fs-md);
line-height:1.55;
margin:0 0 var(--space-2) 0;
}
.card{
border:1px solid var(--border);
border-radius:var(--radius-md);
padding:14px;
background:#fff;
}
.data-block{
font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size:var(--fs-sm);
background:#fbfcfd;
border:1px solid var(--border);
border-radius:10px;
padding:12px;
line-height:1.55;
overflow:auto;
white-space:pre-wrap;
}
.kpi{
display:flex;justify-content:space-between;align-items:center;
padding:10px 12px;border:1px dashed var(--border);border-radius:10px;
background:#fcfffd;
font-size:var(--fs-md);
}
.footer{
height:42px;
display:flex;
align-items:center;
justify-content:space-between;
padding:0 14px;
border-top:1px solid var(--border);
background:#fafcfa;
color:var(--text-muted);
font-size:var(--fs-xs);
}
.dot{
width:8px;height:8px;border-radius:50%;
background:var(--brand-primary);
box-shadow:0 0 0 4px rgba(10,143,85,0.12);
}
@media (max-width:420px){
.slide{width:100%}
.slide h2{font-size:20px}
.slide p{font-size:15px}
.data-block{font-size:13px}
}
</style>
</head>
<body>
<div class="deck">
<div class="slides">
<!-- Slide 1 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>활동 이슈 - 신담보 출시</h2>
<div class="stack">
<p>하이클래스 암주요치료비 2종<br>암주요치료비(상급종합병원플러스)</p>
<div class="row">
<span class="chip">9x16 모바일</span>
<span class="badge">컬러감 유지</span>
<span class="badge">텍스트 원문</span>
</div>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>1 / 14</span>
</div>
</section>
<!-- Slide 2 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>위자료 / 휴업손해 / 실제 치료비 / 향후 치료비 / 기타 손해배상금</h2>
<div class="card">
<p>위자료 상해 급수별로 정액을 지급함 ( 염좌는 15만원 )</p>
<p>휴업손해<br>소득 신고 및 입증 되는 소득의 85%를 입원기간 동안 인정<br>( 15% 공제 이유는? 경제활동 과정에서의 비용을 차감 ) 3,144,413원</p>
<p>실제 치료비 말 그대로의 실제로 발생한 치료비, 요건 병원에다가<br>보험사에서 납부 해주겠죠?</p>
<p>향후 치료비<br>Point, 합의라는 과정은 상호간의 협의.<br>지금까지 100만원 어치 치료받아서 차도가 있으니, 향후에<br>받을 치료비를 예를들어 50만원 지급하기로 하고 합의하는 항목<br>(약관상에 항목에는 없음, 단 합의를 위한 조정 항목)</p>
<p>기타 손해배상금 통원시에 교통비, 1회 통원시마다 8천원의 교통비 지급.<br>입원은 해당 안됨, 만일 하루에 병원을 2곳가면 1만6천원</p>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>2 / 14</span>
</div>
</section>
<!-- Slide 3 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>염좌 12급 / 6일입원 / 2일 통원 / 무과실 / 주부 / 120만<br></h2>
<div class="stack">
<div class="row">
<span class="badge">위자료</span>
<span class="badge">휴업손해</span>
<span class="badge">기타손배금</span>
<span class="badge">향후치료비</span>
</div>
<div class="card">
<p>12급 : 150,000원</p>
<p>3,144,413 / 30일 * 85% * 6일 = 534,546원</p>
<p>8,000 * 2일 = 16,000원</p>
<p class="strong accent">499,454원</p>
</div>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>3 / 14</span>
</div>
</section>
<!-- Slide 4 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>25년 주요치료비 가입실적 비중</h2>
<div class="data-block">
16.3 31.5 37.1 12.8 24.9 28.2
24.1 40.6 45.3 20.2 31.4 33.3
12.7 28.3 32.8 9.5 23.7 26.7
9.9 17.5 22.8 2.8 8.0 11.9
</div>
<p class="muted text-sm mt-2">단위 : %</p>
</div>
<div class="footer">
<span class="dot"></span>
<span>4 / 14</span>
</div>
</section>
<!-- Slide 5 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>25년 주요치료비 가입실적 비중</h2>
<div class="data-block">
24.3 35.8 43.6 11.7 22.0 28.6
24.4 47.9 57.8 13.7 31.8 40.4
23.3 30.1 34.6 11.2 17.2 20.2
33.6 31.8 34.7 7.0 8.7 11.7
</div>
<p class="muted text-sm mt-2">단위 : %</p>
</div>
<div class="footer">
<span class="dot"></span>
<span>5 / 14</span>
</div>
</section>
<!-- Slide 6 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>암은 우연히 생기는 질병<br>이다?</h2>
<div class="card">
<p>자료 : 존스홉킨스대 사이언스 논문</p>
<p>암은 재수없는 사람이 걸린다? 암은 누가 걸릴지 모른다는 것이 모범답안</p>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>6 / 14</span>
</div>
</section>
<!-- Slide 7 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>01-05 06-10 11-15 16-20 18-22</h2>
<div class="stack">
<p>전체 남자(폐암/위암) 여자(유방암/대장암)<br>(단위 : %)</p>
<div class="kpi"><span>54.2%(01-05)  65.5%(06-10)70.8%(11-15)72.9%(18-22)</span></div>
<p class="muted">2025년 1월 2일 22년 국가암등록통계 발표</p>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>7 / 14</span>
</div>
</section>
<!-- Slide 8 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2 class="mb-2">암 목록</h2>
<div class="row">
<div class="card" style="flex:1 1 100%">
<p>갑상선암<br>폐암<br>대장암<br>위암<br>유방암<br>전립선암<br>간암<br>췌장암<br>담낭 및 기타담도 암<br>신장암</p>
</div>
<div class="card" style="flex:1 1 100%">
<p>갑상선암<br>대장암<br>폐암<br>위암<br>유방암<br>전립선암<br>간암<br>췌장암<br>담낭 및 기타담도 암<br>신장암</p>
</div>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>8 / 14</span>
</div>
</section>
<!-- Slide 9 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>암 순위/특징</h2>
<div class="card">
<p>갑상선암(여성2위)<br>대장암(남/여성3<br>위)<br>폐암(남성1위/여성4<br>위)<br>유방암(여성1위)<br>위암<br>전립선암(남성2위)<br>간암(남성5위)<br>췌장암<br>신장암<br>담낭 및 기타담도 암</p>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>9 / 14</span>
</div>
</section>
<!-- Slide 10 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>특징/경향</h2>
<div class="card">
<p>남성 比 여성3배 ↑<br>사망률 1위 / 감각신경 X<br>뒤끝 있는 암 / 10년 후 재발 25% / 5위->4위<br>서양형 암 → 한국형 암<br>21년 남성 4위</p>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>10 / 14</span>
</div>
</section>
<!-- Slide 11 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>구 분 마스토체크</h2>
<div class="card">
<p>대 상 전연령<br>정확도 특허기준 92%<br>1ml의 혈액채혈로 검사 가능<br>치밀유방의 경우 높은 정확도<br>특 징<br>비 용 8~10만원</p>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>11 / 14</span>
</div>
</section>
<!-- Slide 12 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>PSA 수치 전립선암 진단 확률</h2>
<div class="data-block">
0~4 12.4~16.0
4~10 15.9~19.6
10~20 33.0~34.1
20~100 76.0~77.6
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>12 / 14</span>
</div>
</section>
<!-- Slide 13 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>과거<br><br>뇌/심<br>현재<br><br>뇌/심</h2>
<p class="muted mt-2">구조 유지</p>
</div>
<div class="footer">
<span class="dot"></span>
<span>13 / 14</span>
</div>
</section>
<!-- Slide 14 -->
<section class="slide">
<div class="bar"></div>
<div class="content">
<h2>건강보험 통계분석해보니. 서울 원정 암 환자, 더 늘었다</h2>
<div class="stack">
<p>이데일리 24.12.3</p>
<div class="card">
<p>국립암센터(고양<br>시) 원자력병원(서울/노원구)</p>
</div>
</div>
</div>
<div class="footer">
<span class="dot"></span>
<span>14 / 14</span>
</div>
</section>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,375 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>9x16 모바일 리디자인</title>
<style>
:root{
/* Color system */
--brand: #0BA360; /* DB 계열 그린 톤 */
--brand-dark: #0A5B3D;
--brand-light: #E6F5EE;
--accent: #E94E3C; /* 강조(수치/합계) */
--ink: #122129; /* 기본 본문 */
--muted: #5B6B73; /* 보조 본문 */
--surface: #FFFFFF;
--bg: #F4F7F6;
/* Typography */
--font-sans: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--fs-hero: 28px;
--fs-h1: 22px;
--fs-h2: 18px;
--fs-h3: 15px;
--fs-body: 14px;
--fs-small: 12px;
--lh-tight: 1.2;
--lh-normal: 1.5;
--lh-relaxed: 1.7;
/* Spacing scale (4px grid) */
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* Radius & shadow */
--radius-6: 6px;
--radius-12: 12px;
--radius-16: 16px;
--shadow-1: 0 4px 12px rgba(0,0,0,.06);
--shadow-2: 0 8px 20px rgba(0,0,0,.10);
}
/* Layout scaffold */
body{
margin:0;
background: var(--bg);
font-family: var(--font-sans);
color: var(--ink);
}
.deck{
max-width: 470px;
margin: 0 auto;
padding: var(--space-6) var(--space-4) var(--space-10);
display: grid;
gap: var(--space-6);
}
.page{
aspect-ratio: 9 / 16;
background: var(--surface);
border-radius: var(--radius-16);
box-shadow: var(--shadow-1);
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
}
.page .bar{
height: 10px;
background: linear-gradient(90deg, var(--brand), #12C77D);
}
.page .content{
padding: var(--space-6);
overflow: auto;
}
.page .footer{
padding: var(--space-2) var(--space-6) var(--space-4);
display:flex;align-items:center;justify-content:flex-end;
color: var(--muted);
font-size: var(--fs-small);
}
/* Typography system */
.hero{
font-size: var(--fs-hero);
line-height: var(--lh-tight);
font-weight: 800;
letter-spacing: -0.2px;
color: var(--brand-dark);
margin: 0 0 var(--space-3) 0;
}
h1{
font-size: var(--fs-h1);
line-height: var(--lh-tight);
font-weight: 800;
letter-spacing: -0.2px;
color: var(--brand-dark);
margin: 0 0 var(--space-4) 0;
}
h2{
font-size: var(--fs-h2);
line-height: var(--lh-tight);
font-weight: 700;
color: var(--brand);
margin: var(--space-5) 0 var(--space-2) 0;
}
h3{
font-size: var(--fs-h3);
line-height: var(--lh-tight);
font-weight: 700;
color: var(--ink);
margin: var(--space-4) 0 var(--space-2) 0;
}
.txt{
font-size: var(--fs-body);
line-height: var(--lh-relaxed);
color: var(--ink);
margin: 0 0 var(--space-3) 0;
}
.muted{ color: var(--muted); }
.chip{
display:inline-block;
padding: 2px 8px;
border-radius: 999px;
background: var(--brand-light);
color: var(--brand-dark);
font-size: var(--fs-small);
font-weight: 700;
}
.panel{
background: #F9FCFB;
border: 1px solid #E3F3EC;
border-radius: var(--radius-12);
padding: var(--space-4);
box-shadow: inset 0 -1px 0 rgba(0,0,0,0.04);
}
.stat{
color: var(--accent);
font-weight: 800;
letter-spacing: -0.2px;
}
.grid-2{
display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-4);
}
.mono{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
letter-spacing: 0.2px;
}
/* Spacing utilities */
.mt-0{ margin-top: var(--space-0); }
.mt-2{ margin-top: var(--space-2); }
.mt-3{ margin-top: var(--space-3); }
.mt-4{ margin-top: var(--space-4); }
.mt-5{ margin-top: var(--space-5); }
.mt-6{ margin-top: var(--space-6); }
.mb-0{ margin-bottom: var(--space-0); }
.mb-2{ margin-bottom: var(--space-2); }
.mb-3{ margin-bottom: var(--space-3); }
.mb-4{ margin-bottom: var(--space-4); }
.mb-5{ margin-bottom: var(--space-5); }
.mb-6{ margin-bottom: var(--space-6); }
.px-4{ padding-left: var(--space-4); padding-right: var(--space-4); }
/* Decorative */
.bullet{
position: relative;
padding-left: 14px;
}
.bullet::before{
content:"";
position:absolute; left:0; top:0.7em;
width:6px;height:6px;border-radius:50%;
background: var(--brand);
}
/* Responsive helper for smaller phones */
@media (max-width: 360px){
:root{
--fs-hero: 24px;
--fs-h1: 20px;
--fs-h2: 16px;
--fs-h3: 14px;
--fs-body: 13px;
}
}
</style>
</head>
<body>
<main class="deck">
<!-- Page 1 -->
<section class="page" aria-label="자부상 개요">
<div class="bar"></div>
<div class="content">
<h1>영업교육파트 보험업계 핫 이슈 플러스 - ① 자부상</h1>
<h3>위자료</h3>
<p class="txt">상해 급수별로 정액을 지급함 ( 염좌는 15만원 )</p>
<h3>휴업손해</h3>
<p class="txt">소득 신고 및 입증 되는 소득의 85%를 입원기간 동안 인정<br>( 15% 공제 이유는? 경제활동 과정에서의 비용을 차감 ) 3,144,413원</p>
<h3>실제 치료비</h3>
<p class="txt">말 그대로의 실제로 발생한 치료비, 요건 병원에다가<br>보험사에서 납부 해주겠죠?</p>
<h3>향후 치료비</h3>
<p class="txt">Point, 합의라는 과정은 상호간의 협의.<br>지금까지 100만원 어치 치료받아서 차도가 있으니, 향후에<br>받을 치료비를 예를들어 50만원 지급하기로 하고 합의하는 항목<br>(약관상에 항목에는 없음, 단 합의를 위한 조정 항목)</p>
<h3>기타 손해배상금</h3>
<p class="txt">통원시에 교통비, 1회 통원시마다 8천원의 교통비 지급.<br>입원은 해당 안됨, 만일 하루에 병원을 2곳가면 1만6천원</p>
</div>
<div class="footer">9x16</div>
</section>
<!-- Page 2 -->
<section class="page" aria-label="자부상 사례 산출">
<div class="bar"></div>
<div class="content">
<h1>염좌 12급 / 6일입원 / 2일 통원 / 무과실 / 주부 / 120만<br></h1>
<div class="grid-2 mt-4">
<div class="panel">
<h3 class="mb-2">위자료</h3>
<p class="txt">12급 : <span class="stat">150,000원</span></p>
</div>
<div class="panel">
<h3 class="mb-2">휴업손해</h3>
<p class="txt mono">3,144,413 / 30일 * 85% * 6일 = <span class="stat">534,546원</span></p>
</div>
</div>
<div class="grid-2 mt-4">
<div class="panel">
<h3 class="mb-2">기타손배금</h3>
<p class="txt mono">8,000 * 2일 = <span class="stat">16,000원</span></p>
</div>
<div class="panel">
<h3 class="mb-2">향후치료비</h3>
<p class="txt"><span class="stat">499,454원</span></p>
</div>
</div>
<div class="panel mt-6">
<p class="txt muted">위자료<br>휴업손해<br>기타손배금<br>향후치료비</p>
</div>
</div>
<div class="footer">9x16</div>
</section>
<!-- Page 3 -->
<section class="page" aria-label="신담보 출시">
<div class="bar"></div>
<div class="content">
<h1>활동 이슈 - 신담보 출시</h1>
<h2>하이클래스 암주요치료비 2종</h2>
<p class="txt chip mt-2">암주요치료비(상급종합병원플러스)</p>
</div>
<div class="footer">9x16</div>
</section>
<!-- Page 4 -->
<section class="page" aria-label="가입실적 비중 수치">
<div class="bar"></div>
<div class="content">
<h1>주요 수치</h1>
<div class="panel mono">
<p class="txt mb-2">16.3 31.5 37.1 12.8 24.9 28.2<br>24.1 40.6 45.3 20.2 31.4 33.3<br>12.7 28.3 32.8 9.5 23.7 26.7<br>9.9 17.5 22.8 2.8 8.0 11.9</p>
<p class="txt">24.3 35.8 43.6 11.7 22.0 28.6<br>24.4 47.9 57.8 13.7 31.8 40.4<br>23.3 30.1 34.6 11.2 17.2 20.2<br>33.6 31.8 34.7 7.0 8.7 11.7</p>
</div>
</div>
<div class="footer">9x16</div>
</section>
<!-- Page 5 -->
<section class="page" aria-label="암 인식">
<div class="bar"></div>
<div class="content">
<h1>암은 우연히 생기는 질병<br>이다?</h1>
<p class="txt">자료 : 존스홉킨스대 사이언스 논문</p>
<p class="txt bullet">암은 재수없는 사람이 걸린다? 암은 누가 걸릴지 모른다는 것이 모범답안</p>
</div>
<div class="footer">9x16</div>
</section>
<!-- Page 6 -->
<section class="page" aria-label="국가암등록통계">
<div class="bar"></div>
<div class="content">
<h1>01-05 06-10 11-15 16-20 18-22</h1>
<p class="txt">전체 남자(폐암/위암) 여자(유방암/대장암)<br>(단위 : %)</p>
<p class="txt mono stat">54.2%(01-05)  65.5%(06-10)70.8%(11-15)72.9%(18-22)</p>
<p class="txt mt-3">2025년 1월 2일 22년 국가암등록통계 발표</p>
</div>
<div class="footer">9x16</div>
</section>
<!-- Page 7 -->
<section class="page" aria-label="암 발생 순위 및 특징">
<div class="bar"></div>
<div class="content">
<h1>암 발생</h1>
<div class="grid-2">
<div>
<p class="txt">갑상선암<br>폐암<br>대장암<br>위암<br>유방암<br>전립선암<br>간암<br>췌장암<br>담낭 및 기타담도 암<br>신장암</p>
</div>
<div>
<p class="txt">갑상선암<br>대장암<br>폐암<br>위암<br>유방암<br>전립선암<br>간암<br>췌장암<br>담낭 및 기타담도 암<br>신장암</p>
</div>
</div>
<div class="panel mt-4">
<p class="txt">갑상선암(여성2위)<br>대장암(남/여성3<br>위)<br>폐암(남성1위/여성4<br>위)<br>유방암(여성1위)<br>위암<br>전립선암(남성2위)<br>간암(남성5위)<br>췌장암<br>신장암<br>담낭 및 기타담도 암</p>
</div>
<div class="grid-2 mt-4">
<p class="txt bullet">남성 比 여성3배 ↑</p>
<p class="txt bullet">사망률 1위 / 감각신경 X</p>
<p class="txt bullet">뒤끝 있는 암 / 10년 후 재발 25% / 5위-&gt;4위</p>
<p class="txt bullet">서양형 암 → 한국형 암</p>
</div>
<p class="txt mt-3">21년 남성 4위</p>
</div>
<div class="footer">9x16</div>
</section>
<!-- Page 8 -->
<section class="page" aria-label="검사 안내">
<div class="bar"></div>
<div class="content">
<h1>구 분 마스토체크</h1>
<p class="txt">대 상 전연령<br>정확도 특허기준 92%<br>1ml의 혈액채혈로 검사 가능<br>치밀유방의 경우 높은 정확도<br>특 징<br>비 용 8~10만원</p>
<h2 class="mt-4">PSA 수치 전립선암 진단 확률</h2>
<div class="panel mono">
<p class="txt">0~4 12.4~16.0<br>4~10 15.9~19.6<br>10~20 33.0~34.1<br>20~100 76.0~77.6</p>
</div>
</div>
<div class="footer">9x16</div>
</section>
<!-- Page 9 -->
<section class="page" aria-label="과거와 현재">
<div class="bar"></div>
<div class="content">
<h1>과거</h1>
<p class="txt"><br>뇌/심</p>
<h1 class="mt-5">현재</h1>
<p class="txt"><br>뇌/심</p>
</div>
<div class="footer">9x16</div>
</section>
<!-- Page 10 -->
<section class="page" aria-label="이슈 기사">
<div class="bar"></div>
<div class="content">
<h1>건강보험 통계분석해보니. 서울 원정 암 환자, 더 늘었다</h1>
<p class="txt">이데일리 24.12.3</p>
<p class="txt">국립암센터(고양<br>시) 원자력병원(서울/노원구)</p>
</div>
<div class="footer">9x16</div>
</section>
</main>
</body>
</html>

BIN
data/raw/pptx/input.pptx Normal file

Binary file not shown.

3
data/raw/samples.csv Normal file
View File

@ -0,0 +1,3 @@
id,pptx_path,html_path
input_vs_v1,data/raw/pptx/input.pptx,data/raw/html/output_001.html
input_vs_v2,data/raw/pptx/input.pptx,data/raw/html/output_002.html
1 id pptx_path html_path
2 input_vs_v1 data/raw/pptx/input.pptx data/raw/html/output_001.html
3 input_vs_v2 data/raw/pptx/input.pptx data/raw/html/output_002.html

0
data/reports/metrics.csv Normal file
View File

23
pyproject.toml Normal file
View File

@ -0,0 +1,23 @@
[tool.poetry]
name = "ppt2html-eval-benchmark"
version = "0.1.0"
description = "PPT -> HTML 변환 품질 벤치마크"
authors = ["Your Name <you@example.com>"]
packages = [{ include = "eval_ppt2html", from = "src" }]
[tool.poetry.dependencies]
python = "^3.10"
python-pptx = "^0.6.23"
pdf2image = "^1.17.0"
playwright = "^1.48.0"
pillow = "^11.0.0"
typer = "^0.12.3"
rich = "^13.9.0"
# 이하: 선택적으로 추가
torch = "^2.4.0" # LPIPS 등
transformers = "^4.44.0" # BERTScore 등
sacrebleu = "^2.4.3"
[tool.poetry.scripts]
ppt2html-eval = "eval_ppt2html.cli:app"

10
requirements.txt Normal file
View File

@ -0,0 +1,10 @@
python-pptx==0.6.23
pdf2image==1.17.0
playwright==1.48.0
Pillow==11.0.0
typer==0.12.3
rich==13.9.0
sacrebleu==2.4.3
transformers==4.44.0
torch==2.4.0
scikit-image==0.24.0

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from pathlib import Path
from eval_ppt2html.models import ImageElement, SamplePair
from eval_ppt2html.utils.logging import get_logger
logger = get_logger(__name__)
def generate_image_caption(image_path: Path) -> str:
"""
VLM 기반 이미지 캡션을 생성하는 자리.
현재는 stub 구현이며, 실제 서비스에서는 OpenAI / Qwen-VL 등으로 교체한다.
"""
# TODO: 실제 모델 호출 로직으로 교체
return f"Auto-generated caption for {image_path.name} (stub)."
def attach_captions(sample: SamplePair) -> None:
"""
샘플 모든 이미지(I_i/I_o) 대해 caption이 비어 있으면 채운다.
"""
logger.info("Attaching image captions (stub) for sample: %s", sample.sample_id)
for img in sample.I_i + sample.I_o:
if img.caption:
continue
img.caption = generate_image_caption(img.file_path)

98
src/eval_ppt2html/cli.py Normal file
View File

@ -0,0 +1,98 @@
# src/eval_ppt2html/cli.py
from __future__ import annotations
from pathlib import Path
from typing import Optional
import typer
from rich import print
from eval_ppt2html.models import SamplePair
from eval_ppt2html.preprocess.pptx_input import preprocess_input_pptx
from eval_ppt2html.preprocess.html_output import preprocess_output_html
from eval_ppt2html.captions.vlm_stub import attach_captions
from eval_ppt2html.eval_units.builder import build_eval_units
from eval_ppt2html.utils.io import (
load_samples_from_csv,
save_sample_to_json,
)
from eval_ppt2html.metrics.aggregate import compute_metrics_for_sample, save_metrics
app = typer.Typer(help="PPT -> HTML 변환 품질 평가 도구")
@app.command()
def preprocess(
samples_csv: Path = typer.Argument(..., help="샘플 목록 CSV (id,pptx_path,html_path)"),
work_dir: Path = typer.Option("data", help="작업 디렉토리 (default: ./data)"),
limit: Optional[int] = typer.Option(None, help="처리 샘플 수 제한"),
):
"""
샘플쌍 (input_pptx, output_html) 전처리하여
T_i, T_o, L_i, L_o, I_i, I_o, S_i, S_o, eval_units 생성한다.
"""
samples = load_samples_from_csv(samples_csv)
if limit is not None:
samples = samples[:limit]
print(f"[bold green]Preprocess {len(samples)} samples...[/bold green]")
for row in samples:
sample_id = row["id"]
pptx_path = Path(row["pptx_path"])
html_path = Path(row["html_path"])
print(f"\n[cyan]>> {sample_id}[/cyan]")
sample = SamplePair(
sample_id=sample_id,
input_pptx=pptx_path,
output_html=html_path,
)
preprocess_input_pptx(sample, work_dir)
preprocess_output_html(sample, work_dir)
attach_captions(sample)
build_eval_units(sample)
save_sample_to_json(sample, work_dir)
print("\n[bold green]Preprocess done.[/bold green]")
@app.command()
def metrics(
processed_dir: Path = typer.Argument("data/processed/json", help="전처리 JSON 디렉토리"),
output_csv: Path = typer.Option("data/reports/metrics.csv", help="결과 저장 경로"),
):
"""
전처리된 JSON 파일들을 읽어 벤치마크 지표(텍스트, 이미지, 레이아웃, 종합 점수) 계산.
"""
json_files = sorted(Path(processed_dir).glob("*.json"))
print(f"[bold green]Compute metrics for {len(json_files)} samples...[/bold green]")
all_metrics = []
for json_path in json_files:
sample_metrics = compute_metrics_for_sample(json_path)
all_metrics.append(sample_metrics)
save_metrics(all_metrics, output_csv)
print(f"[bold green]Saved metrics to {output_csv}[/bold green]")
@app.command()
def full_run(
samples_csv: Path = typer.Argument(..., help="샘플 목록 CSV"),
work_dir: Path = typer.Option("data", help="작업 디렉토리"),
limit: Optional[int] = typer.Option(None, help="처리 샘플 수 제한"),
):
"""
preprocess + metrics 번에 실행.
"""
preprocess(samples_csv=samples_csv, work_dir=work_dir, limit=limit)
processed_dir = Path(work_dir) / "processed" / "json"
output_csv = Path(work_dir) / "reports" / "metrics.csv"
metrics(processed_dir=processed_dir, output_csv=output_csv)
if __name__ == "__main__":
app()

View File

@ -0,0 +1,58 @@
from __future__ import annotations
from collections import defaultdict
from eval_ppt2html.models import EvalUnit, SamplePair
from eval_ppt2html.utils.logging import get_logger
logger = get_logger(__name__)
def build_eval_units(sample: SamplePair) -> None:
"""
단순 규칙:
- input_page_index == output_page_index인 페이지를 1:1 매칭하여 평가 유닛을 생성한다.
- 복잡한 매칭 로직이 필요해지면 함수만 교체하면 된다.
"""
text_by_input_page: dict[int, list[str]] = defaultdict(list)
layout_by_input_page: dict[int, list[str]] = defaultdict(list)
text_by_output_page: dict[int, list[str]] = defaultdict(list)
layout_by_output_page: dict[int, list[str]] = defaultdict(list)
for t in sample.T_i:
text_by_input_page[t.page_index].append(t.id)
for l in sample.L_i:
layout_by_input_page[l.page_index].append(l.id)
for t in sample.T_o:
text_by_output_page[t.page_index].append(t.id)
for l in sample.L_o:
layout_by_output_page[l.page_index].append(l.id)
snapshot_i_by_page = {s.page_index: s.file_path for s in sample.S_i}
snapshot_o_by_page = {s.page_index: s.file_path for s in sample.S_o}
pages = sorted(
set(text_by_input_page.keys())
| set(layout_by_input_page.keys())
| set(text_by_output_page.keys())
| set(layout_by_output_page.keys())
)
logger.info("Building eval units for pages: %s", pages)
for page_index in pages:
unit_id = f"{sample.sample_id}_page{page_index}"
eval_unit = EvalUnit(
unit_id=unit_id,
input_page_index=page_index,
output_page_index=page_index,
input_text_ids=text_by_input_page.get(page_index, []),
output_text_ids=text_by_output_page.get(page_index, []),
input_layout_ids=layout_by_input_page.get(page_index, []),
output_layout_ids=layout_by_output_page.get(page_index, []),
input_snapshot=snapshot_i_by_page.get(page_index),
output_snapshot=snapshot_o_by_page.get(page_index),
)
sample.eval_units.append(eval_unit)

View File

@ -0,0 +1,204 @@
from __future__ import annotations
from pathlib import Path
from typing import Dict, List
from eval_ppt2html.metrics.image_metrics import simple_image_similarity
from eval_ppt2html.metrics.layout_metrics import layout_iou_score
from eval_ppt2html.metrics.text_metrics import aggregate_text_metrics
from eval_ppt2html.models import LayoutElement, SamplePair, TextElement
from eval_ppt2html.utils.io import load_sample_json
from eval_ppt2html.utils.logging import get_logger
logger = get_logger(__name__)
def _rebuild_sample_from_dict(data: Dict) -> SamplePair:
"""
JSON dict에서 SamplePair 구조를 재구성한다.
(메트릭 계산에 필요한 최소 필드만 사용.)
"""
from eval_ppt2html.models import (
ImageElement,
LayoutElement,
PageSnapshot,
SamplePair,
TextElement,
)
sample = SamplePair(
sample_id=data["sample_id"],
input_pptx=Path(data["input_pptx"]),
output_html=Path(data["output_html"]),
)
for t in data.get("T_i", []):
sample.T_i.append(
TextElement(
id=t["id"],
page_index=t["page_index"],
content=t["content"],
role=t["role"],
hierarchy_level=t.get("hierarchy_level"),
bbox=tuple(t["bbox"]) if t.get("bbox") else None,
font_family=t.get("font_family"),
font_size=t.get("font_size"),
font_weight=t.get("font_weight"),
color=t.get("color"),
)
)
for t in data.get("T_o", []):
sample.T_o.append(
TextElement(
id=t["id"],
page_index=t["page_index"],
content=t["content"],
role=t["role"],
hierarchy_level=t.get("hierarchy_level"),
bbox=tuple(t["bbox"]) if t.get("bbox") else None,
font_family=t.get("font_family"),
font_size=t.get("font_size"),
font_weight=t.get("font_weight"),
color=t.get("color"),
)
)
for l in data.get("L_i", []):
sample.L_i.append(
LayoutElement(
id=l["id"],
page_index=l["page_index"],
element_type=l["element_type"],
bbox=tuple(l["bbox"]),
z_index=l.get("z_index"),
role=l.get("role"),
)
)
for l in data.get("L_o", []):
sample.L_o.append(
LayoutElement(
id=l["id"],
page_index=l["page_index"],
element_type=l["element_type"],
bbox=tuple(l["bbox"]),
z_index=l.get("z_index"),
role=l.get("role"),
)
)
for s in data.get("S_i", []):
sample.S_i.append(
PageSnapshot(
page_index=s["page_index"],
file_path=Path(s["file_path"]),
)
)
for s in data.get("S_o", []):
sample.S_o.append(
PageSnapshot(
page_index=s["page_index"],
file_path=Path(s["file_path"]),
)
)
sample.eval_units = data.get("eval_units", [])
return sample
def _pair_text_for_metrics(sample: SamplePair) -> List[tuple[str, str]]:
"""
매우 단순한 텍스트 매칭:
- 페이지 인덱스 기준으로 T_i/T_o를 순서대로 정렬 같은 index끼리 매칭.
"""
refs: List[str] = []
hyps: List[str] = []
input_text = sorted(sample.T_i, key=lambda t: (t.page_index, t.id))
output_text = sorted(sample.T_o, key=lambda t: (t.page_index, t.id))
for idx in range(min(len(input_text), len(output_text))):
refs.append(input_text[idx].content)
hyps.append(output_text[idx].content)
return list(zip(refs, hyps))
def _layout_elements_for_page(
elements: List[LayoutElement],
page_index: int,
) -> List[LayoutElement]:
return [el for el in elements if el.page_index == page_index]
def compute_metrics_for_sample(json_path: Path) -> Dict:
"""
전처리된 샘플(JSON) 대해:
- 텍스트 메트릭
- 이미지/시각 SSIM
- 레이아웃 IoU
- 종합 점수
계산한다.
"""
data = load_sample_json(json_path)
sample = _rebuild_sample_from_dict(data)
logger.info("Computing metrics for sample: %s", sample.sample_id)
# 텍스트 메트릭
text_pairs = _pair_text_for_metrics(sample)
text_metrics = aggregate_text_metrics(text_pairs)
# 레이아웃 메트릭 (page_index = 1 기준 baseline)
layout_input_page1 = _layout_elements_for_page(sample.L_i, page_index=1)
layout_output_page1 = _layout_elements_for_page(sample.L_o, page_index=1)
layout_score = layout_iou_score(layout_input_page1, layout_output_page1)
# 시각 메트릭 (S_i[0], S_o[0] 기준)
if sample.S_i and sample.S_o:
img_metrics = simple_image_similarity(
sample.S_i[0].file_path,
sample.S_o[0].file_path,
)
ssim_score = img_metrics["ssim"]
else:
ssim_score = 0.0
# 종합 스코어(가중합 예시)
text_bleu_norm = text_metrics["mean_bleu"] / 100.0
layout_norm = layout_score
visual_norm = ssim_score
final_score = (
0.4 * text_bleu_norm +
0.3 * layout_norm +
0.3 * visual_norm
)
return {
"sample_id": sample.sample_id,
"text_bleu": text_metrics["mean_bleu"],
"text_length_ratio": text_metrics["mean_length_ratio"],
"layout_iou": layout_score,
"ssim": ssim_score,
"final_score": final_score * 100.0, # 0~100 스케일
}
def save_metrics(metrics_list: List[Dict], output_csv: Path) -> None:
"""다수 샘플 메트릭을 CSV로 저장한다."""
import csv
output_csv.parent.mkdir(parents=True, exist_ok=True)
if not metrics_list:
return
fieldnames = list(metrics_list[0].keys())
with output_csv.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for row in metrics_list:
writer.writerow(row)

View File

@ -0,0 +1,41 @@
from __future__ import annotations
from pathlib import Path
import numpy as np
from PIL import Image
from skimage.metrics import structural_similarity as ssim
def _load_grayscale(path: Path) -> np.ndarray:
img = Image.open(path).convert("L")
return np.array(img)
def compute_ssim(img_path_a: Path, img_path_b: Path) -> float:
"""
이미지(페이지 스냅샷 ) 대한 SSIM 점수 (0~1).
해상도가 다르면 작은 쪽에 맞춘다.
"""
a = _load_grayscale(img_path_a)
b = _load_grayscale(img_path_b)
if a.shape != b.shape:
h = min(a.shape[0], b.shape[0])
w = min(a.shape[1], b.shape[1])
a = a[:h, :w]
b = b[:h, :w]
score, _ = ssim(a, b, full=True)
return float(score)
def simple_image_similarity(img_path_a: Path, img_path_b: Path) -> dict:
"""
이미지 기반 간단 유사도 메트릭 집계 (현재는 SSIM만 제공).
필요 LPIPS 추가 가능.
"""
ssim_score = compute_ssim(img_path_a, img_path_b)
return {
"ssim": ssim_score,
}

View File

@ -0,0 +1,60 @@
from __future__ import annotations
from typing import Iterable, List
from eval_ppt2html.models import BBox, LayoutElement
def _intersection_over_union(a: BBox, b: BBox) -> float:
"""두 bbox 간 IoU(Intersection over Union)를 계산한다."""
ax, ay, aw, ah = a
bx, by, bw, bh = b
x1 = max(ax, bx)
y1 = max(ay, by)
x2 = min(ax + aw, bx + bw)
y2 = min(ay + ah, by + bh)
inter_w = max(0.0, x2 - x1)
inter_h = max(0.0, y2 - y1)
inter_area = inter_w * inter_h
if inter_area == 0:
return 0.0
area_a = aw * ah
area_b = bw * bh
union_area = area_a + area_b - inter_area
if union_area == 0:
return 0.0
return inter_area / union_area
def layout_iou_score(
input_elements: Iterable[LayoutElement],
output_elements: Iterable[LayoutElement],
) -> float:
"""
매우 단순한 레이아웃 유사도:
- input의 요소에 대해 output의 동일 타입 요소 IoU 최대값을 가져와 평균.
- 0~1 스케일, 1 가까울수록 레이아웃이 유지된 .
"""
input_list = list(input_elements)
output_list = list(output_elements)
if not input_list or not output_list:
return 0.0
scores: List[float] = []
for el_in in input_list:
best_iou = 0.0
for el_out in output_list:
if el_in.element_type != el_out.element_type:
continue
iou = _intersection_over_union(el_in.bbox, el_out.bbox)
best_iou = max(best_iou, iou)
scores.append(best_iou)
return sum(scores) / len(scores)

View File

@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Iterable, List, Tuple
from sacrebleu.metrics import BLEU
def compute_bleu(reference: str, hypothesis: str) -> float:
"""
단일 reference/hypothesis에 대한 BLEU 점수 (0~100 스케일).
"""
bleu = BLEU(effective_order=True)
score = bleu.sentence_score(hypothesis, [reference]).score
return float(score)
def compute_length_ratio(reference: str, hypothesis: str) -> float:
"""
reference 대비 hypothesis의 길이 비율.
- 1.0 가까울수록 유사한 길이.
"""
if not reference:
return 1.0 if not hypothesis else 0.0
return len(hypothesis) / len(reference)
def aggregate_text_metrics(pairs: Iterable[Tuple[str, str]]) -> dict:
"""
(reference, hypothesis) 텍스트 쌍들에 대한 간단한 집계 메트릭.
- mean_bleu
- mean_length_ratio
"""
pairs = list(pairs)
if not pairs:
return {"mean_bleu": 100.0, "mean_length_ratio": 1.0}
bleu_scores: List[float] = []
length_ratios: List[float] = []
for ref, hyp in pairs:
bleu_scores.append(compute_bleu(ref, hyp))
length_ratios.append(compute_length_ratio(ref, hyp))
mean_bleu = sum(bleu_scores) / len(bleu_scores)
mean_length_ratio = sum(length_ratios) / len(length_ratios)
return {
"mean_bleu": mean_bleu,
"mean_length_ratio": mean_length_ratio,
}

114
src/eval_ppt2html/models.py Normal file
View File

@ -0,0 +1,114 @@
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# (x, y, width, height) in pixels
BBox = Tuple[float, float, float, float]
@dataclass
class TextElement:
"""문서 내 텍스트 요소 하나를 표현한다."""
id: str
page_index: int
content: str
role: str # "title", "subtitle", "body", "bullet", ...
hierarchy_level: Optional[int] = None
bbox: Optional[BBox] = None
font_family: Optional[str] = None
font_size: Optional[float] = None
font_weight: Optional[str] = None
color: Optional[str] = None
@dataclass
class LayoutElement:
"""레이아웃 요소 (텍스트, 이미지, 도형 등)."""
id: str
page_index: int
element_type: str # "text", "image", "shape", ...
bbox: BBox
z_index: Optional[int] = None
role: Optional[str] = None
@dataclass
class ImageElement:
"""이미지 요소 + 저장된 파일 경로 + 캡션."""
id: str
page_index: int
bbox: BBox
file_path: Path
caption: Optional[str] = None
@dataclass
class PageSnapshot:
"""한 페이지(또는 화면)의 스크린샷."""
page_index: int
file_path: Path
@dataclass
class EvalUnit:
"""
평가 단위 (주로 페이지/화면 단위).
원본/변환본의 텍스트, 레이아웃, 스냅샷 id를 매핑해 둔다.
"""
unit_id: str
input_page_index: int
output_page_index: int
input_text_ids: List[str] = field(default_factory=list)
output_text_ids: List[str] = field(default_factory=list)
input_layout_ids: List[str] = field(default_factory=list)
output_layout_ids: List[str] = field(default_factory=list)
input_snapshot: Optional[Path] = None
output_snapshot: Optional[Path] = None
@dataclass
class SamplePair:
"""
하나의 (input_pptx, output_html) 샘플 .
전처리 결과(T_i/T_o/L_i/L_o/I_i/I_o/S_i/S_o) 평가 단위(eval_units) 포함한다.
"""
sample_id: str
input_pptx: Path
output_html: Path
T_i: List[TextElement] = field(default_factory=list)
T_o: List[TextElement] = field(default_factory=list)
L_i: List[LayoutElement] = field(default_factory=list)
L_o: List[LayoutElement] = field(default_factory=list)
I_i: List[ImageElement] = field(default_factory=list)
I_o: List[ImageElement] = field(default_factory=list)
S_i: List[PageSnapshot] = field(default_factory=list)
S_o: List[PageSnapshot] = field(default_factory=list)
eval_units: List[EvalUnit] = field(default_factory=list)
def to_dict(self) -> Dict:
"""JSON 직렬화를 위한 dict 변환 (Path → str 처리 포함)."""
def _convert_path(value):
if isinstance(value, Path):
return str(value)
return value
raw = asdict(self)
def _walk(obj):
if isinstance(obj, dict):
return {k: _walk(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_walk(v) for v in obj]
return _convert_path(obj)
return _walk(raw)

View File

View File

@ -0,0 +1,112 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
from bs4 import BeautifulSoup # beautifulsoup4 필요
from eval_ppt2html.models import (
BBox,
LayoutElement,
SamplePair,
TextElement,
)
from eval_ppt2html.utils.logging import get_logger
logger = get_logger(__name__)
def is_visible_text(text: str) -> bool:
"""빈 줄/공백만 있는 텍스트는 제외."""
return bool(text and text.strip())
def infer_html_role(tag_name: str) -> str:
"""태그명으로 대략적인 role을 추론한다."""
tag_name = (tag_name or "").lower()
if tag_name in {"h1", "h2"}:
return "title"
if tag_name in {"h3", "h4", "h5", "h6"}:
return "subtitle"
if tag_name == "li":
return "bullet"
return "body"
def preprocess_output_html(
sample: SamplePair,
work_dir: Path,
base_url: Optional[str] = None, # 사용하지 않지만 CLI 시그니처 맞추기용
) -> None:
"""
HTML 파일을 정적으로 파싱하여:
- 텍스트(T_o)
- 레이아웃(L_o, 더미 bbox)
채운다.
브라우저 렌더링은 사용하지 않고, 단일 페이지(page_index=1) 취급한다.
"""
html_out_dir = work_dir / sample.sample_id / "html"
html_out_dir.mkdir(parents=True, exist_ok=True)
html_path = sample.output_html.resolve()
logger.info("Parsing HTML statically: %s", html_path)
if not html_path.exists():
raise FileNotFoundError(f"HTML file not found: {html_path}")
html_text = html_path.read_text(encoding="utf-8", errors="ignore")
soup = BeautifulSoup(html_text, "html.parser")
page_index = 1
text_idx = 0
layout_idx = 0
# 모든 텍스트 노드를 순회하면서 부모 태그 기준으로 처리
for node in soup.find_all(string=True):
parent = node.parent
tag_name = parent.name if parent else None
raw_text = node.strip()
if not is_visible_text(raw_text):
continue
text_idx += 1
text_id = f"html_text_{text_idx}"
role = infer_html_role(tag_name)
# 레이아웃 평가는 일단 더미 bbox로 대체 (x, y, w, h)
# y를 text_idx로 두어 순서 정도만 표현
bbox: BBox = (0.0, float(text_idx), 1.0, 1.0)
text_el = TextElement(
id=text_id,
page_index=page_index,
content=raw_text,
role=role,
hierarchy_level=None,
bbox=bbox,
font_family=None,
font_size=None,
font_weight=None,
color=None,
)
sample.T_o.append(text_el)
layout_idx += 1
layout_el = LayoutElement(
id=f"html_layout_text_{layout_idx}",
page_index=page_index,
element_type="text",
bbox=bbox,
z_index=None,
role=role,
)
sample.L_o.append(layout_el)
logger.info(
"HTML preprocessing done for sample %s (T_o=%d, L_o=%d)",
sample.sample_id,
len(sample.T_o),
len(sample.L_o),
)

View File

@ -0,0 +1,195 @@
from __future__ import annotations
from pathlib import Path
from typing import List, Optional
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
from eval_ppt2html.models import (
BBox,
ImageElement,
LayoutElement,
SamplePair,
TextElement,
)
from eval_ppt2html.utils.logging import get_logger
logger = get_logger(__name__)
EMU_PER_INCH = 914400
DEFAULT_DPI = 96
def emu_to_px(emu: int, dpi: int = DEFAULT_DPI) -> float:
"""PPTX EMU 단위를 픽셀 단위로 변환한다."""
inches = emu / EMU_PER_INCH
return inches * dpi
def normalize_text(text: str) -> str:
"""텍스트 내 공백을 정규화한다."""
return " ".join(text.split())
def infer_ppt_role_from_shape(shape) -> str:
"""
PPTX shape의 placeholder 타입과 폰트 정보를 참고해
대략적인 semantic role을 추론한다.
"""
try:
if getattr(shape, "is_placeholder", False):
ph_type = str(shape.placeholder_format.type).lower()
if "title" in ph_type:
return "title"
if "subtitle" in ph_type:
return "subtitle"
except Exception:
pass
return "body"
def get_ppt_font_family(shape) -> Optional[str]:
"""텍스트 shape에서 폰트 패밀리를 추출한다."""
try:
p = shape.text_frame.paragraphs[0]
if p.runs:
return p.runs[0].font.name
except Exception:
pass
return None
def get_ppt_font_size(shape) -> Optional[float]:
"""텍스트 shape에서 폰트 크기(pt)를 추출한다."""
try:
p = shape.text_frame.paragraphs[0]
if p.runs and p.runs[0].font.size:
return float(p.runs[0].font.size.pt)
except Exception:
pass
return None
def get_ppt_font_weight(shape) -> Optional[str]:
"""텍스트 shape에서 bold 여부를 font_weight로 추출한다."""
try:
p = shape.text_frame.paragraphs[0]
if p.runs and p.runs[0].font.bold:
return "bold"
except Exception:
pass
return "normal"
def get_ppt_font_color(shape) -> Optional[str]:
"""텍스트 shape에서 글자 색상을 hex 문자열로 추출한다."""
try:
p = shape.text_frame.paragraphs[0]
if not p.runs:
return None
rgb = p.runs[0].font.color.rgb
if rgb:
return f"#{rgb:06x}"
except Exception:
pass
return None
def extract_from_pptx(pptx_path: Path, output_dir: Path, sample: SamplePair) -> None:
"""
PPTX 파일에서 텍스트(T_i), 레이아웃(L_i), 이미지(I_i) 추출하여 sample에 채워 넣는다.
(페이지 스크린샷 S_i는 단계에서는 생성하지 않는다.)
"""
output_dir.mkdir(parents=True, exist_ok=True)
prs = Presentation(pptx_path)
logger.info("Extracting shapes from PPTX: %s", pptx_path)
for page_index, slide in enumerate(prs.slides, start=1):
for shape_index, shape in enumerate(slide.shapes, start=1):
shape_id = f"ppt_page{page_index}_shape{shape_index}"
left_px = emu_to_px(shape.left)
top_px = emu_to_px(shape.top)
width_px = emu_to_px(shape.width)
height_px = emu_to_px(shape.height)
bbox: BBox = (left_px, top_px, width_px, height_px)
# 텍스트 요소
if shape.has_text_frame:
raw_text = normalize_text(shape.text)
if raw_text:
role = infer_ppt_role_from_shape(shape)
text_el = TextElement(
id=f"text_{shape_id}",
page_index=page_index,
content=raw_text,
role=role,
hierarchy_level=None,
bbox=bbox,
font_family=get_ppt_font_family(shape),
font_size=get_ppt_font_size(shape),
font_weight=get_ppt_font_weight(shape),
color=get_ppt_font_color(shape),
)
sample.T_i.append(text_el)
layout_el = LayoutElement(
id=f"layout_text_{shape_id}",
page_index=page_index,
element_type="text",
bbox=bbox,
z_index=shape_index,
role=role,
)
sample.L_i.append(layout_el)
# 이미지 요소
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
image = shape.image
img_ext = image.ext or "png"
img_filename = (
f"{sample.sample_id}_page{page_index}_img{shape_index}.{img_ext}"
)
img_path = output_dir / img_filename
with img_path.open("wb") as f:
f.write(image.blob)
img_el = ImageElement(
id=f"image_{shape_id}",
page_index=page_index,
bbox=bbox,
file_path=img_path,
caption=None,
)
sample.I_i.append(img_el)
layout_el = LayoutElement(
id=f"layout_image_{shape_id}",
page_index=page_index,
element_type="image",
bbox=bbox,
z_index=shape_index,
role=None,
)
sample.L_i.append(layout_el)
def preprocess_input_pptx(sample: SamplePair, work_dir: Path) -> None:
"""
샘플의 input_pptx에 대해 T_i, L_i, I_i를 추출한다.
(현재 버전은 PPT 페이지 스크린샷 S_i는 생성하지 않는다.)
"""
ppt_out_dir = work_dir / sample.sample_id / "pptx"
ppt_out_dir.mkdir(parents=True, exist_ok=True)
extract_from_pptx(sample.input_pptx, ppt_out_dir, sample)
logger.info(
"PPTX preprocessing done for sample %s (T_i=%d, L_i=%d, I_i=%d)",
sample.sample_id,
len(sample.T_i),
len(sample.L_i),
len(sample.I_i),
)

Binary file not shown.

View File

@ -0,0 +1,43 @@
from __future__ import annotations
import csv
import json
from pathlib import Path
from typing import Dict, List
from eval_ppt2html.models import SamplePair
def load_samples_from_csv(csv_path: Path) -> List[Dict[str, str]]:
"""
CSV 파일에서 샘플 목록(id, pptx_path, html_path) 읽어온다.
:
id,pptx_path,html_path
sample_001,data/raw/pptx/input.pptx,data/raw/html/output_001.html
"""
with csv_path.open(newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
return list(reader)
def save_sample_to_json(sample: SamplePair, work_dir: Path) -> Path:
"""
SamplePair 객체를 JSON 파일로 저장한다.
저장 경로: <work_dir>/processed/json/<sample_id>.json
"""
json_dir = work_dir / "processed" / "json"
json_dir.mkdir(parents=True, exist_ok=True)
json_path = json_dir / f"{sample.sample_id}.json"
with json_path.open("w", encoding="utf-8") as f:
json.dump(sample.to_dict(), f, ensure_ascii=False, indent=2)
return json_path
def load_sample_json(json_path: Path) -> Dict:
"""
전처리된 SamplePair JSON 파일을 로드하여 dict로 반환한다.
"""
with json_path.open(encoding="utf-8") as f:
return json.load(f)

View File

@ -0,0 +1,30 @@
from __future__ import annotations
import logging
from typing import Optional
def get_logger(name: Optional[str] = None) -> logging.Logger:
"""
패키지 전역에서 있는 간단한 콘솔 로거를 반환한다.
생성되면 같은 이름으로 재호출 동일 로거를 반환한다.
"""
logger_name = name or "ppt2html-eval"
logger = logging.getLogger(logger_name)
# 이미 핸들러가 붙어 있으면 그대로 사용
if logger.handlers:
return logger
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
formatter = logging.Formatter(
"[%(asctime)s] [%(levelname)s] %(name)s - %(message)s",
datefmt="%H:%M:%S",
)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger