diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e1ce9a4d..e92c36df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 이 프로젝트의 주요 변경 사항을 기록합니다. +## [0.7.3] — 2026年04月22日 + +> Visual Diff 기반 호환성 개선 — 한컴 렌더링과 페이지별 비교·수정 + +### 수정 +- **PUA 심볼 문자 렌더링**: Wingdings 등 심볼 폰트의 PUA 문자(U+F000~F0FF)를 유니코드 표준 문자로 변환하여 SVG/Canvas에서 정상 표시 (⇩⇧⇦⇨ 등 화살표, ●くろまる■しかく◆だいやまーく 등 도형, ✔☑ 등 체크마크) +- **문단 테두리/배경 여백 반영**: border_fill 렌더링 시 문단의 margin_left/margin_right를 반영하여 테두리 박스 위치·폭이 텍스트 영역과 정확히 일치 + +### 내부 개선 +- Visual Diff 파이프라인 구축 (한컴 PDF ↔ rhwp SVG 페이지별 픽셀 비교) + ## [0.6.0] — 2026年04月04日 > 조판 품질 개선 + 비기능성 기반 구축 — "알을 깨고 세상으로" diff --git a/Cargo.toml b/Cargo.toml index 31550c92f..c3de5fb12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rhwp" -version = "0.7.2" +version = "0.7.3" edition = "2021" description = "HWP file viewer and editor written in Rust, targeting WebAssembly" license = "MIT" diff --git a/README.md b/README.md index 14a407cfb..1b5d93309 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,8 @@ rhwp는 Rust + WebAssembly 기반의 오픈소스 HWP/HWPX 뷰어/에디터입 - 페이지네이션 (다단 분할, 표 행 분할), 머리말/꼬리말/바탕쪽/각주 - SVG 내보내기 (CLI) + Canvas 렌더링 (WASM/Web) - 웹 에디터 + hwpctl 호환 API (30 Actions, Field API) -- 783+ 테스트 +- 793+ 테스트 +- Visual diff 테스트 파이프라인 (Hancom PDF 대조 검증) ### v1.0.0 — 조판 엔진 @@ -100,6 +101,8 @@ rhwp는 Rust + WebAssembly 기반의 오픈소스 HWP/HWPX 뷰어/에디터입 ### Rendering (렌더링) - **Paragraph layout**: line spacing, indentation, alignment, tab stops +- **Paragraph border/fill**: border_fill background/border with margin-aware positioning +- **Symbol fonts**: Wingdings PUA (U+F000~F0FF) → Unicode standard character mapping - **Tables**: cell merging, border styles (solid/double/triple/dotted), cell formula calculation - **Multi-column layout** (2-column, 3-column, etc.) - **Paragraph numbering/bullets** @@ -200,7 +203,7 @@ document.getElementById('viewer').innerHTML = doc.renderPageSvg(0); ```bash cargo build # Development build cargo build --release # Release build -cargo test # Run tests (755+ tests) +cargo test # Run tests (793+ tests) ``` ### WASM Build @@ -308,7 +311,7 @@ scripts/ # Build & quality tools |--|-----------|-----------| | **사람의 역할** | AI 출력 수락 | 지시, 검토, 결정 | | **계획** | 없음 — "그냥 만들어" | 계획서 작성 → 승인 → 실행 | -| **품질 관문** | 동작하길 바람 | 783 테스트 + Clippy + CI + 코드 리뷰 | +| **품질 관문** | 동작하길 바람 | 793 테스트 + Clippy + CI + 코드 리뷰 | | **디버깅** | AI에게 AI 버그 수정 요청 | 사람이 진단, AI가 구현 | | **아키텍처** | 우연히 형성 | 의도적 설계 (CQRS, 의존성 방향) | | **문서** | 없음 | 724개 파일의 프로세스 기록 | diff --git a/README_EN.md b/README_EN.md index 0688aee56..40869f7da 100644 --- a/README_EN.md +++ b/README_EN.md @@ -145,7 +145,7 @@ document.getElementById('viewer').innerHTML = doc.renderPageSvg(0); ```bash cargo build # Development build cargo build --release # Release build -cargo test # Run tests (783+ tests) +cargo test # Run tests (793+ tests) ``` ### WASM Build @@ -292,7 +292,7 @@ The `mydocs/` directory is not documentation about the code — it's documentati |--|-------------|-------------| | **Human role** | Accept AI output | Direct, review, decide | | **Planning** | None — "just build it" | Written plan → approval → execution | -| **Quality gate** | Hope it works | 783 tests + Clippy + CI + code review | +| **Quality gate** | Hope it works | 793 tests + Clippy + CI + code review | | **Debugging** | Ask AI to fix AI's bugs | Human diagnoses, AI implements fix | | **Architecture** | Emergent (accidental) | Deliberate (CQRS, dependency direction) | | **Documentation** | None | 724 files of process records | diff --git a/mydocs/eng/plans/task_right_tab_align.md b/mydocs/eng/plans/task_right_tab_align.md new file mode 100644 index 000000000..7a0ece062 --- /dev/null +++ b/mydocs/eng/plans/task_right_tab_align.md @@ -0,0 +1,167 @@ +# Right Tab 정렬 정밀 수정 기획서 + +## 문제 현상 + +목차(TOC)에서 페이지 번호의 우측 정렬이 어긋남. +- 장제목(I, II, III...): 페이지 번호 올바르게 우측 정렬 +- 소제목(1., 2., 3...): 페이지 번호가 왼쪽으로 9.33px 밀림 + +## 재현 파일 + +`/Users/hyounmoukshin/Downloads/KTX.hwp` 2페이지 (셀[10] 내 목차) + +## 원인 분석 + +### 데이터 구조 + +``` +p[0] 장제목: " I. 사업 개요[TAB] 3" ctrls=2, text_len=22 +p[1] 소제목: "1. 추진배경 및 목적[TAB] 4" ctrls=0, text_len=29 +``` + +- 장제목: `ctrls=2` → 텍스트가 컨트롤 코드 경계에서 여러 run으로 분리됨 +- 소제목: `ctrls=0` → 전체 텍스트가 단일 run + +### Right Tab 계산 흐름 + +``` +compute_char_positions() 내 탭 처리: + abs_x = line_x_offset + 현재까지_폭 + (tab_pos, tab_type=RIGHT, _) = find_next_tab_stop(abs_x, ...) + rel_tab = tab_pos - line_x_offset + seg_w = measure_segment_from(탭_이후_나머지_텍스트) ← 핵심 + result_x = rel_tab - seg_w +``` + +### seg_w 차이의 근본 원인 + +`measure_segment_from()`은 탭 문자 이후 **같은 run 내**의 나머지 텍스트 폭을 측정함. + +| 문단 | 탭이 있는 run | 탭 이후 텍스트 | seg_w | +|------|-------------|-------------|-------| +| p[0] 장제목 | `"...개요\t"` (run 끝) | (다음 run) `" 3"` | **0.00** (같은 run에 없음) | +| p[1] 소제목 | `"...목적\t 4"` (단일 run) | `" 4"` (공백+숫자) | **9.33** (공백 포함) | + +장제목에서 seg_w=0인 이유: 탭이 run의 마지막 문자이므로 `measure_segment_from(i+1)`이 빈 결과를 반환. +→ **교차 run 탭 처리**(pending_right_tab)에서 다음 run의 폭을 별도로 계산. + +소제목에서 seg_w=9.33인 이유: 탭 뒤에 `" 4"`가 같은 run에 있어서 공백 폭(≈9.33px)이 포함됨. + +### 한컴의 동작 + +한컴에서는 right tab 정렬 시 **탭 직후의 선행 공백을 무시**하고, 실질 텍스트(숫자)의 우측 끝을 tab_pos에 맞춤. + +``` +한컴: result_x = rel_tab - (숫자 폭만) +rhwp: result_x = rel_tab - (공백 + 숫자 폭) +차이: 공백 1개 ≈ 9.33px +``` + +## 수정 방안 + +### 방안 A: seg_w 계산 시 선행 공백 제거 (권장) + +`measure_segment_from()`에서 탭 직후 선행 공백을 skip하여 측정. + +```rust +// text_measurement.rs: measure_segment_from() +fn measure_segment_from(...) -> f64 { + let mut w = 0.0; + let mut skipping_leading_spaces = true; // ← 추가 + for i in start..chars.len() { + if chars[i] == '\t' { break; } + if cluster_len[i] == 0 { continue; } + // right tab 직후 선행 공백 skip + if skipping_leading_spaces && chars[i] == ' ' { continue; } + skipping_leading_spaces = false; + w += char_width(i); + } + w +} +``` + +**장점**: 최소 변경, right tab의 seg_w만 영향 +**단점**: center tab에도 영향 가능 (center tab은 선행 공백 포함해야 할 수 있음) +**위험도**: 낮음 — right/center tab 전용이므로 일반 텍스트 레이아웃 무관 + +### 방안 B: right tab 전용 seg_w 계산 + +기존 `measure_segment_from()`은 그대로 두고, right tab일 때만 선행 공백을 제거한 별도 측정. + +```rust +// compute_char_positions() 내: +1 => { // 오른쪽 탭 + let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + // right tab: 선행 공백 제거 + let trimmed_start = (i + 1..chars.len()).find(|&j| chars[j] != ' ' && chars[j] != '\t').unwrap_or(chars.len()); + let trimmed_seg_w = measure_segment_from(&chars, &cluster_len, trimmed_start, &char_width); + x = (rel_tab - trimmed_seg_w).max(x); +} +``` + +**장점**: right tab에만 정확히 적용, center tab은 기존 동작 유지 +**단점**: 코드 중복 + +### 방안 C: 교차 run 처리 통일 + +장제목처럼 탭이 run 끝에 있으면 `pending_right_tab`으로 다음 run에서 처리됨. +소제목처럼 탭이 run 중간에 있으면 같은 run 내에서 처리됨. +두 경로를 통일하여 동일한 결과를 생성. + +**장점**: 근본적 해결 +**단점**: 변경 범위 크고 regression 위험 높음 + +## 실험 결과 (2026年04月22日) + +### 시도 1: seg_w에서 선행 공백 제거 (방안 B) +- `measure_segment_from` → `skip_leading_spaces`로 공백 skip +- `compute_char_positions`에서 right tab 후 공백 position도 0폭 처리 +- **결과**: 소제목 경로는 개선되었으나 장제목 경로(교차 run)는 미적용 → 여전히 불일치 + +### 시도 2: 교차 run에서도 `trim_start()` 적용 +- `pending_right_tab` 처리에서 `run.text.trim_start()` 폭 계산 +- **결과**: 장제목이 더 오른쪽으로 밀림 (공백 제거 → run_w 감소 → est_x 증가) +- 장제목/소제목의 절대 위치 차이 24.88px → 오히려 악화 + +### 시도 3: available_width를 right_tab_width로 통일 +- `available_width` 대신 `col_area.width - margin_right`를 사용 +- **결과**: 모든 tab_pos가 동일해졌으나, 기존 문서의 정렬이 크게 변동 → revert + +### 핵심 교훈 + +right tab 정렬은 3가지 경로로 처리됨: +1. **같은 run 내 탭** → `compute_char_positions`에서 `seg_w` 계산 +2. **교차 run 탭 (추정)** → `pending_right_tab_est` + `estimate_text_width` +3. **교차 run 탭 (렌더)** → `pending_right_tab_render` + `estimate_text_width` + +이 3경로의 공백 처리가 불일치하면 정렬이 어긋남. +단순히 한 경로만 수정하면 다른 경로와 불일치가 발생하여 오히려 악화. + +### 올바른 접근 + +1. **3경로 모두에서 동일한 공백 처리 규칙** 적용 +2. right tab 후 선행 공백의 정확한 의미를 한컴 동작에서 역공학 +3. 단일 문서(KTX.hwp)가 아닌 **여러 목차 문서**로 교차 검증 필요 +4. Task 403의 제어 샘플 생성 프레임워크를 활용하여 right tab 전용 테스트 샘플 생성 + +## 추천 + +**별도 Task로 분리하여 체계적으로 진행** (현재 세션에서의 즉시 수정은 위험). + +- right tab 전용으로 `trimmed_seg_w` 계산 +- center tab은 기존 동작 유지 +- `compute_char_positions()` 내 3곳(추정 패스, 렌더 패스, paragraph_layout 교차 run)에서 동일 적용 +- 793 테스트 + KTX.hwp visual diff로 검증 + +## 수정 파일 + +| 파일 | 변경 | +|------|------| +| `src/renderer/layout/text_measurement.rs` | `compute_char_positions()` right tab 분기에서 trimmed seg_w | + +## 검증 계획 + +1. `cargo test` — 793 전체 통과 +2. KTX.hwp 2페이지 목차 — 장제목/소제목 페이지 번호 동일 x좌표 정렬 +3. 기존 visual diff 페이지(p1, p5, p6, p10) — regression 없음 +4. 다른 목차 문서(hwpspec.hwp 등)로 추가 검증 diff --git a/mydocs/eng/plans/task_visual_diff.md b/mydocs/eng/plans/task_visual_diff.md new file mode 100644 index 000000000..40dc2bd32 --- /dev/null +++ b/mydocs/eng/plans/task_visual_diff.md @@ -0,0 +1,503 @@ +# Visual Diff 기반 HWP 호환성 확보 기획서 + +> 문서 하나씩 한컴 렌더링과 비교하여, 페이지 단위로 차이를 찾고 수정하는 체계 + +--- + +## 1. 목표 + +**rhwp의 렌더링 결과를 한컴 뷰어와 페이지 단위로 비교**하여, 시각적 차이를 정량화하고 문서별로 호환성을 달성한다. + +### 성공 기준 + +| 지표 | 목표 | +|------|------| +| 페이지별 픽셀 일치율 (SSIM) | ≥ 95% | +| 문서별 페이지 수 일치 | 100% | +| 레이아웃 구조 일치 (문단/표 위치) | ≥ 98% | + +### 기존 접근법과의 차이 + +| 기존 (T398~404) | Visual Diff | +|-----------------|-------------| +| LINE_SEG 필드 수치 비교 | 렌더링 결과 이미지 비교 | +| 글자 폭·줄바꿈 개별 추적 | 최종 출력물 기준 전체 품질 | +| 원인 분석 중심 | 결과 확인 → 원인 추적 | +| 제어 샘플 위주 | 실제 문서 위주 | + +**두 접근법은 상호 보완적이다** — Visual Diff로 "어디가 다른지" 발견하고, LINE_SEG 비교로 "왜 다른지" 추적한다. + +--- + +## 2. 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Visual Diff Pipeline │ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌───────────┐ ┌────────┐ │ +│ │ 한컴 참조 │ │ rhwp 렌더링 │ │ 이미지 비교 │ │ 리포트 │ │ +│ │ (Ground │──▶│ (SVG→PNG) │──▶│ (Diff) │──▶│ (HTML) │ │ +│ │ Truth) │ │ │ │ │ │ │ │ +│ └────���─────┘ └────────────���─┘ └────────���──┘ └────────┘ │ +│ │ │ │ │ │ +│ PDF/PNG 캡처 export-svg pixelmatch per-page │ +│ from 한컴 + resvg→PNG + SSIM diff map │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.1 Ground Truth 생성 (한컴 참조 이미지) + +한컴에서 문서를 열어 **페이지별 PNG 이미지**를 확보한다. + +| 방법 | 장점 | 단점 | +|------|------|------| +| **한컴 → PDF 인쇄 → pdf-to-png** | 자동화 가능, 해상도 제어 | PDF 변환 과정의 미세 차이 | +| **한컴 화면 캡처 (수동)** | 화면 표시 그대로 | 수동, 해상도 불균일 | +| **한컴 hwp2pdf CLI** | 완전 자동화 | 설치 필요, 라이선스 | + +**권장**: 한컴에서 PDF 인쇄 → `pdftoppm` 또는 `pdf-lib` 로 페이지별 PNG 추출. +작업지시자가 한컴에서 PDF를 생성하고, 이후 파이프라인은 자동화한다. + +### 2.2 rhwp 렌더링 이미지 생성 + +```bash +# SVG 내보내기 (페이지별) +rhwp export-svg sample.hwp -o output/rhwp/ --embed-fonts + +# SVG → PNG 변환 (resvg, 이미 hwpx 프로젝트에서 사용 중) +resvg output/rhwp/page_0.svg output/rhwp/page_0.png --width 2480 +``` + +**해상도 통일**: 양측 모두 A4 기준 ×ばつ3508px (300 DPI)** 로 정규화. + +### 2.3 이미지 비교 엔진 + +``` +per-page comparison: + 1. 크기 정규화 (resize to same dimensions) + 2. 픽셀 diff (pixelmatch — threshold 0.1) + 3. SSIM 계산 (structural similarity) + 4. 차이 영역 바운딩 박스 추출 + 5. diff 히트맵 이미지 생성 (빨간색 = 차이) +``` + +**사용 도구**: +- `pixelmatch` (npm) — 고속 픽셀 비교, diff 이미지 생성 +- `ssim.js` 또는 자체 SSIM — 구조적 유사도 (0.0~1.0) +- `sharp` (npm) — 이미지 리사이즈, 포맷 변환 + +### 2.4 리포트 생성 + +페이지별 비교 결과를 **HTML 리포트**로 자동 생성: + +``` +sample.hwp — Visual Diff Report +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Overall: 15 pages, SSIM avg 0.962, 12 pass / 3 fail + +Page 1 [PASS] SSIM: 0.991 diff: 0.2% + ┌─────────┐ ��─────────┐ ┌─────────┐ + │ 한컴 │ │ rhwp │ │ diff │ + │ (참조) │ │ (실제) │ │ (차이) │ + └─────────┘ └��────────┘ └────���────┘ + +Page 5 [FAIL] SSIM: 0.871 diff: 8.3% + ┌─────��───┐ ┌─────────┐ ┌─────────┐ + │ 한컴 │ │ rhwp │ │ diff │ ← 표 하단 20px 밀림 + └─��───────┘ └─────────┘ └─────────┘ + + diff regions: + - (120, 450) ~ (580, 520): 표 행 높이 차이 + - (50, 800) ~ (550, 830): 줄바꿈 위치 차이 +``` + +--- + +## 3. 디렉토리 구조 + +``` +visual-diff/ +├── ground-truth/ # 한컴 참조 이미지 (작업지시자 제공) +│ ├── KTX/ +│ │ ├── page_00.png +│ �� ├── page_01.png +│ │ └── ... +│ ├── hwpspec/ +│ │ └── ... +│ └── manifest.json # 문서별 메타 (페이지 수, 해상도, 생성일) +│ +├── rendered/ # rhwp 렌더링 결과 (자동 생성) +│ ├── KTX/ +│ │ ├��─ page_00.png +│ │ └── ... +│ └── ... +│ +├── diff/ # 차이 이미지 + 리포트 (자동 생��) +│ ├── KTX/ +│ │ ├── page_00_diff.png +│ │ └── ... +│ ├── KTX_report.html +│ └── summary.html # 전체 문서 요약 대시보�� +│ +├── scripts/ +│ ├── render-all.sh # rhwp SVG→PNG 일괄 변환 +│ ├── compare.mjs # 이미지 비교 + diff 생성 +│ ├── report.mjs # HTML 리포트 생성 +│ ├── gt-from-pdf.sh # PDF→PNG ground truth 생성 +│ └── run-pipeline.sh # 전체 파이프라인 실행 +│ +├── config.json # 설정 (threshold, resolution, 문서 목록) +└── README.md +``` + +--- + +## 4. 문서 우선순위 (문서별 순차 진행) + +### Wave 1 — 기본 문서 (단순 구조, 빠른 검증) + +| 순서 | 문서 | 페이지 | 특성 | 목적 | +|------|------|--------|------|------| +| 1 | `basic/KTX.hwp` | 1 | 표+이미지+텍스트 혼합 | 파이프라인 검증, 대표 문서 | +| 2 | `basic/english.hwp` | 1~2 | 영문 텍스트 | 영문 폭 측정 검증 | +| 3 | `basic/Textmail.hwp` | 1 | 단순 텍스트 | 기본 문단 레이아웃 | +| 4 | `basic/request.hwp` | 1~2 | 표 중심 | 표 렌더링 기본 | +| 5 | `basic/interview.hwp` | 1~2 | 텍스트+서식 | 글자 서식 | + +### Wave 2 — 표/레이아웃 문서 (표, 다단, 이미지) + +| 순서 | 문서 | 페이지 | 특성 | 목적 | +|------|------|--------|------|------| +| 6 | `hwp_table_test.hwp` | 2~3 | 다양한 표 | 표 셀 병합, 테두�� | +| 7 | `table-complex.hwp` | 1~2 | 복합 표 | 중첩 표, 셀 정렬 | +| 8 | `inner-table-01.hwp` | 1~2 | 중첩 표 | 표 안의 표 | +| 9 | `hwp-img-001.hwp` | 1~2 | 이미지 배치 | 그림 위치, 크기, 자르기 | +| 10 | `hwp-multi-001.hwp` | 3~5 | 다단 레이아웃 | 다단 분할 | + +### Wave 3 — 복합 문서 (실무 수준) + +| 순��� | 문서 | 페���지 | 특성 | 목적 | +|------|------|--------|------|------| +| 11 | `basic/treatise sample.hwp` | 5~10 | 논문 양식 | 머리말/꼬리말, 각주, 다단 | +| 12 | `footnote-01.hwp` | 2~3 | 각주 | 각주 영역 배치 | +| 13 | `endnote-01.hwp` | 2~3 | 미주 | 미주 처리 | +| 14 | `eq-01.hwp` | 1~2 | 수식 | 수식 렌더링 | +| 15 | `tac-case-001~005.hwp` | 각 1~2 | TAC 인라인 표 | 인라인 배치 | + +### Wave 4 — 대형 문서 (종합 검증) + +| 순서 | 문서 | 페이지 | 특성 | 목적 | +|------|------|--------|------|------| +| 16 | `hwpspec.hwp` | 170+ | HWP 스펙 문서 | 대량 페이지, 종합 | +| 17 | `hwpctl_API_v2.4.hwp` | 50+ | API 문서 | 표+코드+텍스트 혼합 | +| 18 | `basic/Worldcup_FIFA2010_32.hwp` | 30+ | 복합 레이아웃 | 이미지+표+서식 혼합 | + +### Wave 5 — 특수 케이스 + +| 순서 | 문서 | 특성 | 목적 | +|------|------|------|------| +| 19 | `shift-return.hwp` | 강제 줄바꿈 | 줄바꿈 종류 | +| 20 | `form-01.hwp` | 양식 | 양식 필드 렌더링 | +| 21 | `draw-group.hwp` | 그리기 개체 | 도형 그룹 | +| 22 | `hwp-3.0-HWPML.hwp` | 구버전 포맷 | 하위 호환성 | + +--- + +## 5. 문서별 진행 프로세스 + +**한 문서를 완전히 맞춘 후 다음 문서로 넘어간다.** + +``` +┌──────────────────────────────────────────────────────┐ +│ Document-by-Document Conformance Cycle │ +│ │ +│ 1 Ground Truth 확보 │ +│ 작업지시자: 한컴에서 PDF 인쇄 → 제공 │ +│ │ +│ 2 파이프라인 실행 │ +│ rhwp export-svg → resvg PNG → pixelmatch diff │ +│ │ +│ 3 리포트 확인 │ +│ 페이지별 SSIM, diff 히트맵, 차이 영역 확인 │ +│ │ +│ 4 차이 분석 │ +│ diff 영역 → dump-pages / dump / debug-overlay │ +│ 로 원인 특정 (줄바꿈? 표 높이? 이미지 위치?) │ +│ │ +│ 5 수정 │ +│ 원인별 코드 수정 (렌더러, 조판, 파서) │ +│ │ +│ 6 재검증 │ +│ 파이프라인 재실행 → SSIM 개선 확인 │ +│ + 이전 문서 회귀 확인 (regression guard) │ +│ │ +│ 7 완료 판정 │ +│ 모든 페이지 SSIM ≥ 0.95 → PASS → 다음 문서 │ +│ │ +│ ※(注記) 수정 불가 케이스 (폰트 차이 등)는 known-diff로 │ +│ 등록하고 해당 영역을 마스크 처리 │ +└─────────────────────────────────��────────────────────┘ +``` + +### 회귀 방지 (Regression Guard) + +- 새 문서 작업 시 **이전에 통과한 모든 문서**도 함께 비교 +- 회귀 발생 시: 새 수정 롤백 또는 조건부 분기 도입 +- `summary.html` 대시보드에서 전체 문서 상태 한눈에 확인 + +``` +Visual Diff Dashboard +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Document Pages SSIM Status + ───────────────────────────────────── + KTX.hwp 1 0.98 ✅ PASS + english.hwp 2 0.96 ✅ PASS + Textmail.hwp 1 0.97 ✅ PASS + request.hwp 2 0.93 🔧 WIP (표 테두리) + hwp_table_test.hwp 3 -- ⏳ PENDING + ... +``` + +--- + +## 6. Known-Diff 마스크 정책 + +일부 차이는 rhwp에서 의도적으로 다르거나, 현 단계에서 해결 불가능하다. + +| 유형 | 예시 | 처리 | +|------|------|------| +| **폰트 차이** | 한컴 전용 폰트 → 오픈소스 대체 | 텍스트 영역 마스크, 레이아웃만 비교 | +| **안티앨리어싱** | SVG vs 한컴 GDI 렌더링 차이 | threshold 완화 (0.1→0.15) | +| **서브픽셀** | 1px 미만 위치 차이 | SSIM 임계값으로 흡수 | +| **커서/선택** | 한컴 캡처 시 커서 포함 | 캡처 시 커서 숨기기 | + +마스크 파일 (`mask.json`): +```json +{ + "KTX": { + "page_00": [ + { + "region": [100, 200, 400, 50], + "reason": "한컴 전용 폰트(HY견고딕) → 오픈소스 대체", + "type": "font-substitution" + } + ] + } +} +``` + +--- + +## 7. 기술 구현 상세 + +### 7.1 Ground Truth 생성 스크립트 + +```bash +# gt-from-pdf.sh — PDF → 페이지별 PNG +# 의존성: poppler-utils (pdftoppm) +pdftoppm -png -r 300 "$PDF_FILE" "$OUTPUT_DIR/page" +# 결과: page-01.png, page-02.png, ... +# 파일명 정규화: page_00.png, page_01.png, ... (0-indexed) +``` + +### 7.2 rhwp 렌더링 스크립트 + +```bash +# render-all.sh — 문서별 SVG→PNG 일괄 변환 +for doc in $(jq -r '.documents[].file' config.json); do + name=$(basename "$doc" .hwp) + mkdir -p rendered/"$name" + + # SVG 내보내기 (임베드 폰트) + rhwp export-svg "samples/$doc" -o rendered/"$name" --embed-fonts + + # SVG → PNG (300 DPI, A4 기준) + for svg in rendered/"$name"/*.svg; do + png="${svg%.svg}.png" + resvg "$svg" "$png" --width 2480 + done +done +``` + +### 7.3 비교 엔진 핵심 로직 + +```javascript +// compare.mjs (Node.js) +import { PNG } from 'pngjs'; +import pixelmatch from 'pixelmatch'; + +function comparePage(gtPath, renderedPath, diffPath, options) { + const gt = PNG.sync.read(fs.readFileSync(gtPath)); + const rendered = PNG.sync.read(fs.readFileSync(renderedPath)); + + // 크기 정규화 + const [w, h] = [Math.max(gt.width, rendered.width), + Math.max(gt.height, rendered.height)]; + const gtResized = resizeToFit(gt, w, h); + const renderedResized = resizeToFit(rendered, w, h); + + // 마스크 적용 + const mask = loadMask(options.document, options.page); + if (mask) applyMask(gtResized, renderedResized, mask); + + // 픽셀 비교 + const diff = new PNG({ width: w, height: h }); + const mismatchCount = pixelmatch( + gtResized.data, renderedResized.data, diff.data, + w, h, { threshold: 0.1 } + ); + + // SSIM 계산 + const ssim = calculateSSIM(gtResized, renderedResized); + + // diff 이미지 저장 + fs.writeFileSync(diffPath, PNG.sync.write(diff)); + + return { + mismatchPixels: mismatchCount, + mismatchRate: mismatchCount / (w * h), + ssim: ssim, + pass: ssim>= 0.95, + }; +} +``` + +### 7.4 config.json 예시 + +```json +{ + "resolution": { "dpi": 300, "width": 2480, "height": 3508 }, + "threshold": { "pixel": 0.1, "ssim_pass": 0.95 }, + "documents": [ + { + "file": "basic/KTX.hwp", + "name": "KTX", + "pages": 1, + "wave": 1, + "status": "pending" + }, + { + "file": "basic/english.hwp", + "name": "english", + "pages": 2, + "wave": 1, + "status": "pending" + } + ] +} +``` + +--- + +## 8. 기존 인프라와의 연계 + +| 기존 도구 | Visual Diff에서의 역할 | +|----------|----------------------| +| `export-svg` | rhwp 렌더링 이미지 생성의 핵심 | +| `--debug-overlay` | diff 영역의 원인 분석 시 문단/표 식별 | +| `dump-pages` | diff 영역 → 해당 페이지의 문단 배치 확인 | +| `dump -s N -p M` | 특정 문단의 ParaShape, LINE_SEG 상세 조사 | +| `ir-diff` | HWPX↔HWP 포맷 간 IR 차이 확인 (보조) | +| T398 LINE_SEG 비교 | 줄바꿈 차이의 수치적 원인 추적 | +| E2E scenario-runner | 편집 후 레이아웃 안정성 검증 (보조) | + +### 디버깅 워크플로우 통합 + +``` +1 Visual Diff → Page 5 FAIL (SSIM 0.87) +2 diff 히트맵 → (120, 450)~(580, 520) 영역에 차이 +3 debug-overlay → 해당 영역은 s0:pi=45 ci=0 (표 16x4) +4 dump-pages -p 5 → pi=45 표 높이 확인 +5 dump -s 0 -p 45 → ParaShape, LINE_SEG 상세 +6 LINE_SEG 비교 (T398) → text_start -3 (글자 폭 차이) +7 수정 → 재검증 +``` + +--- + +## 9. 구현 단계 + +### Step 1: 파이프라인 기반 구축 + +- `visual-diff/` 디렉토리 생성 +- `compare.mjs` — pixelmatch + SSIM 비교 엔진 +- `report.mjs` — HTML 리포트 생성기 +- `render-all.sh` — rhwp SVG→PNG 일괄 변환 +- `gt-from-pdf.sh` — PDF→PNG ground truth 생성 +- `config.json` — 설정 + 문서 목록 +- `run-pipeline.sh` — 전체 파이프라인 원클릭 실행 + +**산출물**: `KTX.hwp` 1개 문서로 파이프라인 end-to-end 동작 확인 + +### Step 2: KTX.hwp (Wave 1-1) — 첫 번째 문서 맞추기 + +- 작업지시자가 한컴에서 KTX.hwp PDF 생성 → ground-truth 이미지 확보 +- 파이프라인 실행 → 첫 리포트 생성 +- diff 분석 → 수정 → SSIM ≥ 0.95 달성 +- known-diff 마스크 정책 실전 적용 + +### Step 3: Wave 1 나머지 (english, Textmail, request, interview) + +- 문서별 순차 진행 +- 각 문서 PASS 후 이전 문서 regression guard 실행 +- 반복적으로 발견되는 패턴은 **공통 수정**으로 분류 + +### Step 4: Wave 2~5 순차 진행 + +- Wave별 복잡도 증가에 따라 수정 범위도 확대 +- Wave 4 (대형 문서) 진입 시점에서 SSIM 기준 재조정 검토 +- 최종 summary dashboard 완성 + +### Step 5: CI 통합 (선택) + +- GitHub Actions에서 regression guard 자동 실행 +- PR 머지 전 기존 PASS 문서 회귀 확인 +- ground-truth 이미지는 Git LFS 또는 별도 스토리지 + +--- + +## 10. 작업 분담 + +| 역할 | 담당 | 내용 | +|------|------|------| +| **Ground Truth 생성** | 작업지시자 | 한컴에서 PDF 인쇄, 제공 | +| **파이프라인 구축** | Claude (이 태스크) | 스크립트, 비교 엔진, 리포트 | +| **diff 분석** | 공동 | 리포트 보면서 원인 토론 | +| **코드 수정** | rhwp 레포에서 진행 | 렌더러/조판/파서 수정 | +| **Known-diff 판정** | 작업지시자 | 수용/거부 결정 | + +--- + +## 11. 의존성 + +| 도구 | 용도 | 설치 | +|------|------|------| +| `resvg` | SVG→PNG 변환 | `cargo install resvg` 또는 `brew install resvg` | +| `pdftoppm` (poppler) | PDF→PNG | `brew install poppler` | +| `pixelmatch` | 픽셀 비교 | `npm install pixelmatch pngjs` | +| `sharp` | 이미지 리사이즈 | `npm install sharp` | +| rhwp (native build) | SVG 내보내기 | 기존 빌드 | + +--- + +## 12. 리스크 및 대응 + +| 리스크 | 영향 | 대응 | +|--------|------|------| +| 한컴 폰트 없음 → 텍스트 영역 대량 차이 | SSIM 급락 | 텍스트 영역 마스크 + 레이아웃만 비교 | +| 대형 문서 (170+ 페이지) 처리 시간 | 파이프라인 느림 | 변경 페이지만 증분 비교 | +| SVG→PNG 변환 품질 (resvg 한계) | 오탐 (false diff) | threshold 조정 + resvg 최신 버전 | +| 한컴 PDF 출력과 화면 출력의 미세 차이 | 기준 흔들림 | 한컴 화면 캡처로 크로스 체크 | +| 수정이 다른 문서에 regression 유발 | 무한 루프 | regression guard 강제 + 조건부 분기 | + +--- + +## 승인 요청 + +위 기획서를 검토하시고, 다음을 결정해 주세요: + +1. **진행 승인** — Step 1 (파이프라인 기반 구축)부터 시작 +2. **Ground Truth 첫 문서** — `basic/KTX.hwp`를 한컴에서 PDF로 제공 가능 여부 +3. **문서 우선순위 조정** — Wave 1~5 순서 변경이 필요한 경우 +4. **SSIM 기준** — 0.95가 적절한지, 조정 필요 여부 diff --git a/src/renderer/html.rs b/src/renderer/html.rs index 410d9b0e0..45a9b7702 100644 --- a/src/renderer/html.rs +++ b/src/renderer/html.rs @@ -248,6 +248,11 @@ impl Renderer for HtmlRenderer { } fn draw_text(&mut self, text: &str, x: f64, y: f64, style: &TextStyle) { + // PUA 문자(U+F000~F0FF, Wingdings 등 심볼 폰트)를 유니코드 표준 문자로 변환 + let text = &text.chars().map(|ch| { + crate::renderer::layout::map_pua_bullet_char(ch) + }).collect::(); + let font_size = if style.font_size> 0.0 { style.font_size } else { 12.0 }; let color = color_to_css(style.color); let font_family = if style.font_family.is_empty() { diff --git a/src/renderer/layout.rs b/src/renderer/layout.rs index abd66b274..5e79f6a8b 100644 --- a/src/renderer/layout.rs +++ b/src/renderer/layout.rs @@ -405,18 +405,15 @@ impl LayoutEngine { self.build_header(&mut tree, page_content, header_paragraphs, composed, styles, layout, bin_data_content); } - // 본문 영역 노드 + // 본문 영역 노드 (clip_rect은 콘텐츠 레이아웃 후 확정) let body_id = tree.next_id(); + let body_bbox = layout_rect_to_bbox(&layout.body_area); let mut body_node = RenderNode::new( body_id, RenderNodeType::Body { - clip_rect: if self.clip_enabled.get() { - Some(layout_rect_to_bbox(&layout.body_area)) - } else { - None - }, + clip_rect: None, // 레이아웃 후 설정 }, - layout_rect_to_bbox(&layout.body_area), + body_bbox, ); // 단별 콘텐츠 레이아웃 @@ -431,6 +428,43 @@ impl LayoutEngine { // 단 구분선 self.build_column_separators(&mut tree, &mut body_node, layout); + // 콘텐츠 레이아웃 후 clip_rect 확정: + // 자식 노드(표 등)의 실제 바운딩 박스를 재귀적으로 반영하여 + // body_area보다 큰 콘텐츠(표 외곽 테두리 등)가 잘리지 않도록 함 + if self.clip_enabled.get() { + let mut clip = body_bbox; + fn expand_clip(clip: &mut BoundingBox, node: &RenderNode) { + let cb = &node.bbox; + let child_bottom = cb.y + cb.height; + let child_right = cb.x + cb.width; + let clip_bottom = clip.y + clip.height; + let clip_right = clip.x + clip.width; + if child_bottom> clip_bottom { + clip.height = child_bottom - clip.y; + } + if child_right> clip_right { + clip.width = child_right - clip.x; + } + if cb.x < clip.x { + clip.width += clip.x - cb.x; + clip.x = cb.x; + } + if cb.y < clip.y { + clip.height += clip.y - cb.y; + clip.y = cb.y; + } + for child in &node.children { + expand_clip(clip, child); + } + } + for child in &body_node.children { + expand_clip(&mut clip, child); + } + body_node.node_type = RenderNodeType::Body { + clip_rect: Some(clip), + }; + } + // 용지 기준 이미지: body clip 바깥에 배치 (배경 이미지 등) for img_node in paper_images { tree.root.children.push(img_node); diff --git a/src/renderer/layout/paragraph_layout.rs b/src/renderer/layout/paragraph_layout.rs index 7debafff0..5d422e017 100644 --- a/src/renderer/layout/paragraph_layout.rs +++ b/src/renderer/layout/paragraph_layout.rs @@ -2150,11 +2150,13 @@ impl LayoutEngine { } // 문단 테두리/배경 범위 수집 (build_single_column에서 연속 그룹으로 병합 렌더링) + // margin_left/margin_right를 반영하여 박스 위치·폭 조정 if para_border_fill_id> 0 { let bg_height = y - bg_y_start; if bg_height> 0.0 { + // margin_left/margin_right는 이미 px 단위 (style_resolver에서 변환됨) self.para_border_ranges.borrow_mut().push( - (para_border_fill_id, col_area.x, bg_y_start, col_area.width, y) + (para_border_fill_id, col_area.x + margin_left, bg_y_start, col_area.width - margin_left - margin_right, y) ); } } diff --git a/src/renderer/layout/table_layout.rs b/src/renderer/layout/table_layout.rs index eda919045..5e6b9f422 100644 --- a/src/renderer/layout/table_layout.rs +++ b/src/renderer/layout/table_layout.rs @@ -332,6 +332,67 @@ impl LayoutEngine { split_row_range, row_y_shift, ); + + // ── 5-1. 표 전체 외곽 테두리 보충 ── + // 셀 테두리만으로는 표 외곽이 비어있을 수 있음. + // 셀이 해당 외곽 엣지를 커버하지 않는 곳에만 table.border_fill_id fallback 적용. + // (셀이 존재하지만 의도적으로 테두리를 없앤 곳에는 적용하지 않음) + if table.border_fill_id> 0 { + let tbl_idx = (table.border_fill_id as usize).saturating_sub(1); + if let Some(tbl_bs) = styles.border_styles.get(tbl_idx) { + let borders = &tbl_bs.borders; // [left, right, top, bottom] + + // 셀이 커버하는 외곽 엣지 맵 구축 + let mut h_covered = vec![vec![false; col_count]; row_count + 1]; + let mut v_covered = vec![vec![false; row_count]; col_count + 1]; + for cell in &table.cells { + let c = cell.col as usize; + let r = cell.row as usize; + if c>= col_count || r>= row_count { continue; } + let ec = (c + cell.col_span as usize).min(col_count); + let er = (r + cell.row_span as usize).min(row_count); + // 상단 + if r == 0 { for cc in c..ec { h_covered[0][cc] = true; } } + // 하단 + if er == row_count { for cc in c..ec { h_covered[row_count][cc] = true; } } + // 좌측 + if c == 0 { for rr in r..er { v_covered[0][rr] = true; } } + // 우측 + if ec == col_count { for rr in r..er { v_covered[col_count][rr] = true; } } + } + + // 셀이 커버하지 않는 외곽 엣지에만 fallback 적용 + for c in 0..col_count { + if h_edges[0][c].is_none() && !h_covered[0][c] { + let b = &borders[2]; + if !matches!(b.line_type, crate::model::style::BorderLineType::None) && b.width> 0 { + h_edges[0][c] = Some(*b); + } + } + if h_edges[row_count][c].is_none() && !h_covered[row_count][c] { + let b = &borders[3]; + if !matches!(b.line_type, crate::model::style::BorderLineType::None) && b.width> 0 { + h_edges[row_count][c] = Some(*b); + } + } + } + for r in 0..row_count { + if v_edges[0][r].is_none() && !v_covered[0][r] { + let b = &borders[0]; + if !matches!(b.line_type, crate::model::style::BorderLineType::None) && b.width> 0 { + v_edges[0][r] = Some(*b); + } + } + if v_edges[col_count][r].is_none() && !v_covered[col_count][r] { + let b = &borders[1]; + if !matches!(b.line_type, crate::model::style::BorderLineType::None) && b.width> 0 { + v_edges[col_count][r] = Some(*b); + } + } + } + } + } + // ── 6. 테두리 렌더링 ── table_node.children.extend(render_edge_borders( tree, &h_edges, &v_edges, &row_col_x, &row_y, table_x, table_y, diff --git a/src/renderer/svg.rs b/src/renderer/svg.rs index 351fd433c..4ab6c4f17 100644 --- a/src/renderer/svg.rs +++ b/src/renderer/svg.rs @@ -1633,6 +1633,11 @@ impl Renderer for SvgRenderer { } fn draw_text(&mut self, text: &str, x: f64, y: f64, style: &TextStyle) { + // PUA 문자(U+F000~F0FF, Wingdings 등 심볼 폰트)를 유니코드 표준 문자로 변환 + let text = &text.chars().map(|ch| { + crate::renderer::layout::map_pua_bullet_char(ch) + }).collect::(); + let color = color_to_svg(style.color); let font_size = if style.font_size> 0.0 { style.font_size } else { 12.0 }; let font_family = if style.font_family.is_empty() { diff --git a/src/renderer/web_canvas.rs b/src/renderer/web_canvas.rs index 558ce6c53..3d2206a63 100644 --- a/src/renderer/web_canvas.rs +++ b/src/renderer/web_canvas.rs @@ -1166,6 +1166,11 @@ impl Renderer for WebCanvasRenderer { } fn draw_text(&mut self, text: &str, x: f64, y: f64, style: &TextStyle) { + // PUA 문자(U+F000~F0FF, Wingdings 등 심볼 폰트)를 유니코드 표준 문자로 변환 + let text = &text.chars().map(|ch| { + crate::renderer::layout::map_pua_bullet_char(ch) + }).collect::(); + // 글꼴 설정 let font_weight = if style.bold { "bold " } else { "" }; let font_style = if style.italic { "italic " } else { "" }; AltStyle によって変換されたページ (->オリジナル) / アドレス: モード: デフォルト 音声ブラウザ ルビ付き 配色反転 文字拡大 モバイル
AltStyle によって変換されたページ (->オリジナル) / アドレス: モード: デフォルト 音声ブラウザ ルビ付き 配色反転 文字拡大 モバイル