diff --git a/mydocs/orders/20260514.md b/mydocs/orders/20260514.md index 01afa410a..1e8a99b55 100644 --- a/mydocs/orders/20260514.md +++ b/mydocs/orders/20260514.md @@ -1,5 +1,14 @@ # 오늘 할일 - 2026년 5월 14일 +## M100 — v1.0.0 조판 엔진 + +| Issue | 타스크 | 상태 | 비고 | +|------|--------|------|------| +| **#877** | hwp3-sample16.hwp WASM 로드 실패 — RawVec capacity overflow + paragraph alignment + 시각 정합 | **완료 (PR #890 머지 대기)** | 브랜치 `local/task877_v2` (22 commits). 핵심 근본 원인: HWP3 drawing Fill.alpha 한컴 convention. 한컴 viewer 정합. 잔존 3건은 #894 통합 task 에서 처리 후 #895/#896 분리. | +| **#894** | Task #877 잔존 통합 (HWPX 페이지 수 / paragraph multi-line picture / HWP5 페이지 수 → HWP3 외곽선 좌표) | **완료** | 브랜치 `local/task894` (base `local/devel @ c2955b5`). Stage 2 (paragraph multi-line picture 중복 image 3→1) + Stage 3 (HWP3 page border paper_based, 페이지 번호 외곽선 안) 완전 해소. Stage 1 (HWPX self-closing run charPrIDRef) 정확성 보강. cargo test --all-targets 1355 passed. 최종 보고서: `mydocs/report/task_m100_894_report.md`. | +| **#895** | HWPX 변환본 lineseg vpos 페이지 break 0 reset 누락 (Task #894 Stage 1 분리) | **등록 (별도 task 권장)** | 한컴 HWPX 변환기가 lineseg vpos 를 누적값으로 저장 → typeset 의 vpos-reset trigger (cv==0 && pv>5000) 발동 실패 → 페이지 break point 마다 1 페이지 누적 inflate (sample16-hwp5.hwpx 72/62). Fix α/β/γ 후보 모두 광범위 영향 + 회귀 점검 자료 부족. | +| **#896** | sample16 페이지 18 추가 시각 — ◦ x 좌표 + WMF 텍스트 (Task #894 Stage 4 분리) | **등록 (별도 task 권장)** | 차이 1 (paragraph 397~399 ◦ x 좌표 시프트): raw HWP3 char_shapes 의 empty char + space 분할 구조 → paragraph_layout 첫 빈 char_shape 처리 차이. 차이 2 (WMF 그림 안 텍스트 겹침): WMF converter 영역. 모두 본 task scope 외 별도 영역. | + ## M100 — v1.0.0 조판 엔진 (PR 처리 — 5/14 사이클) | Issue | 타스크 | 상태 | 비고 | diff --git a/mydocs/plans/task_m100_877.md b/mydocs/plans/task_m100_877.md new file mode 100644 index 000000000..7a669b456 --- /dev/null +++ b/mydocs/plans/task_m100_877.md @@ -0,0 +1,64 @@ +# Task #877 수행 계획서 + +**제목**: hwp3-sample16.hwp WASM 로드 실패 — RawVec capacity overflow (HWP3 picture record alignment) + +**이슈**: https://github.com/edwardkim/rhwp/issues/877 +**브랜치**: `local/task877` (분기: `local/devel`) +**마일스톤**: v1.0.0 (M100) + +## 배경 + +`samples/hwp3-sample16.hwp` (2.9 MB, 64쪽 RFP 문서) 를 rhwp-studio 에서 열면 panic: + +``` +panicked at library/alloc/src/raw_vec/mod.rs:28:5: capacity overflow +[main] 파일 로드 실패: RuntimeError: unreachable +``` + +한컴오피스에서는 정상 오픈. 네이티브 CLI 에서는 graceful Err 반환. + +## 원인 분석 (이슈 #877 의 probe 결과) + +### 1차 panic 지점 +[src/parser/hwp3/records.rs:413](../../src/parser/hwp3/records.rs#L413) — `Hwp3AdditionalInfoBlock::read`: +```rust +let length = reader.read_u32::()?; +let mut data = vec![0u8; length as usize]; // length = 0xDC000000 (~3.69 GB) → WASM panic +``` + +### 상위 원인 +- decompressed body = 16.2 MB (64쪽 분량) 인데 paragraph 1개만 인식 +- 첫 picture (`ch=11`) 처리 후 cursor misalign → 16.18 MB 가 통째로 `additional_info_blocks` 영역으로 잘못 진입 +- 첫 additional_info_block 의 length 가 garbage (0xDC000000) 로 읽혀 거대 할당 시도 + +### 환경별 차이 +- 32-bit WASM: 메모리 한계 초과 → `RawVec::capacity_overflow` panic → `unreachable` trap +- 64-bit 네이티브: 가상메모리 할당 통과 → `read_exact` EOF → graceful Err + +## 목표 + +1. **방어성 (Stage 1)**: 모든 HWP3 length-prefixed 할당 지점에 sanity check 도입 → panic → Err 변환. WASM/네이티브 모두 graceful fail. +2. **WASM panic hook (Stage 2)**: `console_error_panic_hook` 등록 (이미 부분 동작 — 메시지 노출은 되나 stack 이 wasm function index 만). Rust source map 강화 검토. +3. **근본 원인 (Stage 3)**: HWP3 picture (`ch=11`) byte alignment 정합. sample16 의 첫 picture 가 정확히 어디까지 stream 을 소비해야 하는지 spec 재확인 + 수정 → 64쪽 전체 파싱 성공. + +## 제약 / 비목표 + +- 수정은 `src/parser/hwp3/` 영역에 한정 (CLAUDE.md HWP3 파서 규칙). +- 렌더링 품질 (예: picture 미리보기) 은 본 task 범위 밖. 본 task 는 **로드 성공 + 64쪽 인식** 까지가 목표. +- 다른 HWP3 sample (sample01~14) 의 회귀 없음 확인 필수. + +## 검증 계획 + +1. `cargo test --release` 전체 테스트 통과 +2. `cargo run --release --bin rhwp -- dump samples/hwp3-sample16.hwp` 성공 +3. `cargo run --release --bin rhwp -- dump-pages samples/hwp3-sample16.hwp -p 0` 64쪽 인식 +4. `cargo run --release --bin rhwp -- export-svg samples/hwp3-sample16.hwp` 64개 SVG 출력 +5. 다른 HWP3 sample (sample01/10/11/13/14 등) export-svg 회귀 없음 +6. rhwp-studio WASM 빌드 후 sample16 로드 + 페이지 표시 시각 확인 + +## 참고 파일 + +- 이슈: [GitHub #877](https://github.com/edwardkim/rhwp/issues/877) +- 샘플: `samples/hwp3-sample16.hwp` (미커밋 신규) +- 비교 샘플: `samples/hwp3-sample16-hwp5.hwp`, `samples/hwp3-sample16-hwp5.hwpx` +- 코드: [src/parser/hwp3/records.rs:405-417](../../src/parser/hwp3/records.rs#L405-L417), [src/parser/hwp3/mod.rs:929-1128](../../src/parser/hwp3/mod.rs#L929-L1128) diff --git a/mydocs/plans/task_m100_877_impl.md b/mydocs/plans/task_m100_877_impl.md new file mode 100644 index 000000000..802b99260 --- /dev/null +++ b/mydocs/plans/task_m100_877_impl.md @@ -0,0 +1,178 @@ +# Task #877 구현 계획서 + +**관련 수행 계획서**: [task_m100_877.md](task_m100_877.md) + +## 단계 구성 (3 stage) + +--- + +## Stage 1 — 방어성 가드 (allocation sanity check) + +### 목적 +HWP3 파서에서 외부 입력으로 받은 `length` 값으로 직접 `vec![0u8; length as usize]` / `Vec::with_capacity(length)` 를 하는 모든 지점에 sanity check 도입. +- 32-bit WASM 의 `RawVec` capacity overflow panic → graceful `Hwp3Error::IoError` 변환 +- 64-bit 네이티브의 거대 가상메모리 할당 시도 차단 → 빠른 graceful fail + +### 작업 내용 + +1. **취약 지점 식별** (`src/parser/hwp3/` grep): + - [records.rs:413](../../src/parser/hwp3/records.rs#L413) `Hwp3AdditionalInfoBlock::read` — `length: u32` + - [records.rs:392](../../src/parser/hwp3/records.rs#L392) `Hwp3InfoBlock::read` — `length: u16` (16-bit 이므로 ≤64KB, panic 위험 낮음) + - [mod.rs:932](../../src/parser/hwp3/mod.rs#L932) picture `ext_buf` — `n_ext: u32` + - [mod.rs:1120](../../src/parser/hwp3/mod.rs#L1120) `ch=29` (`< 1000000` 검증 기존 존재 — 동일 패턴 표준화) + - drawing.rs, ole.rs 등 다른 `read_exact` 호출 위치 정밀 grep + +2. **공통 helper 도입** (예: `src/parser/hwp3/util.rs` 또는 `mod.rs` 내부): + ```rust + /// stream 의 남은 길이를 초과하는 length 요청 시 Err. + /// HWP3 binary 파싱에서 garbage length 로 인한 거대 vec! 할당을 방지. + fn read_sized_buf( + reader: &mut R, + length: usize, + max_allowed: usize, + ) -> Result, io::Error> { + if length > max_allowed { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("HWP3 size overflow: requested={length}, max_allowed={max_allowed}"), + )); + } + let mut buf = vec![0u8; length]; + reader.read_exact(&mut buf)?; + Ok(buf) + } + ``` + - `max_allowed` 는 호출 측에서 stream 남은 길이 또는 spec 상 최댓값 전달 + - `Cursor<&[u8]>` 사용처에서는 `get_ref().len() - position()` 으로 산출 + - 일반 `R: Read` 사용처 (drawing.rs 등) 에서는 호출 측에서 적정 상한 전달 + +3. **호출 측 수정**: + - `Hwp3AdditionalInfoBlock::read`: `max_allowed = body_data.len() - cursor.position()` (호출자가 전달) + - picture `ext_buf`: `max_allowed` = body 잔여 또는 spec 권장 (예: 100 MB 상한) + - `ch=29` 의 기존 `< 1000000` 검증 → 새 helper 로 통합 + +4. **단위 테스트**: garbage length 입력 시 panic 없이 `Err` 반환 검증. + +### 산출물 +- 코드: `src/parser/hwp3/` 내 가드 적용 +- 보고서: `mydocs/working/task_m100_877_stage1.md` + +### 검증 +- `cargo test --release` 통과 +- sample16 dump 시 panic 없이 graceful Err (또는 부분 파싱 후 Err) 반환 +- 다른 HWP3 sample 회귀 없음 + +--- + +## Stage 2 — HWP3 picture (ch=11) byte alignment 정합 (근본 원인) + +### 목적 +sample16 가 1개 paragraph 만 인식되고 나머지 16.18 MB 가 잘못 해석되는 alignment 버그 수정 → 64쪽 전체 파싱 성공. + +### 작업 내용 + +1. **현 picture (ch=11) 처리 흐름 정리** ([mod.rs:852-1053](../../src/parser/hwp3/mod.rs#L852-L1053)): + - 6 byte 헤더 (u32 + u16) 소비 + - 348 byte `info_buf` + - `n_ext` (info_buf[0..4]) 만큼 `ext_buf` 추가 소비 + - `pic_type == 3` 일 때 `parse_drawing_object_tree(ext_buf)` (drawing.rs 진입) + - `caption_paras = parse_paragraph_list(body_cursor, ...)` **재귀 호출** + - 호출자 (text body 루프) 는 `i += 3` (헤더 4 hchar 소비) + +2. **sample16 picture 실제 byte 구조 분석**: + - decompressed body offset 15078~ 의 bytes 정밀 dump (probe binary 작성) + - `info_buf` 348 byte 의 모든 필드 추출 + 의미 매핑 (HWP3 spec hwp30_spec.pdf 또는 한컴 변환본 `hwp3-sample16-hwp5.hwp` IR 비교) + - 정확한 picture record 끝 위치 = ? + - 같은 sample16 가 처음 1개 paragraph 만 cc=5 인 이유 검증 (실제로 표지가 매우 작은 paragraph + 큰 picture 인 경우 vs. parser 가 일부 byte 를 빠뜨리는 경우) + +3. **HWP5 변환본 (`samples/hwp3-sample16-hwp5.hwp`) IR 비교**: + - `rhwp dump samples/hwp3-sample16-hwp5.hwp` 출력 → 한컴이 변환한 paragraph 구조 + - 첫 paragraph 의 picture record 가 HWP5 에서 어떻게 표현되는지 확인 + - HWP3 → HWP5 변환 spec offset 매핑 추정 + +4. **alignment 수정**: + - Picture record 끝나는 정확한 offset 산출 로직 수정 + - `caption_paras` 재귀 호출 시 진입 시점이 caption 영역인지 명확화 + - 가능한 원인 후보: + - (a) `info_buf` 크기 (348) 가 sample16 에서는 다를 가능성 (variant 1.7 vs 1.5 등 HWP3 minor version) + - (b) `ext_buf` 무조건 read 대신 `pic_type == 3` 일 때만 read + - (c) `caption_paras` 재귀 호출 진입 조건 (caption 이 있을 때만) + - (d) 다른 미처리 sub-record (예: pic_type 별 별도 데이터 블록) + +5. **회귀 방지**: 기존 HWP3 sample (sample01/10/11/13/14) 의 picture 처리 동일성 유지. + +### 산출물 +- 코드: `src/parser/hwp3/mod.rs` picture (`ch=11`) 부분 +- 분석 문서: `mydocs/tech/hwp3_picture_record_alignment.md` (byte 구조 정리) +- 보고서: `mydocs/working/task_m100_877_stage2.md` + +### 검증 +- `cargo run --bin rhwp -- dump samples/hwp3-sample16.hwp` 성공 (64쪽 인식) +- `cargo run --bin rhwp -- dump-pages samples/hwp3-sample16.hwp -p 0` 정상 +- 다른 HWP3 sample 회귀 없음 (sample01/10/11/13/14 dump 비교) + +--- + +## Stage 3 — WASM panic hook + 통합 회귀 테스트 + +### 목적 +- WASM 환경에서 향후 미식별 panic 발생 시 진단 가능하도록 panic hook 정비 +- sample16 회귀 방지를 위한 통합 테스트 추가 + +### 작업 내용 + +1. **WASM panic hook 점검**: + - 현재 console 로그에 `panicked at library/alloc/src/raw_vec/mod.rs:28:5: capacity overflow` 메시지는 노출 — `console_error_panic_hook` 또는 유사 hook 이 이미 동작 중인지 확인 + - `src/wasm_api.rs` 초기화 부에서 panic hook 설정 코드 확인 + 누락 시 추가 + - 빌드 옵션 (debug-info, source map) 검토 — wasm function index 만 노출되는 stack 을 source 라인으로 매핑하는 방법 조사 (debug build 비용 vs. release 성능) + - 결정: 매핑 비용이 크면 panic hook 만 강화하고 source map 은 별도 task 로 미룸 + +2. **try_reserve 패턴 검토**: + - Stage 1 의 helper 외에 `Vec::try_reserve` / `Vec::try_with_capacity_in` 같은 alloc API 적용 가치 검토 + - 현재 stable Rust 에서 `Vec::with_capacity` 의 panic 가능성 vs. `try_reserve_exact` 의 fallible 패턴 + - 보수적 결정: 본 task 에서는 helper 함수 + length 검증만 하고 `try_reserve` 도입은 별도 RFC 로 + +3. **통합 회귀 테스트 추가**: + - `tests/issue_877.rs` (또는 `tests/hwp3_sample16.rs`) 신설 + - sample16 로딩 → `DocumentCore::from_bytes()` panic 없이 성공 + - paragraph 개수 / 페이지 수가 1 보다 큰지 (Stage 2 후 64쪽 인식 검증) + - 다른 HWP3 sample (sample14 등) 의 회귀 없음 sanity check + +4. **rhwp-studio 시각 확인**: + - Docker 로 WASM 빌드 (`docker compose --env-file .env.docker run --rm wasm`) + - rhwp-studio 에서 sample16 로드 → 페이지 표시 확인 + - 스크린샷 캡처 → 보고서 첨부 + +### 산출물 +- 코드: `src/wasm_api.rs` panic hook (필요 시), `tests/issue_877.rs` +- 보고서: `mydocs/working/task_m100_877_stage3.md` + +### 검증 +- `cargo test --release tests::issue_877` 통과 +- `cargo test --release` 전체 통과 +- WASM 빌드 → rhwp-studio sample16 로드 시각 확인 + +--- + +## 최종 산출물 + +- 코드 수정: `src/parser/hwp3/` (records.rs, mod.rs), `src/wasm_api.rs`, `tests/issue_877.rs` +- 문서: + - 수행 계획서: `mydocs/plans/task_m100_877.md` + - 구현 계획서: `mydocs/plans/task_m100_877_impl.md` + - Stage 1/2/3 보고서: `mydocs/working/task_m100_877_stage{1,2,3}.md` + - 분석 문서: `mydocs/tech/hwp3_picture_record_alignment.md` + - 최종 보고서: `mydocs/report/task_m100_877_report.md` +- 신규 sample git 등록: `samples/hwp3-sample16.hwp` (및 `-hwp5.hwp` / `-hwp5.hwpx` 변환본) + +## 위험 / 미해결 가능성 + +- **Stage 2 가 가장 불확실**: HWP3 spec 의 picture record 정확한 layout 이 sample16 의 variant 와 일치하지 않을 가능성. 분석 후 본 task 범위 내에서 해결 불가 판단 시 Stage 1 (방어성) 만 머지하고 alignment 는 별도 task 로 분기. +- HWP3 spec 문서 부재 시 한컴 변환본 (`hwp3-sample16-hwp5.hwp`) IR 비교 + 다른 HWP3 sample 실측으로 reverse-engineer. + +## 진행 순서 + +1. Stage 1 시작 → 완료 후 Stage 1 보고서 + 승인 요청 +2. Stage 2 시작 → 분석 후 (alignment 가능 / 불가능) 결정 → Stage 2 보고서 + 승인 요청 +3. Stage 3 시작 → 완료 후 Stage 3 보고서 + 승인 요청 +4. 최종 결과 보고서 + orders 갱신 → 승인 요청 → 머지 diff --git a/mydocs/plans/task_m100_894.md b/mydocs/plans/task_m100_894.md new file mode 100644 index 000000000..000a9764e --- /dev/null +++ b/mydocs/plans/task_m100_894.md @@ -0,0 +1,136 @@ +# Task #894 수행 계획서 — Task #877 잔존 통합 (page border 좌표 / multi-line picture 중복 / HWP5 페이지 수 inflate) + +**이슈**: [edwardkim/rhwp#894](https://github.com/edwardkim/rhwp/issues/894) +**브랜치**: `local/task894` (base: `local/devel` @ `c2955b5`) +**마일스톤**: v1.0.0 (M100) +**선행 task**: #877 (closed, PR #890 머지 대기) + +## 1. 배경 + +Task #877 (hwp3-sample16.hwp 정합) 완료 후 분석된 잔존 3건을 1개 통합 이슈로 묶음. 각 항목은 영역이 다르므로 (renderer × 2, HWP5 파서 × 1) 본 task 내부에서 stage 분할로 처리. + +원본 분석: [mydocs/working/task_m100_877_residual.md](../working/task_m100_877_residual.md) + +## 2. 잔존 항목 요약 + +| # | 항목 | 영역 | 우선순위 | 예상 stage | +|---|------|------|---------|----------| +| A | HWP3 페이지 외곽선 좌표 기준 정합 | renderer (`src/renderer/layout.rs:732~764`) | 중 | Stage 3 | +| B | paragraph multi-line picture SVG 중복 emit | renderer (`typeset.rs` / `svg.rs`) | 중 | Stage 2 | +| C' | HWPX 변환본 페이지 수 inflate (72 → 62) | HWPX 파서 / pagination | 높 | Stage 1 | +| D | CLAUDE.md 컨트리뷰터 워크플로우 + 실수 회피 가이드 보강 (c2955b5) | docs | — | **base 포함** (별도 stage 없음) | + +**항목 D 주석**: c2955b5 commit 은 #877 작업 중 발생한 실수 (fork devel push, PR base 오류 등) 의 회피 가이드를 CLAUDE.md 에 추가한 변경. PR #890 에 미포함이므로 task894 PR 에 묶여 메인테이너에 전달됨. local/task894 의 base 가 local/devel @ c2955b5 이므로 자동 포함 (별도 stage 작업 불필요). + +## 3. 진행 전략 + +### 3.1 우선순위 판정 + +- **C (HWP5 페이지 수 inflate)** 가 우선순위 최상. 36 페이지 inflate (62→98) 는 광범위 영향 가능 — 다른 HWP5 샘플 회귀 점검도 필요. +- **A / B** 는 sample16 단일 케이스의 시각 정합 문제. 한컴 viewer 와의 미세 차이. + +### 3.2 진행 순서 (제안) + +순서 후보 (작업지시자 결정 사항): + +| 안 | 순서 | 사유 | +|----|------|------| +| (i) | C → B → A | 우선순위 높→낮. C 회귀 점검 결과가 다른 샘플에 영향 가능 | +| (ii) | A → B → C | 영역별 (renderer 먼저 → HWP5 파서). 한 영역의 변경 영향이 다음 영역에 미치지 않음 | +| (iii) | A → C → B | 작업 난이도 추정 낮 → 높 (B 가 multi-line picture pipeline 깊이 진단 필요) | + +**기본 추천**: (i) — 우선순위 가중. 하지만 항목별 영향 범위 / 의존성 / 작업지시자 의도에 따라 결정. + +### 3.3 Stage 분할 — 항목별 독립 + +각 stage 는 **독립 commit + 단계별 보고서 + 승인** 으로 진행. 항목 간 의존 없음. + +#### Stage 1: 항목 A — HWP3 페이지 외곽선 좌표 기준 정합 + +**진단 영역**: +- `src/renderer/layout.rs:732~764` — `build_page_borders` 의 `paper_based` 분기 +- `src/parser/hwp3/mod.rs` — Task #877 의 page border IR 변환 commit `c8ba53b` +- sample16 페이지 2 (목차) 우측 페이지 번호 좌표 dump + +**가설**: +1. HWP3 → IR 변환 시 `attr & 0x01` (paper_based) 가 잘못 설정 +2. `border_margin*` → `spacing_*` 변환 값 부적절 +3. 한컴 viewer 의 page border 좌표 기준이 paper-based 인데 rhwp 는 body-based + +**검증 절차**: +- `rhwp export-svg samples/hwp3-sample16.hwp --debug-overlay -p 1` → 페이지 2 외곽선 / 텍스트 좌표 비교 +- `pdf/hwp3-sample16-hwp5-2022.pdf` 페이지 2 → 한컴 viewer 정답 좌표 확인 +- HWP5 변환본 IR (`rhwp dump samples/hwp3-sample16-hwp5.hwp -s 0`) → page_border_fill attr 확인 + +**위험**: +- 다른 HWP3 샘플의 page border 회귀 가능 (sample4 / sample5 / sample10 등) +- 회귀 방지 위해 `cargo test --release` + `dump-pages` 6종 회귀 검증 필수 + +#### Stage 2: 항목 B — paragraph multi-line picture SVG 중복 emit + +**진단 영역**: +- `src/renderer/typeset/` 또는 `src/renderer/svg/` — paragraph 의 picture emit pipeline +- `src/renderer/picture_footnote.rs` (있다면) +- sample16 페이지 18 paragraph 394 (ls_count=3, controls=3, picture markers `` × 3) + +**가설**: +1. paragraph 의 multi-line 렌더링 시 picture control 이 line 단위로 emit → ls_count=3 마다 3번 +2. inline picture 의 line wrap 로직이 각 line 마다 image 재발생 +3. typeset 단계의 picture placement 가 single emission 으로 dedupe 안 됨 + +**검증 절차**: +- `rhwp dump samples/hwp3-sample16.hwp -s 0 -p 394` → controls 구조 +- `rhwp export-svg samples/hwp3-sample16.hwp -p 17` → SVG `` 개수 / href 동일성 확인 +- 다른 샘플에서 multi-line + inline picture 패턴 검색 + +**위험**: +- 렌더러 typeset pipeline 변경은 광범위 영향 가능 +- treat_as_char=true picture 의 정상 emit 회귀 점검 필요 + +#### Stage 3: 항목 C — HWP5 변환본 페이지 수 inflate + +**진단 영역**: +- `src/parser/hwp5/` — HWP5 파서 전반 +- `src/renderer/pagination.rs` 또는 페이지 분할 로직 +- `samples/hwp3-sample16-hwp5.hwp` — 한컴 변환본, 한컴 viewer 62 페이지 / rhwp 98 페이지 + +**가설**: +1. HWP5 파서의 paragraph spacing / line spacing 해석 오류 → 페이지당 라인 수 부족 +2. ParaShape `spacing_after` / `spacing_line` 값 단위 변환 (HWPUNIT / Percent / Fixed) 부정확 +3. CharShape / 폰트 metric 계산 차이로 line height 부풀림 +4. Table / picture 의 height 측정 오류로 인한 페이지 break 과다 + +**검증 절차**: +- `rhwp dump-pages samples/hwp3-sample16-hwp5.hwp -p 0` → 페이지 1 의 paragraph 배치 + 높이 +- `rhwp ir-diff samples/hwp3-sample16-hwp5.hwpx samples/hwp3-sample16-hwp5.hwp` → HWPX (62 페이지로 비교 권위 보유) 와 IR 차이 +- 다른 HWP5 샘플 페이지 수 (`gh-issue-* / hwpx-*` 등) 한컴 viewer 와 대조 + +**위험**: +- HWP5 파서 / pagination 변경은 **모든 HWP5 / HWPX 샘플에 영향** — 회귀 위험 최고 +- 회귀 방지: `cargo test --release` + 시각 회귀 (golden_svg 디렉토리) + 페이지 수 회귀 검증 +- 잠재적으로 본 stage 가 별도 후속 task 로 분리 필요할 수도 있음 (작업 규모 측정 후 결정) + +## 4. 검증 / 회귀 방지 + +각 stage 완료 시: +- `cargo test --release` 전체 통과 (현재 1381 passed 기준) +- HWP3 sample 6종 dump 페이지 수 회귀 없음 (sample / sample4 / sample5 / sample10 / sample13 / sample14) +- HWP5 / HWPX 주요 샘플 페이지 수 회귀 점검 (Stage 3 시 필수) +- golden SVG 회귀 (`tests/golden_svg/`) + +## 5. 산출물 / 문서 파일 + +| 단계 | 파일 | +|------|------| +| 수행 계획서 | `mydocs/plans/task_m100_894.md` (본 파일) | +| 구현 계획서 | `mydocs/plans/task_m100_894_impl.md` (다음 단계) | +| Stage 1 보고서 | `mydocs/working/task_m100_894_stage1.md` | +| Stage 2 보고서 | `mydocs/working/task_m100_894_stage2.md` | +| Stage 3 보고서 | `mydocs/working/task_m100_894_stage3.md` | +| 최종 보고서 | `mydocs/report/task_m100_894_report.md` | + +## 6. 작업지시자 결정 요청 사항 + +1. **진행 순서** — (i) C → B → A / (ii) A → B → C / (iii) A → C → B 중 선택 +2. **Stage 3 (C) 의 별도 task 분리 여부** — HWP5 파서 변경 규모가 클 경우 본 task 에서 별도 분리 가능 +3. **수행 계획서 자체 승인** — 본 문서 base 로 구현 계획서 작성 진행 가능 여부 diff --git a/mydocs/plans/task_m100_894_impl.md b/mydocs/plans/task_m100_894_impl.md new file mode 100644 index 000000000..804e8058e --- /dev/null +++ b/mydocs/plans/task_m100_894_impl.md @@ -0,0 +1,152 @@ +# Task #894 구현 계획서 — 잔존 통합 (Stage 1~3) + +**이슈**: [edwardkim/rhwp#894](https://github.com/edwardkim/rhwp/issues/894) +**수행 계획서**: [task_m100_894.md](task_m100_894.md) +**진행 순서**: Stage 1 (C') → Stage 2 (B) → Stage 3 (A) +**Scope 변경**: 항목 C (HWP5 변환본 inflate) 는 #877 진행 중 이미 해결 → **항목 C' 로 대체**: HWPX 변환본 페이지 수 정합 (72 → 62) +**Scope 추가**: 항목 D (CLAUDE.md c2955b5 컨트리뷰터 워크플로우 보강) — PR #890 미포함, task894 PR 로 메인테이너 전달. base 자동 포함, 별도 stage 없음 +**Scope 갱신 (Stage 1 진단 후)**: Stage 1 의 ROOT CAUSE (HWPX lineseg vpos 페이지 break 0 reset 누락) 가 한컴 HWPX 변환기의 본질적 한계. Fix 가 광범위 영향 + 회귀 점검 자료 부족 → **별도 task [#895](https://github.com/edwardkim/rhwp/issues/895) 로 분리**. 본 task 의 Stage 1 은 Fix 1 (HWPX self-closing run charPrIDRef, `55c6191`) 만 유지하는 정확성 보강으로 종료. + +## Stage 1 — 항목 C' : HWPX 변환본 페이지 수 inflate (72 → 62) + +### 1.0 사전 진단 결과 (이미 수행) + +``` +샘플 rhwp 페이지 한컴 viewer 차이 +hwp3-sample16-hwp5.hwp 62 62 0 ✅ +hwp3-sample16-hwp5.hwpx 72 62 +10 ❌ +hwp3-sample16.hwp 64 64 0 ✅ +``` + +`ir-diff` 카테고리 요약 (HWPX vs HWP5): + +| 항목 | 건수 | 영향 | +|------|------|------| +| char_shapes count | 604 | 라인 구성 영향 가능 | +| line_segs count | 59 | **직접적 pagination 영향** | +| cc (char count) | 26 | paragraph 길이 | +| text | 13 | paragraph 텍스트 | +| controls count | 1 | | + +### 1.1 정밀 진단 (Step 1) + +- [ ] `ir-diff` 의 line_segs count 차이가 발생하는 paragraph 위치 파악 (`--max-lines` 또는 grep 으로 lines 차이만 추출) +- [ ] 차이 paragraph 의 char_shape segmentation 차이 분석 (rhwp 가 HWPX char_shape 를 과다 분할 가정) +- [ ] HWPX `section0.xml` 원본 char shape 정보 직접 확인 (`unzip -p hwp3-sample16-hwp5.hwpx Contents/section0.xml | head`) +- [ ] HWPX 파서의 char_shape parsing 코드 위치 식별 (`src/parser/hwpx/section.rs` 등) + +### 1.2 가설 후보 + +| 가설 | 검증 방법 | +|------|----------| +| H1: HWPX char_shape 가 line_seg 마다 fragmenting 됨 (HWP5 는 paragraph 전체 1개) | char_shape count 604 / line_segs 59 의 비율 분석 | +| H2: HWPX paragraph 의 line wrap 계산이 다른 폰트 metric 사용 → 페이지 라인 부족 | 동일 paragraph 의 line_seg 폭 / height 비교 | +| H3: HWPX charPr 의 size unit 단위 변환 오류 | char_shape size 직접 비교 | + +### 1.3 수정 (Step 2) + +가설 검증 후 root cause 에 따라 분기. 잠정 작업: +- [ ] HWPX 파서 (`src/parser/hwpx/`) 의 char_shape / line_seg 변환 로직 수정 +- [ ] 변환 결과 ir-diff 재실행 → line_segs count 차이 0 또는 최소화 + +### 1.4 검증 (Step 3) + +- [ ] `cargo run --release -- dump-pages samples/hwp3-sample16-hwp5.hwpx` → 62 페이지 정합 +- [ ] `cargo test --release` 전체 통과 +- [ ] **HWPX 회귀 점검**: 모든 HWPX 샘플 페이지 수 회귀 없음 + - samples 디렉토리 내 `.hwpx` 파일 전체 `dump-pages` 페이지 수 before/after 비교 스크립트 실행 +- [ ] golden SVG 회귀 (`tests/golden_svg/`) + +### 1.5 위험 + +- HWPX 파서 변경은 **모든 HWPX 샘플에 영향** — 회귀 위험 최고. 1.4 단계에서 회귀 발견 시 fix 범위를 sample16-hwp5.hwpx 의 특정 패턴으로 좁히는 방향 전환. + +--- + +## Stage 2 — 항목 B : paragraph multi-line picture SVG 중복 emit + +### 2.1 진단 (Step 1) + +- [ ] `cargo run --release -- dump samples/hwp3-sample16.hwp -s 0 -p 394` → controls 정밀 구조 +- [ ] `cargo run --release -- export-svg samples/hwp3-sample16.hwp -p 17` → SVG `` 개수 / href 동일성 +- [ ] 다른 샘플에서 inline picture (``) multi-line 패턴 검색 (`rg -l "ls_count"` 등) +- [ ] picture emit 코드 위치: `src/renderer/typeset/` 또는 `src/renderer/svg/` (`grep -rn "image" src/renderer/svg/`) + +### 2.2 가설 후보 + +| 가설 | 검증 방법 | +|------|----------| +| H1: picture control 이 line_seg 단위로 emit → ls_count=3 마다 3번 발생 | typeset.rs 의 picture placement 코드 inspection | +| H2: text 의 `` marker 가 3개 있어 marker 마다 image 1개 emit | text 분석 + control index ↔ marker index 매핑 검증 | +| H3: paragraph emit 시 controls iterate × line_seg iterate 이중 loop | 코드 trace | + +### 2.3 수정 (Step 2) + +- [ ] root cause 에 따라 picture emit 위치 1회 emission 으로 dedupe +- [ ] treat_as_char picture 와 그렇지 않은 picture 의 emit 분기 보존 + +### 2.4 검증 (Step 3) + +- [ ] sample16 페이지 18 SVG → `` 1개 (paragraph 394 [1]) +- [ ] `cargo test --release` + golden SVG 회귀 +- [ ] 다른 picture 샘플 회귀 점검 (treat_as_char picture 정상 emit) + +### 2.5 위험 + +- 렌더러 typeset 변경은 광범위 영향. treat_as_char picture / float picture 모두 회귀 점검 필수. + +--- + +## Stage 3 — 항목 A : HWP3 페이지 외곽선 좌표 기준 정합 + +### 3.1 진단 (Step 1) + +- [ ] `cargo run --release -- dump samples/hwp3-sample16.hwp -s 0 -p 0` → page_border_fill attr / spacing 값 확인 +- [ ] `cargo run --release -- export-svg samples/hwp3-sample16.hwp --debug-overlay -p 1` → 페이지 2 외곽선 + 텍스트 좌표 +- [ ] `pdf/hwp3-sample16-hwp5-2022.pdf` 페이지 2 → 한컴 정답 좌표 +- [ ] HWP5 변환본 IR (`rhwp dump samples/hwp3-sample16-hwp5.hwp -s 0`) → page_border_fill attr 비교 + +### 3.2 가설 후보 + +| 가설 | 검증 방법 | +|------|----------| +| H1: HWP3 → IR 변환 시 `attr & 0x01` paper_based 가 잘못 설정 (false 가 정답인데 true 또는 반대) | HWP5 변환본 attr 와 대조 | +| H2: `border_margin*` → `spacing_*` 변환 값 부정확 (5mm 가 아닌 다른 단위) | 한컴 spec 참조 + HWP5 변환본 spacing 값 대조 | +| H3: 본문 paragraph 의 right margin 이 body_area 초과 — page border 가 아닌 paragraph 측 문제 | paragraph 의 right offset 측정 | + +### 3.3 수정 (Step 2) + +- [ ] HWP3 page_border_fill IR 변환 (`src/parser/hwp3/mod.rs`) 수정 +- [ ] paper_based / spacing 정합 + +### 3.4 검증 (Step 3) + +- [ ] sample16 페이지 2 SVG → 페이지 번호 외곽선 박스 안 +- [ ] HWP3 sample 6종 (sample/4/5/10/13/14) 페이지 외곽선 회귀 점검 +- [ ] `cargo test --release` + golden SVG 회귀 + +### 3.5 위험 + +- HWP3 다른 샘플의 page border 회귀. 회귀 발견 시 sample16 특정 조건 (attr / margin 값) 으로 분기 처리. + +--- + +## 통합 검증 (모든 Stage 완료 후) + +- [ ] `cargo test --release` 전체 통과 (현재 1381 passed 기준) +- [ ] HWP3 6종 + HWPX 주요 샘플 dump-pages 회귀 없음 +- [ ] sample16 (HWP3 / HWP5 / HWPX) 페이지 수 정합 64 / 62 / 62 +- [ ] golden SVG 회귀 없음 + +## 산출물 + +| 단계 | 파일 | +|------|------| +| Stage 1 보고서 | `mydocs/working/task_m100_894_stage1.md` | +| Stage 2 보고서 | `mydocs/working/task_m100_894_stage2.md` | +| Stage 3 보고서 | `mydocs/working/task_m100_894_stage3.md` | +| 최종 보고서 | `mydocs/report/task_m100_894_report.md` | + +## 의사결정 요청 + +본 구현 계획서 자체 승인. 승인 시 Stage 1 (HWPX 변환본 페이지 수 정합) 진단부터 진행. diff --git a/mydocs/plans/task_m100_896.md b/mydocs/plans/task_m100_896.md new file mode 100644 index 000000000..4c90096f5 --- /dev/null +++ b/mydocs/plans/task_m100_896.md @@ -0,0 +1,132 @@ +# Task #896 수행 계획서 — sample16 페이지 18 추가 시각 정합 (Task #894 Stage 4 분리) + +**이슈**: [edwardkim/rhwp#896](https://github.com/edwardkim/rhwp/issues/896) +**브랜치**: `local/task896` (base: `local/task894 @ ce8d3ce`) +**선행 task**: #894 (PR #897 MERGEABLE, 메인테이너 머지 대기) +**선선행 task**: #877 (PR #890 MERGEABLE) + +## 1. 배경 + +Task #894 의 Stage 4 진단 중 발견된 두 가지 차이. sample16 페이지 18 의 추가 시각 정합: +1. paragraph 397/398/399 의 ◦ 글머리 x 좌표 차이 (paragraph_layout) +2. paragraph 394 picture (WMF 다이어그램) 안의 텍스트 겹침 (WMF converter) + +본 task #894 의 HWP3 파서/IR 영역과 다른 영역으로 분리 — paragraph_layout / WMF converter 가 본 task 의 작업 영역. + +## 2. 차이별 사전 진단 (Task #894 Stage 4 결과 정합) + +### 2.1 차이 1 — ◦ 글머리 x 좌표 + +| paragraph | 첫 글머리 | rhwp SVG x | 한컴 viewer | +|-----------|----------|-----------|------------| +| 396 | ○ | 117.81 | (들여쓰기 정합) | +| 397/398/399 | ◦ | **107.30** (-10.5 px) | (paragraph 396 과 같은 x) | + +**ROOT CAUSE**: paragraph 397/398/399 의 raw HWP3 char_shapes 가 `empty char + space 분할 구조`: + +``` +paragraph 396: + [CS] pos=0 id=1116 spacing=-8% char=" " (single char_shape) + +paragraph 397/398/399: + [CS] pos=0 id=1117 spacing=0% char="" ← empty char + 0% spacing + [CS] pos=0 id=1118 spacing=-8% char=" " ← 그 다음 ' ' +``` + +rhwp paragraph_layout 이 첫 빈 char_shape 의 spacing/font 를 paragraph 시작 x 계산에 반영 → ◦ 위치 시프트. + +**Fix 방향 (회귀 위험 매우 높음)**: +- paragraph 의 첫 빈 char ("") 의 char_shape 를 paragraph 시작 x 계산에서 skip +- 모든 paragraph 영향 → HWP3/HWP5/HWPX 회귀 점검 필수 + +### 2.2 차이 2 — WMF 그림 안 텍스트 겹침 + +`samples/hwp3-sample16.hwp` 페이지 18 의 paragraph 394 picture (WMF, `bin_id=3`, "주전산센터 목표시스템 구성(안)") 내부 텍스트 (Windows 서버군, DMZ 등) 가 한컴 viewer 와 달리 겹쳐 보임. + +**영역**: `src/wmf/converter/` — `text_out`, `ext_text_out`, `set_text_align` 등. + +**Fix 방향 (영역 매우 큼)**: +- WMF text positioning / font / clipping 정합 +- 한컴 사적 WMF 확장 가능성 조사 +- 다른 WMF 샘플 회귀 점검 자료 확보 필요 + +## 3. 진행 전략 + +### 3.1 차이별 독립성 + +- 차이 1 (paragraph_layout) — 모든 paragraph 영향, 회귀 위험 매우 높 +- 차이 2 (WMF converter) — 별도 영역, 회귀 점검 자료 부족 + +각 차이는 영역이 다르므로 **독립 stage 진행**. stage 간 의존 없음. + +### 3.2 진행 순서 옵션 + +| 안 | 순서 | 사유 | +|----|------|------| +| (i) | 차이 1 → 차이 2 | paragraph_layout 친숙도 + 회귀 검증 자료 풍부 | +| (ii) | 차이 2 → 차이 1 | WMF 영역 큼 → 먼저 분량 측정 후 다음 결정 | +| (iii) | 차이 1 만 진행, 차이 2 별도 task | 두 영역 분량 합 매우 큼 | +| (iv) | 차이 2 만 진행, 차이 1 별도 task | WMF 영역 우선 | + +**기본 추천**: (iii) — 차이 1 만 본 task 에서 진행. 차이 2 (WMF) 는 추가 분리 task. + +**확정 (작업지시자 결정)**: **(b) 차이 1 + 차이 2 모두 본 task**. 구현 계획서에서 5 stages 분할 (각 차이 진단/Fix/회귀 + 통합 검증). + +### 3.3 Stage 분할 (옵션 iii 기준) + +#### Stage 1 — 차이 1: paragraph_layout 정밀 진단 + +1. `src/renderer/layout/paragraph_layout.rs` 의 paragraph 시작 x 계산 코드 추적 +2. 첫 char_shape 의 spacing/font_size 가 paragraph 시작 x 에 미치는 영향 분석 +3. paragraph 396 vs 397 의 layout 결과 정밀 측정 (cargo run + dump-pages + export-svg) + +#### Stage 2 — 차이 1: Fix 적용 + +- 첫 빈 char ("") 의 char_shape 를 layout 에서 skip 또는 spacing 무시 처리 +- 회귀 위험 점검: 다른 HWP3/HWP5/HWPX sample 의 paragraph 시작 x 변화 + +#### Stage 3 — 차이 1: 회귀 검증 + +- `cargo test --release --all-targets` +- HWP3/HWP5/HWPX sample 페이지 수 회귀 +- golden SVG 회귀 +- sample16 페이지 18 ◦ x 좌표 정합 확인 + +#### (별도 task 분리 권장) — 차이 2: WMF converter + +영역 매우 큼 + 회귀 점검 자료 부족. 별도 task 로 분리 진행. + +## 4. Base 결정 + +`local/task896` base = `local/task894 @ ce8d3ce` (PR #897 head). + +이유: +- 차이 1 진단의 일부는 Task #894 의 Stage 2 fix (`control_text_positions` fallback) 정합 후 결과 +- PR #897 머지 후 자동으로 본 PR diff 가 task896 commits 만으로 축소 + +CLAUDE.md 옵션 B + 같은 fork branch 위 base 패턴 — Task #894 의 task877_v2 위 분기 패턴과 동일 정합. + +## 5. 위험 평가 + +| 위험 | 영향 | 완화 | +|------|------|------| +| paragraph_layout 변경 → 모든 paragraph 영향 | 매우 높음 | 단계별 회귀 검증 (HWP3 + HWP5 + HWPX) | +| 첫 빈 char_shape 처리 변경의 다른 case 부작용 | 높음 | 빈 char 와 정상 char 의 구별 정밀 | +| sample16 외 다른 sample 의 ◦ x 좌표 차이 회귀 | 중 | 다양한 sample 의 paragraph 시작 x 측정 | +| PR #897 미머지 + 본 PR conflict | 중 | task894 base 정합 (PR #890 + PR #897 머지 시 자동 해소) | + +## 6. 작업지시자 결정 요청 + +1. **scope 선택**: + - (a) 차이 1 만 본 task — 차이 2 (WMF) 는 별도 task ⭐ 추천 + - (b) 차이 1 + 차이 2 모두 본 task + - (c) 차이 1 만, 차이 2 보류 + +2. **수행 계획서 자체 승인** → 구현 계획서 작성 진행 + +## 7. 산출물 예정 + +- 수행 계획서: 본 파일 +- 구현 계획서: `mydocs/plans/task_m100_896_impl.md` (다음 단계) +- Stage 보고서: `mydocs/working/task_m100_896_stage{N}.md` +- 최종 보고서: `mydocs/report/task_m100_896_report.md` diff --git a/mydocs/plans/task_m100_896_impl.md b/mydocs/plans/task_m100_896_impl.md new file mode 100644 index 000000000..5db614e32 --- /dev/null +++ b/mydocs/plans/task_m100_896_impl.md @@ -0,0 +1,149 @@ +# Task #896 구현 계획서 — Stage 1~5 + +**이슈**: [edwardkim/rhwp#896](https://github.com/edwardkim/rhwp/issues/896) +**수행 계획서**: [task_m100_896.md](task_m100_896.md) +**Scope (작업지시자 확정)**: **차이 1 + 차이 2 모두 본 task** + +## 진행 순서 + +1. **Stage 1** — 차이 1 (paragraph_layout ◦ x 좌표) 정밀 진단 +2. **Stage 2** — 차이 1 Fix + 회귀 검증 +3. **Stage 3** — 차이 2 (WMF 그림 안 텍스트 겹침) 정밀 진단 +4. **Stage 4** — 차이 2 Fix + 회귀 검증 +5. **Stage 5** — 통합 검증 + 최종 보고서 + +## Stage 1 — 차이 1 정밀 진단 (paragraph_layout) + +### 1.1 진단 절차 + +- [ ] paragraph 397 의 SVG x=107.3 의 정확한 산출 흐름 추적 +- [ ] paragraph 396 의 SVG x=117.81 과 비교 +- [ ] 첫 빈 char_shape (id=1117 spacing=0% char="") 가 paragraph 시작 x 에 미치는 영향 측정 +- [ ] paragraph_layout 의 paragraph 시작 x 계산 코드 위치 찾기: + - 후보 1: `src/renderer/layout/paragraph_layout.rs` 의 `effective_margin_left` 계산 + - 후보 2: `composer.rs` 의 line breaking + start x 계산 + - 후보 3: `text_measurement.rs` 의 첫 char_shape spacing 사용 + +### 1.2 가설 후보 + +| 가설 | 검증 방법 | +|------|----------| +| H1: paragraph_layout 이 모든 char_shapes 의 첫 entry 의 spacing 을 paragraph 시작 x 에 반영 | char_shape 의 spacing 변경 후 결과 변화 측정 | +| H2: 첫 빈 char ("") 의 font_size 가 line height 보정에 영향 → indent 변경 | corrected_line_height 흐름 추적 | +| H3: composer 의 line start 가 첫 char_shape 의 spacing 적용 | composer 코드 추적 | + +### 1.3 정밀 측정 도구 + +- `RHWP_TYPESET_DRIFT=1 cargo run --bin rhwp -- export-svg samples/hwp3-sample16.hwp -p 17` — paragraph 별 측정 +- 임시 디버그 print: `RHWP_PI397` 등 paragraph index 별 + +### 1.4 Stage 1 산출물 + +- `mydocs/working/task_m100_896_stage1.md` — 진단 결과 + 가설 검증 + fix 방향 + +## Stage 2 — 차이 1 Fix + 회귀 검증 + +### 2.1 Fix 적용 + +가설 검증 후 root cause 별 fix 적용: + +| 후보 | 처리 | +|------|------| +| H1 fix | 첫 char_shape 가 빈 char ("") 인 경우 spacing 무시 또는 char_shape skip | +| H2 fix | corrected_line_height 의 빈 char 처리 정합 | +| H3 fix | composer 의 line start 계산에서 빈 char 제외 | + +### 2.2 회귀 검증 + +- [ ] `cargo test --release --all-targets` 1355+ passed +- [ ] sample16 페이지 18 paragraph 397/398/399 의 ◦ x 좌표 정합 (paragraph 396 과 같은 x ≈ 117.8) +- [ ] HWP3 sample 6종 페이지 수 + golden SVG 회귀 +- [ ] HWP5/HWPX sample 회귀 점검 + +### 2.3 Stage 2 산출물 + +- `mydocs/working/task_m100_896_stage2.md` — Fix 결과 + 회귀 점검 + +## Stage 3 — 차이 2 정밀 진단 (WMF) + +### 3.1 진단 절차 + +- [ ] sample16 paragraph 394 의 picture (WMF `bin_id=3`) 추출 +- [ ] WMF binary 의 text record 분석 (`text_out`, `ext_text_out`) +- [ ] rhwp 의 WMF text rendering 결과 vs 한컴 viewer (PDF) 비교 +- [ ] 텍스트 겹침 위치 정확히 식별 (Windows 서버군, DMZ 등 영역) + +### 3.2 가설 후보 + +| 가설 | 검증 방법 | +|------|----------| +| H1: WMF text_out 의 x/y 좌표 변환 오류 | point_s_to_absolute_point 흐름 추적 | +| H2: set_text_align (top/baseline) 잘못 적용 | text_align 처리 코드 분석 | +| H3: 한컴 사적 WMF 확장 (font/clipping) | WMF binary record 분석 | +| H4: WMF text clipping 누락 → 다른 element 위에 그려짐 | WMF clip rect 처리 코드 | + +### 3.3 정밀 측정 도구 + +- WMF binary 직접 분석 (hex dump 또는 dump tool) +- WMF → SVG 출력 비교 (rhwp 결과 vs WMF reference renderer) + +### 3.4 Stage 3 산출물 + +- `mydocs/working/task_m100_896_stage3.md` — WMF 진단 결과 + fix 방향 + +## Stage 4 — 차이 2 Fix + 회귀 검증 + +### 4.1 Fix 적용 + +가설 검증 후 root cause 별 fix: + +| 후보 | 처리 영역 | +|------|---------| +| H1 fix | `src/wmf/converter/player.rs` 의 좌표 변환 | +| H2 fix | text_align 처리 정합 | +| H3 fix | 한컴 확장 fallback | +| H4 fix | clipping 정합 | + +### 4.2 회귀 검증 + +- [ ] sample16 페이지 18 의 WMF 그림 안 텍스트 정합 +- [ ] 다른 WMF 샘플 (sample10, sample14 등) 회귀 점검 +- [ ] `cargo test --release --all-targets` 회귀 없음 + +### 4.3 Stage 4 산출물 + +- `mydocs/working/task_m100_896_stage4.md` — WMF Fix 결과 + 회귀 점검 + +## Stage 5 — 통합 검증 + 최종 보고서 + +### 5.1 통합 검증 + +- [ ] `cargo test --release --all-targets` 1355+ passed +- [ ] HWP3 sample 6종 페이지 수 회귀 없음 +- [ ] HWP5/HWPX 주요 샘플 페이지 수 회귀 없음 +- [ ] golden SVG 회귀 없음 +- [ ] sample16 페이지 18 시각 정합: + - ◦ x 좌표 (paragraph 397/398/399) + - WMF 그림 안 텍스트 + +### 5.2 최종 보고서 + +- `mydocs/report/task_m100_896_report.md` + +### 5.3 PR 생성 + +- base: `devel`, head: `jangster77:local/task896` +- PR body 에 두 차이 모두 명시 +- `closes #896` + +## 위험 평가 + +| Stage | 위험 | 완화 | +|-------|------|------| +| 1, 2 | paragraph_layout 변경 → 모든 paragraph 영향 | HWP3/HWP5/HWPX sample 다수 회귀 점검 | +| 3, 4 | WMF 변환 변경 → 다른 WMF 샘플 회귀 | 다양한 WMF 샘플 시각 정합 비교 | +| 5 | 통합 영향 | 단계별 누적 검증 | + +## 의사결정 요청 + +본 구현 계획서 자체 승인. 승인 시 Stage 1 (차이 1 paragraph_layout 정밀 진단) 진행. diff --git a/mydocs/report/task_m100_877_report.md b/mydocs/report/task_m100_877_report.md new file mode 100644 index 000000000..2cc194781 --- /dev/null +++ b/mydocs/report/task_m100_877_report.md @@ -0,0 +1,161 @@ +# Task #877 최종 결과 보고서 — hwp3-sample16.hwp WASM 로드 실패 + 시각 정합 + +**이슈**: [edwardkim/rhwp#877](https://github.com/edwardkim/rhwp/issues/877) +**브랜치**: `local/task877_v2` (분기: `local/task873`, 19 commits) +**마일스톤**: v1.0.0 (M100) +**기간**: 2026-05-13 ~ 2026-05-14 + +## 개요 + +`samples/hwp3-sample16.hwp` (2.9 MB, 64쪽 RFP 문서, 한국수자원공사 2004.11) 를 rhwp-studio 에서 열면 panic 으로 로드 실패: + +``` +panicked at library/alloc/src/raw_vec/mod.rs:28:5: capacity overflow +[main] 파일 로드 실패: RuntimeError: unreachable +``` + +본 task 는 panic 차단부터 시작하여 시각 정합까지 단계적으로 해결. + +## 최종 결과 + +### sample16.hwp 의 변화 + +| 항목 | 초기 (panic) | Stage 1 후 | Stage 2 후 | Stage 4 최종 | 한컴 viewer | +|------|----------|---------|---------|---------|---------| +| Panic | ❌ 발생 | ✅ 차단 | ✅ | ✅ | (n/a) | +| 문단 수 | 0 | 77 | 1058 | 1058 | 1058 ✓ | +| 페이지 수 | (실패) | 28737 | 65 | 64 | 64 ✓ | +| 페이지 2 | (실패) | 빈 | 빈 | **목차** | 목차 ✓ | +| 표지 RFP 박스 | (실패) | ❌ | ❌ | **✅** | ✓ | +| 16쪽 본문 외곽선 | (실패) | ❌ | ❌ | **✅** | ✓ | +| Ⅰ~Ⅹ 로마숫자 | (실패) | ❌ | ❌ | **✅** | ✓ | +| 1단계 글머리 ○ | (실패) | ❌ | ❌ | **✅** | ✓ | +| 2단계 글머리 ◦ | (실패) | ❌ | ❌ | **✅** | ✓ | +| 16쪽 다이어그램 | (실패) | ❌ | ❌ | **✅** | ✓ | + +## Stage 별 작업 요약 + +### Stage 1 (b4a4c6b) — Allocation sanity check + +**문제**: HWP3 length-prefixed `vec![0u8; length]` 가 garbage length (0xDC000000 = 3.69 GB) 로 호출되어 32-bit WASM 의 `RawVec` capacity overflow panic. + +**수정**: +- `HWP3_MAX_RECORD_SIZE = 256 MB` cap +- `alloc_record_buf` / `check_record_count` 공통 helper +- 8개 위치 가드 (`Hwp3AdditionalInfoBlock`, picture `ext_buf`, drawing object 등) + +**결과**: WASM panic 차단, native 도 graceful Err 반환. + +### Stage 2 (ce04375) — Special char alignment (ch=5/6/7/8) + +**문제**: HWP3 paragraph 70 의 `ch=6 책갈피` record 가 spec 의 42 byte 가 아닌 8 byte 로 처리 → 후속 paragraph stream 34 byte 어긋남 → paragraph 71 부터 garbage cc/lc → 28737 페이지 오인식. + +**진단**: `mydocs/tech/한글문서파일구조3.0.md` §10.2~§10.4 spec 대조: +- ch=5 (필드코드): 가변 (8+n byte) — n byte 누락 +- ch=6 (책갈피): 42 byte — 34 byte 누락 +- ch=7 (날짜형식): 84 byte — 76 byte 누락 +- ch=8 (날짜코드): 96 byte — 88 byte 누락 + +**결과**: **77 → 1058 문단 / 28737 → 65 페이지** (한컴 HWP5 변환본 정확 일치). + +### Stage 3 (7f35fa3, b0bf58f, 2d737be, 9fbb798) — 시각 차이 4건 중 3건 + +| Commit | 내용 | +|--------|------| +| 7f35fa3 | HWP3 사적 인코딩 0x3590~0x3599 → Ⅰ~Ⅹ (U+2160~U+2169) 매핑 | +| b0bf58f | drawing line_style=0 + width>0 → Solid LineType 보강 | +| 2d737be | drawing object 모든 variant 의 treat_as_char 검사 (빈 페이지 2 해소: 65→64 페이지) | +| 9fbb798 | HWP3 0x3366 → PUA U+F03C5 (1단계 글머리) 매핑 | + +### Stage 4 (b647227, 5b70dfc, ab1fd83, acf3b09, 202cef9, 8008501, **00d6bba**, c8ba53b, 648c2cb) — 잔여 처리 + PDF 정합 + +| Commit | 내용 | +|--------|------| +| b647227 | HWP3 paragraph margins 패턴 기반 ◦ 글머리 자동 prefix 휴리스틱 (sample16 25개) | +| 5b70dfc | image magic detection 에 **WMF / EMF 추가** — 16쪽 다이어그램 표시 | +| ab1fd83 | 점선 (LineType 2~7) 가시성 최소 1.0 px 보강 | +| acf3b09 | PUA U+F03C5 → ○ (U+25CB) 변경 + picture ref_pos=0 위치 정합 | +| 202cef9 → 8008501 | fill_color high flag (0x10000000) 처리 — RGB=0+flag → 흰색 | +| **00d6bba** | **★ 근본 원인 ★** HWP3 drawing Fill.alpha = 0 (한컴 convention) — alpha=255 → opacity=0 완전 투명 회귀 해소 | +| **c8ba53b** | **HWP3 doc_info 페이지 외곽선 IR 변환** — PDF 정합 (모든 페이지 외곽 box) | +| 648c2cb | ◦ 글머리 휴리스틱 ls=145 확장 (paragraph 396/397/398/399 등 16쪽 본문) | + +#### Stage 4 의 핵심 발견 (00d6bba) + +SVG export 결과 `` — Rectangle 자체가 완전 투명. renderer 의 opacity 계산 ([renderer/layout/utils.rs:199](../../src/renderer/layout/utils.rs#L199)): + +```rust +opacity = 1.0 - alpha/255 // alpha=0 → opacity=1 (불투명), alpha=255 → 0 (투명) +``` + +이는 **한컴 convention** (`0=불투명`) 인데, HWP3 drawing 변환 시 표준 HWP5 convention (`255=불투명`) 으로 잘못 설정 → 부호 반대로 적용되어 완전 투명. Fill.alpha=0 으로 수정. + +**이전 모든 fix (외곽선 / 글머리 / WMF) 가 IR 단에서 정확했음에도 화면에 안 보였던 진짜 원인** — opacity=0 으로 모든 drawing object 가 invisible. + +## 검증 + +### cargo test +``` +total: passed: 1381, failed: 0 +``` + +### HWP3 sample 6종 회귀 없음 +| 샘플 | 문단 수 | 페이지 수 | +|------|--------|----------| +| hwp3-sample.hwp | 195 | 16 | +| hwp3-sample10.hwp | 26767 | 763 | +| hwp3-sample13.hwp | 71 | 3 | +| hwp3-sample14.hwp | 256 | 11 | +| hwp3-sample4.hwp | 1273 | 36 | +| hwp3-sample5.hwp | 1931 | 64 | + +### 신규 단위 테스트 +- `test_alloc_record_buf_overflow_returns_err` — overflow Err 반환 +- `test_alloc_record_buf_within_cap_ok` — 정상 범위 Ok +- `test_check_record_count_overflow_returns_err` — count 가드 +- `test_hwp3_sample16_load_alignment` — paragraph >= 1000 검증 + +## 변경 파일 + +### 소스 +- `src/parser/hwp3/mod.rs` — 가드 helper, ch=5/6/7/8 alignment, picture ref_pos, outline bullets 휴리스틱, WMF magic +- `src/parser/hwp3/records.rs` — InfoBlock / AdditionalInfoBlock 가드 +- `src/parser/hwp3/drawing.rs` — 가드, line_style 보강, fill_color flag, alpha convention +- `src/parser/hwp3/ole.rs` — OleInfo 가드 +- `src/parser/hwp3/johab.rs` — 로마숫자 + PUA → ○ 매핑 +- `src/renderer/layout/utils.rs` — 점선 가시성 보강 + +### 문서 +- `mydocs/plans/task_m100_877.md` — 수행 계획서 +- `mydocs/plans/task_m100_877_impl.md` — 구현 계획서 +- `mydocs/working/task_m100_877_stage{1,2,3,4}.md` — 단계별 보고서 +- `mydocs/tech/hwp3_paragraph_border_fill_analysis.md` — border_fill 분석 +- `mydocs/report/task_m100_877_report.md` — 본 최종 보고서 + +### 신규 sample / pdf +- `samples/hwp3-sample16.hwp` (2.9 MB) +- `samples/hwp3-sample16-hwp5.hwp` (3.0 MB) +- `samples/hwp3-sample16-hwp5.hwpx` (3.1 MB) +- `pdf/hwp3-sample16-hwp5-2022.pdf` (2.2 MB) + +## 잔여 / 후속 task + +### 본 task 범위 외 (별도 task) + +| 항목 | 영역 | 비고 | +|------|------|------| +| **HWP3 페이지 외곽선 좌표 기준 정합** | renderer | 목차 페이지 우측 페이지 번호가 외곽선 박스 밖 표시. page border `attr & 0x01` 가드 (paper vs body_area) 검토 필요 | +| **paragraph multi-line picture SVG 중복 emit** | renderer | paragraph 394 [1] 그림이 ls_count=3 마다 SVG image 3번 emit. typeset/svg renderer 영역 | +| **HWP5 변환본 페이지 수 inflate** | HWP5 파서 / pagination | `hwp3-sample16-hwp5.hwp` 가 rhwp 에서 98 페이지 (한컴 viewer 62 페이지) | +| **HWP3 alpha convention 통일** | model / renderer | drawing.rs 의 alpha=0 convention 이 한컴 사적. HWP5/HWPX 파서 통일 검토 | +| **HWP3 PUA chars 전반적 매핑** | parser/hwp3 | 0x3366 외 한컴 사적 PUA chars 추가 매핑 | +| **paragraph border_fill 자동 부여** | parser | 분석 결과 본 task 에서 불필요 (`mydocs/tech/hwp3_paragraph_border_fill_analysis.md`) | + +상세 분석: [mydocs/working/task_m100_877_residual.md](../working/task_m100_877_residual.md) + +## 결론 + +본 task 의 원래 목표 (WASM panic 차단 + paragraph alignment 정합) 를 넘어 **sample16 의 한컴 viewer 정합 시각 표시까지 완전 달성**: +- 문단 / 페이지 / 표지 박스 / 16쪽 본문 박스 / 글머리 / 다이어그램 모두 한컴 정합 + +특히 Stage 4 의 마지막 발견 (Fill.alpha convention 부호 반대) 이 이전 모든 fix 들의 효과를 가시화한 진짜 근본 원인. diff --git a/mydocs/report/task_m100_894_report.md b/mydocs/report/task_m100_894_report.md new file mode 100644 index 000000000..1af4a5afd --- /dev/null +++ b/mydocs/report/task_m100_894_report.md @@ -0,0 +1,169 @@ +# Task #894 최종 결과 보고서 — Task #877 잔존 통합 (3 stages + 2 분리 task) + +**이슈**: [edwardkim/rhwp#894](https://github.com/edwardkim/rhwp/issues/894) +**브랜치**: `local/task894` (base: `local/devel @ c2955b5`) +**마일스톤**: v1.0.0 (M100) +**선행 task**: #877 (closed via PR #890 — 메인테이너 머지 시점에 close) +**분리 task**: #895 (HWPX 페이지 수 inflate), #896 (sample16 페이지 18 추가 시각) +**기간**: 2026-05-14 + +## 1. 개요 + +[Task #877](https://github.com/edwardkim/rhwp/issues/877) (hwp3-sample16.hwp WASM 로드 + paragraph alignment + 시각 정합) 완료 후 분석된 잔존 3건 (HWP3 페이지 외곽선 좌표 / paragraph multi-line picture 중복 / HWP5 변환본 페이지 수 inflate) 의 통합 처리. + +## 2. Scope 변경 이력 + +| Scope | 변경 | 사유 | +|-------|------|------| +| 항목 C (HWP5 변환본 inflate) | 제거 | #877 진행 중 이미 해결 — HWP5 정합 62/62 | +| 항목 C' (HWPX 변환본 inflate 72→62) | 대체 | Stage 1 진단 중 발견 | +| 항목 D (CLAUDE.md c2955b5) | 추가 | PR #890 미포함, task894 PR 로 메인테이너 전달 | +| **Stage 1 (C')** | **#895 로 분리** | Root cause 가 한컴 HWPX 변환기 본질적 한계 (lineseg vpos 0 reset 누락) | +| Stage 4 (◦ x 좌표 + WMF 텍스트) | **#896 으로 분리** | 영역이 다름 (paragraph_layout / WMF converter) | + +## 3. 최종 결과 + +### 3.1 성과 요약 + +| Stage | 항목 | 결과 | 커밋 | +|-------|------|------|------| +| 1 (C') | HWPX self-closing run charPrIDRef 처리 | 정확성 보강 (페이지 수 inflate 는 #895 분리) | 55c6191 | +| **2 (B)** | **paragraph multi-line picture 중복 emit** | **image 3개 → 1개 ✅** | 5c177bd | +| **3 (A)** | **HWP3 페이지 외곽선 좌표** | **paper_based 정합, 페이지 번호 외곽선 안 ✅** | ddb7fa4 | +| D | CLAUDE.md 컨트리뷰터 워크플로우 보강 | base 자동 포함 | c2955b5 (devel) | + +### 3.2 sample16 페이지 18 시각 정합 변화 + +| 항목 | 이전 | 이후 | +|------|------|------| +| paragraph 394 picture (WMF 다이어그램) | SVG image 3개 (중복) | **1개 ✅** | +| 페이지 외곽선 박스 (paragraph 2) | 페이지 번호 밖 | **페이지 번호 안 ✅** | + +### 3.3 분리 task (잔존) + +| Issue | 내용 | 분리 사유 | +|-------|------|----------| +| [#895](https://github.com/edwardkim/rhwp/issues/895) | HWPX 변환본 페이지 수 inflate (72→62) | Root cause 한컴 HWPX 변환기 lineseg vpos 0 reset 누락. 광범위 영향 + 회귀 점검 자료 부족 | +| [#896](https://github.com/edwardkim/rhwp/issues/896) | sample16 페이지 18 ◦ x 좌표 차이 + WMF 그림 안 텍스트 겹침 | paragraph_layout / WMF converter 영역. 본 task scope 외 | + +## 4. Stage 별 결과 + +### 4.1 Stage 1 (항목 C') — HWPX 변환본 페이지 수 inflate + +#### Fix 1 (`55c6191`) — HWPX self-closing `` 처리 + +**진단**: 빈 paragraph 의 `` self-closing element 가 HWPX 파서 `Event::Empty` 분기에 `b"run"` 처리 누락 → 빈 paragraph 의 char_shape 가 default (id=0) 로 잘못 설정. + +**수정**: `src/parser/hwpx/section.rs` 의 `parse_paragraph` 의 `Event::Empty` 분기에 `b"run"` 처리 추가. paragraph 24 CharShape id: 0 → **42** 정확 인식. + +**결과**: +- 정확성 보강 ✅ +- sample16-hwp5.hwpx 페이지 수 (72) 미해결 — root cause 가 한컴 HWPX 변환기의 lineseg vpos 0 reset 누락 (별도 task #895) + +#### Root Cause 확정 (별도 task 분리) + +`typeset.rs:455~493` 의 vpos-reset trigger (`cv==0 && pv>5000`) 가 HWP5 의 페이지 break reset 신호를 사용. HWPX 의 lineseg vpos 는 누적값으로 reset 신호 손실 → trigger 발동 실패 → 페이지 break point 마다 1 페이지 누적 inflate. + +상세: [`mydocs/working/task_m100_894_stage1.md`](../working/task_m100_894_stage1.md) + +### 4.2 Stage 2 (항목 B) — paragraph multi-line picture SVG 중복 emit + +#### Fix (`5c177bd`) + +**진단**: paragraph 394 의 picture (WMF) 가 SVG 에 3 번 emit. ROOT CAUSE: HWP3 파서가 `char_offsets` 를 sequential `[0,1,2,3,4]` 만 push 하고 control marker 위치에 +8 gap 을 추가하지 않음 → `control_text_positions()` 의 갭 분석 실패 → 모든 control 이 fallback 으로 paragraph 끝으로 push → 마지막 line (`is_last_run=true`) 처리 시 3 control 모두 매치되어 picture 1개가 3 번 emit. + +**수정**: `src/model/paragraph.rs` 의 `control_text_positions()` 함수의 fallback 강화 — 갭 분석으로 발견되지 않은 control 의 위치를 text 의 `\u{FFFC}` marker 위치로 매핑. + +**결과**: SVG `` 3개 → **1개** ✅. cargo test 1234 passed, sample 회귀 없음. + +상세: [`mydocs/working/task_m100_894_stage2.md`](../working/task_m100_894_stage2.md) + +### 4.3 Stage 3 (항목 A) — HWP3 페이지 외곽선 좌표 정합 + +#### Fix (`ddb7fa4`) + +**진단**: Task #877 c8ba53b 의 `page_border_fill.attr=0` (body_based) 가 한컴 viewer PDF 출력과 불일치. 외곽선 박스가 paragraph 의 right margin 부근 텍스트 (페이지 번호) 를 포함하지 못함. + +**수정**: `src/parser/hwp3/mod.rs` 의 `page_border_fill.attr = 1` (paper_based). + +**결과**: +- 외곽선 x 범위: 80.3~713.4 → **18.93~774.77** px +- 페이지 번호 (x=728) 외곽선 안 ✅ +- cargo test --all-targets **1355 passed**, 회귀 없음 + +상세: [`mydocs/working/task_m100_894_stage3.md`](../working/task_m100_894_stage3.md) + +### 4.4 Stage 4 — sample16 페이지 18 추가 시각 차이 진단 + +작업지시자 추가 발견 (◦ 글머리 누락 + 그림 안 텍스트 겹침) 진단. 두 차이 모두 **본 task scope 외**: + +| 차이 | 영역 | 결정 | +|------|------|------| +| ◦ x 좌표 차이 (paragraph 397~399) | paragraph_layout (rhwp 렌더러) | #896 분리 | +| WMF 그림 안 텍스트 겹침 | WMF converter | #896 분리 | + +paragraph_layout 변경은 모든 paragraph 영향 (회귀 매우 높), WMF converter 는 별도 영역 + 회귀 점검 자료 부족. + +## 5. 검증 + +### 5.1 cargo test + +``` +cargo test --release --all-targets: 1355 passed, 0 failed +``` + +### 5.2 HWP3/HWPX/HWP5 sample 페이지 수 회귀 + +| 샘플 | 페이지 수 | 회귀 | +|------|---------|------| +| hwp3-sample.hwp | 16 | — | +| hwp3-sample4.hwp | 36 | 없음 | +| hwp3-sample5.hwp | 64 | 없음 | +| hwp3-sample10.hwp | 763 | 없음 | +| hwp3-sample13.hwp | 3 | 없음 | +| hwp3-sample14.hwp | 11 | 없음 | +| hwp3-sample16.hwp | 64 | 없음 | +| 모든 HWPX 샘플 (10종) | 동일 | 없음 | + +### 5.3 신규 검증 케이스 + +- sample16 페이지 18 paragraph 394 picture SVG `` 개수: 1개 (이전 3개) +- sample16 페이지 2 외곽선 박스 / 페이지 번호: 정합 + +## 6. 변경 파일 + +### 6.1 소스 + +- `src/parser/hwpx/section.rs` (+12 lines) — Stage 1 Fix 1 (self-closing run charPrIDRef) +- `src/model/paragraph.rs` (+23 lines, -2 lines) — Stage 2 Fix (control_text_positions fallback) +- `src/parser/hwp3/mod.rs` (+4 lines, -1 line) — Stage 3 Fix (page_border_fill paper_based) + +### 6.2 문서 + +- `mydocs/plans/task_m100_894.md` — 수행 계획서 +- `mydocs/plans/task_m100_894_impl.md` — 구현 계획서 +- `mydocs/working/task_m100_894_stage1.md` — Stage 1 진단 (Fix 1 + root cause 분리 결정) +- `mydocs/working/task_m100_894_stage2.md` — Stage 2 완료 (picture 중복 해소) +- `mydocs/working/task_m100_894_stage3.md` — Stage 3 완료 (page border paper_based) +- `mydocs/report/task_m100_894_report.md` — 본 최종 보고서 + +### 6.3 task 877 의 미포함 변경 (자동 포함) + +- `CLAUDE.md` (c2955b5) — 컨트리뷰터 워크플로우 + 실수 회피 가이드 보강 + +## 7. 잔존 (후속 task 권장) + +본 task 의 분리 사항: + +| Issue | 내용 | +|-------|------| +| [#895](https://github.com/edwardkim/rhwp/issues/895) | HWPX 변환본 lineseg vpos 페이지 break reset 누락 — 페이지 수 inflate | +| [#896](https://github.com/edwardkim/rhwp/issues/896) | sample16 페이지 18 추가 시각 — ◦ x 좌표 + WMF 텍스트 | + +## 8. 결론 + +본 task #894 의 핵심 목표 (Task #877 잔존 3건 처리) 중: +- **2/3 stages 완전 해소** (Stage 2 picture 중복, Stage 3 page border) +- **1/3 stage 정확성 보강 + 별도 task 분리** (Stage 1 의 self-closing run 처리 + 페이지 수 inflate root cause #895 분리) + +추가로 Stage 4 의 새 발견 (paragraph_layout / WMF) 도 #896 으로 분리. 본 PR 의 변경은 cargo test 1355 passed + sample 6종 회귀 없음으로 검증. diff --git a/mydocs/report/task_m100_896_report.md b/mydocs/report/task_m100_896_report.md new file mode 100644 index 000000000..d3b90788b --- /dev/null +++ b/mydocs/report/task_m100_896_report.md @@ -0,0 +1,142 @@ +# Task #896 최종 결과 보고서 — sample16 페이지 18 추가 시각 정합 + +**이슈**: [edwardkim/rhwp#896](https://github.com/edwardkim/rhwp/issues/896) +**브랜치**: `local/task896` (base: `local/task894 @ ce8d3ce`) +**선행 task**: #894 (PR #897), #877 (PR #890) +**기간**: 2026-05-14 + +## 1. 개요 + +Task #894 의 Stage 4 분리 task. sample16 페이지 18 의 추가 시각 정합 처리. + +## 2. 최종 결과 + +### 2.1 성과 요약 + +| Stage | 항목 | 결과 | +|-------|------|------| +| 1+2 | paragraph 398/399 ◦ 잘못 추가 → dash skip | ✅ 한컴 정합 | +| 3+4 | WMF font name 인코딩 + Korean fallback chain | ✅ 한글 글리프 표시 | +| 5/6 | HWP5/HWPX 페이지 수 inflate (skip — CLI 정합 / WASM 환경 별도) | ⏭️ skip | +| **7** | **HWPX `` 파싱 — upstream 4beb6b07 (Task #888) 가 동일 영역 별도 처리 → 본 task 에서 drop** | ⏭️ skip | +| 9 | WMF text positioning (한컴 사적 unit scale) → 별도 task #902 분리 | ⏭️ #902 | + +## 3. Stage 별 결과 + +### 3.1 Stage 1+2 — paragraph 398/399 ◦ 정합 + +#### Fix + +`src/parser/hwp3/mod.rs` 의 `apply_bullet_fixup_single` 에 dash skip 추가: + +```rust +let first_non_space = para.text.chars().find(|c| *c != ' ').unwrap_or(' '); +if first_non_space == '-' { return; } +``` + +#### 결과 + +| paragraph | 이전 | 이후 | +|-----------|------|------| +| 397 | ` ◦ 공사 주요업무...` | 유지 | +| 398 | ` ◦ - 하드웨어...` | ` - 하드웨어...` ✅ | +| 399 | ` ◦ - ORACLE...` | ` - ORACLE...` ✅ | + +PDF 정합 분석: 한컴 viewer 도 paragraph 398/399 의 ◦ 표시 안 함 (sub-item dash marker). + +상세: [`mydocs/working/task_m100_896_stage1.md`](../working/task_m100_896_stage1.md) + +### 3.2 Stage 3+4 — WMF font encoding ⭐ + +#### Fix 1 — `font.rs` charset 기반 primary + +```rust +if charset != CharacterSet::ANSI_CHARSET { + (as_charset, as_latin1) // multi-byte → as_charset primary +} else { + (as_latin1, as_charset) +} +``` + +#### Fix 2 — `util.rs` 한국어 fallback chain + +```rust +if has_korean { + font_family.extend([ + "Apple SD Gothic Neo", "Malgun Gothic", "Nanum Gothic", + "Noto Sans CJK KR", "sans-serif", + ]); +} +``` + +#### 결과 + +| 항목 | 이전 | 이후 | +|------|------|------| +| SVG font-family primary | `'±¼¸²Ã¼'` (깨진) | `'굴림체'` ✅ | +| 시스템 fallback chain | 없음 | Apple SD / Malgun / Nanum / Noto CJK | +| WMF 그림 안 한글 시각 | 모자이크 깨짐 | **정상 표시** ✅ | + +상세: [`mydocs/working/task_m100_896_stage3.md`](../working/task_m100_896_stage3.md) + +### 3.3 Stage 7 — HWPX 외곽선 (drop) + +본 task 진행 중 HWPX `` 파싱 추가 시도했으나 upstream/devel 의 신 commit `4beb6b07` (Task #888 hwpx hwp save) 가 동일 영역 별도로 처리. 중복 → 본 task 에서 drop. + +PR #904 conflict 발생 → close → 본 PR 재구성 (Stage 7 commit 제외). + +### 3.4 Stage 9 — WMF text positioning (별도 task #902 분리) + +WMF binary 분석 결과 한컴 사적 WMF unit scale (SETWINDOWEXT(56,72) + SETVIEWPORTEXT 미호출) 의 비표준 비례 → Task #860 의 viewBox 자동 확장 영향. Fix 영역 매우 큼 + 회귀 위험 매우 높음. + +→ **별도 task [#902](https://github.com/edwardkim/rhwp/issues/902)** 분리. + +## 4. 검증 + +### 4.1 cargo test + +``` +cargo test --release --all-targets: 1398 passed, 0 failed +``` + +### 4.2 sample 페이지 수 회귀 + +- HWP3 sample 6종 (sample/4/5/10/13/14/16) 동일 +- HWPX sample 동일 + +### 4.3 시각 정합 + +- sample16 paragraph 398/399 ◦ 표시 제거 ✅ (한컴 정합) +- paragraph 394 WMF 그림 안 한글 글리프 정상 표시 ✅ + +## 5. 변경 파일 + +### 5.1 소스 + +- `src/parser/hwp3/mod.rs` (+8) — Stage 1+2 +- `src/wmf/parser/objects/graphics/font.rs` (+11) — Stage 3+4 +- `src/wmf/converter/svg/util.rs` (+18, -2) — Stage 3+4 + +### 5.2 문서 + +- `mydocs/plans/task_m100_896.md` +- `mydocs/plans/task_m100_896_impl.md` +- `mydocs/working/task_m100_896_stage1.md` +- `mydocs/working/task_m100_896_stage3.md` +- `mydocs/report/task_m100_896_report.md` (본 파일) + +## 6. 분리 task + +| Issue | 내용 | +|-------|------| +| [#902](https://github.com/edwardkim/rhwp/issues/902) | WMF unit scale 정합 — 한컴 사적 WMF SetWindowExt 비표준 비례 | + +## 7. 결론 + +본 task #896 의 핵심 성과: +- ✅ paragraph 398/399 ◦ 정합 +- ✅ WMF 한글 글리프 표시 +- ⏭️ HWPX 외곽선 — upstream 4beb6b07 가 별도 처리 (drop) +- ⏭️ WMF text positioning → #902 분리 + +cargo test 1398 passed + sample 회귀 없음. diff --git a/mydocs/tech/hwp3_paragraph_border_fill_analysis.md b/mydocs/tech/hwp3_paragraph_border_fill_analysis.md new file mode 100644 index 000000000..071679f30 --- /dev/null +++ b/mydocs/tech/hwp3_paragraph_border_fill_analysis.md @@ -0,0 +1,113 @@ +# HWP3 paragraph border_fill 분석 — Task #877 Stage 4 jang진단 + +**작성일**: 2026-05-14 +**관련 task**: #877 sample16.hwp WASM 로드 정합 + +## 문제 정의 + +sample16.hwp 의 페이지 16 (한컴 viewer 기준) 본문 영역에 회색 점선 외곽선 박스가 표시되어야 하나, rhwp-studio 에서 일부 paragraph 의 외곽선이 누락됨. HWP5 변환본 (`hwp3-sample16-hwp5.hwp`) 에서는 정상 표시. + +## HWP3 vs HWP5 spec 비교 + +### HWP5 spec (한글문서파일형식 5.0 §표 43 문단 모양) + +paragraph 모양 record 에 다음 필드 존재: +- `테두리/배경 모양 ID (BorderFill ID)` — UINT16 +- `문단 테두리 왼쪽/오른쪽/위쪽/아래쪽 간격` — INT16 ×4 + +paragraph 의 외곽선 박스를 명시적 ID 로 참조 → renderer 가 paragraph 영역에 border 그림. + +### HWP3 spec (한글문서파일구조 3.0 §5 문단 모양) + +paragraph 모양 record: +- offset 180: `음영 비율` — byte (%) +- offset 181: `문단 테두리` — byte (0=없음, 1=있음) +- offset 182: `선 연결` — byte +- offset 183: `margin_top` — u16 + +HWP5 와 달리: +- **`BorderFill ID` 명시적 필드 없음** — 단일 boolean (border=0/1) +- 색상 / 점선 종류 / 두께 정보 없음 + +## sample16 실측 데이터 + +### paragraph 89 (본문 글머리 paragraph, 한컴 viewer 16쪽 영역) + +**HWP3 sample16.hwp**: +- `ParaShape.border = 0` (테두리 없음) +- `shade_ratio = 0` +- raw HWP3 자체에 외곽선 정보 부재 + +**HWP5 변환본 hwp3-sample16-hwp5.hwp**: +- `border_fill_id = 1` (테두리/배경 ID = 1) +- border_spacing left/right/top/bottom = 0 + +### paragraph 5 (표지 RFP 박스) + +paragraph 5 의 외곽선 박스는 **paragraph 자체의 border 가 아닌 별도 picture (ch=11 → ShapeObject::Rectangle drawing object)**: +- HWP3 raw line_color=0x000000 + width=84 HU + line_style=0x0000 +- Stage 3 v2 fix: line_style=0x0000 + width>0 → 0x0001 (Solid LineType) 보강 +- Stage 4 fix: ref_pos=0 (Text) → horz/vert_rel_to=Para (paragraph inline) + +즉 paragraph 5 처럼 **명시적 picture 로 외곽선 박스를 그리는 경우는 rhwp 가 정상 처리** (Stage 3+4 fix 후). + +### paragraph 393 (본문 영역 점선 박스) + +paragraph 393 도 picture (Rectangle drawing object) — paragraph border 가 아닌 별도 그림: +- raw line_color=0x000000 + width=56 HU + style=0x0002 (Dash 점선) +- Stage 4 점선 가시성 fix: width < 1.0 px → 1.0 px 보강 + +## 한컴 HWP5 변환기 휴리스틱 추정 + +HWP5 변환본의 1058 paragraph 전부 `border_fill_id > 0`. 분석: + +| 케이스 | HWP5 변환본 처리 | +|--------|----------------| +| 일반 paragraph | `border_fill_id = 1` (default, line_type=0 "선 없음") | +| 본문 영역 paragraph 그룹 | 같은 `border_fill_id` 부여하여 시각 박스 형성 | + +즉 한컴 변환기는 paragraph margins/indent 패턴 + 인접 paragraph 그룹화 분석하여 **자동으로 border_fill_id 를 부여**. 이는 **HWP3 raw 정보에서 직접 도출 불가능한 변환기 휴리스틱**. + +## rhwp 현 상태 분석 결과 + +본 task 의 fix 들로 sample16 의 외곽선 박스가 다음과 같이 처리됨: + +| 시각 외곽선 | rhwp 처리 | 상태 | +|----------|----------|------| +| 표지 RFP 박스 (paragraph 5 picture) | drawing object Rectangle, Stage 3 v2 fix | ✅ | +| 16쪽 본문 영역 점선 박스 (paragraph 393 picture) | drawing object Rectangle, Stage 4 점선 가시성 보강 | ✅ | +| 다이어그램 외곽선 (paragraph 394 표) | 표 (Table) | ✅ | + +**즉 sample16 의 실제 외곽선 박스들은 raw HWP3 의 picture/drawing object 로 표현**되며, Stage 3+4 의 fix 들로 모두 표시됨. + +HWP5 변환본의 paragraph border_fill_id 는 한컴 변환기가 default 부여한 추가 정보 (대부분 line_type=0 "선 없음") 이며, 실제 시각 외곽선은 별도 picture 객체로 그려짐. + +## 결론 + +1. **HWP3 paragraph 의 raw border 필드 (offset 181)** 는 binary 0/1. sample16 의 paragraph 89/91 = 0 → 외곽선 없음 (정합). + +2. **실제 시각 외곽선 박스**는 HWP3 raw 에서 별도 picture (ch=11 → ShapeObject::Rectangle drawing object) 로 표현. Stage 3+4 fix 로 정상 표시. + +3. **한컴 HWP5 변환본의 paragraph.border_fill_id** 는 변환기가 모든 paragraph 에 부여한 default 정보. 시각 외곽선과 직접 대응 안 됨. + +4. **추가 휴리스틱 부재 정당함**: paragraph margins 패턴 → border_fill 자동 부여 같은 휴리스틱은 sample 별 차이 크고 HWP3 spec 외 영역이라 회귀 위험 큼. **HWP3 의 raw picture/drawing object 처리가 정상화되면 시각 외곽선은 자연스럽게 표시**됨. + +## Task #877 잔여 시각 차이의 실체 + +사용자 screenshot 비교 결과 잔여 시각 차이의 원인: + +| 시각 차이 | 실체 | 본 task 해결 | +|----------|------|------------| +| 표지 RFP 박스 누락 | paragraph 5 picture 의 ref_pos=0 위치 기준 누락 | ✅ Stage 4 | +| 16쪽 점선 박스 가시성 | width=56 HU 점선 가시성 부족 | ✅ Stage 4 | +| paragraph 89 글머리 누락 | PUA U+F03C5 font fallback 부재 | ✅ Stage 4 (○ 매핑) | +| paragraph 91 ◦ 글머리 누락 | HWP3 raw 정보 부재 (한컴 변환기 휴리스틱) | ✅ Stage 4 (margins 패턴 휴리스틱) | +| 다이어그램 미표시 | WMF magic detection 누락 | ✅ Stage 4 | + +**paragraph border_fill 자체의 자동 부여 휴리스틱은 불필요** — 위 fix 들이 시각 외곽선의 실체 (picture/drawing object) 를 모두 정상화. + +## 변경 없음 + +본 분석 결과 paragraph border_fill 자동 부여 휴리스틱 도입 불필요. 이미 적용된 fix 들이 충분. + +향후 시각 차이 발견 시 paragraph border_fill 가 아닌 **HWP3 picture/drawing object 의 처리** 또는 **decode_hwp3_extra PUA 매핑** 영역에서 분석할 것. diff --git a/mydocs/working/task_m100_877_residual.md b/mydocs/working/task_m100_877_residual.md new file mode 100644 index 000000000..ac1510ed2 --- /dev/null +++ b/mydocs/working/task_m100_877_residual.md @@ -0,0 +1,62 @@ +# Task #877 잔존 문제 분석 — 별도 task 권장 + +**작성일**: 2026-05-14 +**관련 task**: #877 (sample16.hwp 정합) + +## 잔존 문제 3건 + +본 task 의 fix 22 commits 누적으로 sample16.hwp 의 핵심 시각 정합 (페이지 수, 외곽선, 글머리, 다이어그램 표시) 모두 해소. 그러나 추가 세부 시각 차이 3건 잔존 — 본 task 범위 (HWP3 파서) 외 또는 광범위 렌더러 영역. + +### 1. HWP3 페이지 외곽선 위치 — 본문 영역 vs page border 크기 불일치 + +**증상**: sample16 페이지 2 (목차) 의 목차 항목 끝 페이지 번호들 (1, 3, 5, 6, ...) 이 페이지 외곽선 박스 **밖에** 표시됨. 한컴 viewer (HWP5 변환본) 에서는 외곽선 안에 표시. + +**원인 추정**: +- 본 task 의 page border IR 변환 (c8ba53b) 에서 `border_margin*=355 hunit (5mm)` 을 `spacing_*` 으로 설정 +- 그러나 renderer ([renderer/layout.rs:748-776](../../src/renderer/layout.rs#L748-L776)) 의 `attr & 0x01` 가드: + - `paper_based = false` → body_area 기준 (= 페이지 여백 안) + - 그러면 외곽선 = body_area + spacing + - 그러나 paragraph 텍스트 (목차 우측 페이지 번호 영역) 가 body_area 의 width 를 초과하는 듯 +- 또는 한컴 viewer 의 page border 좌표가 paper_based 인데 rhwp 는 body_based 라 크기 다름 + +**영역**: rhwp renderer / layout (page border 좌표 기준). + +**별도 task 권장**. + +### 2. HWP3 paragraph 394 다이어그램 중복 emit (SVG 3 image) + +**증상**: sample16 페이지 18 (한컴 16쪽) 의 paragraph 394 [1] 그림 (WMF, bin_id=3) 이 SVG 에 **3개 `` 로 emit**됨 (모두 동일 href). 시각상 2~3개 다이어그램 중복. + +**dump 결과**: +- paragraph 394: ls_count=3, controls=3 ([0] 표 + [1] 그림 + [2] 표) +- text: " " (3 picture markers) + +**시도한 fix (효과 없음)**: +- treat_as_char picture wrap=Square → TopAndBottom 정합 (9e9d1bf / rebase 후 SHA): wrap 변경 후에도 image 3개 그대로 +- → wrap 영향 아님. paragraph 의 multi-line + picture-per-line 처리 영역 bug + +**영역**: rhwp 렌더러의 paragraph multi-line picture 처리 (typeset.rs / picture_footnote.rs / svg.rs). + +**별도 task 권장**. + +### 3. HWP5 변환본 페이지 수 inflate + +**증상**: `samples/hwp3-sample16-hwp5.hwp` (한컴 HWP3 → HWP5 변환본) 를 rhwp 가 **98 페이지**로 인식. 한컴 viewer 표시 = **62 페이지**. + +**관찰**: +- rhwp HWP3 sample16: 64 페이지 (한컴 정합) +- rhwp HWP5 변환본: 98 페이지 (한컴 62 페이지) +- HWP5 변환본은 외곽선 / 그림 / 글머리 등 시각 정합도 sample16 HWP3 보다 더 정확 + +**원인**: rhwp 의 **HWP5 파서 또는 pagination 영역**. 본 task #877 (HWP3 파서) 범위 외부. + +**별도 task 권장**. + +## 종합 + +본 task #877 의 핵심 목표 (HWP3 sample16 WASM panic 차단 + paragraph alignment + 핵심 시각 정합) 는 22 commits 로 완전 달성. 잔존 3건은 모두 본 task 범위 외 영역 (rhwp 렌더러 / HWP5 파서). + +각각 별도 issue 등록 후 task 진행 권장: +1. HWP3 페이지 외곽선 좌표 기준 정합 (renderer) +2. paragraph multi-line picture SVG image 중복 emit (renderer) +3. HWP5 변환본 페이지 수 inflate (HWP5 파서 또는 pagination) diff --git a/mydocs/working/task_m100_877_stage1.md b/mydocs/working/task_m100_877_stage1.md new file mode 100644 index 000000000..939cdda88 --- /dev/null +++ b/mydocs/working/task_m100_877_stage1.md @@ -0,0 +1,109 @@ +# Task #877 Stage 1 완료 보고서 — 방어성 가드 (allocation sanity check) + +**관련 계획서**: [task_m100_877_impl.md](../plans/task_m100_877_impl.md) +**브랜치**: `local/task877` + +## 작업 내용 + +### 1. 공통 helper 도입 ([src/parser/hwp3/mod.rs:46-76](../../src/parser/hwp3/mod.rs#L46-L76)) + +```rust +pub(crate) const HWP3_MAX_RECORD_SIZE: usize = 256 * 1024 * 1024; // 256 MB + +pub(crate) fn alloc_record_buf(length: usize) -> Result, io::Error> { ... } +pub(crate) fn check_record_count(count: usize) -> Result<(), io::Error> { ... } +``` + +- `alloc_record_buf`: `vec![0u8; length]` 직접 호출 대신 cap 검증 후 할당 +- `check_record_count`: `Vec::with_capacity` 인자 검증 (point/cell count 등 비-u8 element 용) +- cap 초과 시 `io::Error(InvalidData, "HWP3 record count overflow: ...")` 반환 + +### 2. 가드 적용 위치 + +| 파일 | 위치 | 수정 전 | 수정 후 | +|------|------|---------|---------| +| records.rs:392 | `Hwp3InfoBlock::read` (u16 length, max 64KB but 일관성) | `vec![0u8; length]` | `alloc_record_buf(length)?` | +| records.rs:413 | `Hwp3AdditionalInfoBlock::read` (**원래 panic 지점**, u32 length) | `vec![0u8; length]` | `alloc_record_buf(length)?` | +| mod.rs:694 | 표/textbox cell_buf (`27 × cell_count`) | `vec![0u8; ...]` + `break` | `alloc_record_buf(...)` match err → break | +| mod.rs:932 | picture(ch=11) `ext_buf` (n_ext from info_buf[0..4]) | `vec![0u8; n_ext]` + `break` | `alloc_record_buf(n_ext)` match err → break | +| drawing.rs:372 | `Hwp3DrawingTextBox::read` (info2_len) | `vec![0u8; info2_len]` | `alloc_record_buf(info2_len)?` | +| drawing.rs:346 | `Hwp3DrawingPolygon::read` (point_count u32, `Vec<[i32;2]>::with_capacity`) | `Vec::with_capacity(point_count)` | `check_record_count(point_count)?` 추가 | +| drawing.rs:399 | `Hwp3DrawingCurve::read` (동일) | 동일 | 동일 | +| drawing.rs:451-458 | `Hwp3DrawingExtendedPolygon::read` (point_count + line_attrs) | `vec![0u8; point_count]` + `Vec::with_capacity` | `check_record_count` + `alloc_record_buf` | +| drawing.rs:543-547 | drawing unknown object (info1_len/info2_len u32) | `vec![0u8; ...]` × 2 | `alloc_record_buf(...)?` × 2 | +| ole.rs:38 | `Hwp3OleInfo::read` (total_length - 4) | `vec![0u8; ...]` | `alloc_record_buf(...)?` | + +`ch=29` ([mod.rs:1143](../../src/parser/hwp3/mod.rs#L1143)) 의 기존 `< 1000000` 검증은 본 cap (256MB) 보다 엄격하므로 그대로 유지. + +### 3. 단위 테스트 추가 ([mod.rs:tests](../../src/parser/hwp3/mod.rs)) + +- `test_alloc_record_buf_overflow_returns_err`: `HWP3_MAX_RECORD_SIZE + 1`, `0xDC000000` (sample16 실측 garbage 값) → graceful Err +- `test_alloc_record_buf_within_cap_ok`: 정상 범위 (1024) → Ok +- `test_check_record_count_overflow_returns_err`: count 가드 검증 +- `test_hwp3_sample16_load_without_panic`: sample16 로드 시 panic 없음 (Ok/Err 무관, panic 검증이 본질) + +## 검증 결과 + +### 빌드 +``` +$ cargo build --release + Finished `release` profile [optimized] target(s) in 19.44s +``` + +### 단위 테스트 +``` +$ cargo test --release --lib parser::hwp3 +running 7 tests +... (7개 전부 ok) +test result: ok. 7 passed; 0 failed; 0 ignored +``` + +### 전체 cargo test +``` +$ cargo test --release +test result: ok. 1234 passed; 0 failed; 2 ignored (lib) ++ integration test 36개 묶음 전부 ok +``` + +### sample16 panic 사라짐 확인 +``` +$ cargo run --release --bin rhwp -- dump samples/hwp3-sample16.hwp +... (32093줄 정상 출력) +=== 완료: 1 구역, 77 문단 === +``` + +**Stage 1 이전 (panic/error)**: +``` +오류: HWP 파싱 실패 - 유효하지 않은 파일: HWP 3.0 오류: 입출력 오류가 발생했습니다: failed to fill whole buffer +``` + +**Stage 1 이후 (graceful 부분 파싱)**: 1 구역 / 77 문단 인식. panic 없음. + +> 단, sample16 의 64쪽 분량은 여전히 정확히 인식되지 않음 (현재 28737 페이지로 인식 — pagination 비정상). 이는 picture(ch=11) byte alignment 문제로 인한 paragraph stream misalign 의 후속 영향이며, **Stage 2 에서 해결할 본 task 의 근본 원인**. + +### 다른 HWP3 sample 회귀 없음 (Stage 1 전후 동일) + +| 샘플 | 문단 수 | +|------|---------| +| hwp3-sample.hwp | 195 | +| hwp3-sample10.hwp | 26767 | +| hwp3-sample13.hwp | 71 | +| hwp3-sample14.hwp | 256 | +| hwp3-sample4.hwp | 1273 | +| hwp3-sample5.hwp | 1931 | + +## 환경별 동작 (예상) + +- **네이티브 64-bit**: 기존엔 `vec![0u8; 3.69GB]` 후 read EOF Err. 이제는 더 빠른 시점에 cap 검증 Err. 결과 동일하나 cost 감소. +- **WASM 32-bit**: 기존엔 `RawVec capacity overflow` panic → `unreachable` trap. 이제는 graceful Err. + +## 변경 파일 + +- `src/parser/hwp3/mod.rs` — helper 도입 + ext_buf/cell_buf 가드 + 4개 unit test +- `src/parser/hwp3/records.rs` — InfoBlock/AdditionalInfoBlock 가드 +- `src/parser/hwp3/drawing.rs` — TextBox/Polygon/Curve/ExtendedPolygon/Unknown 가드 +- `src/parser/hwp3/ole.rs` — OleInfo 가드 + +## 다음 단계 + +Stage 2 (picture ch=11 byte alignment 정합) 진행 예정 — sample16 의 단일 paragraph 조기 종료 원인 해결 → 64쪽 전체 정합 파싱. diff --git a/mydocs/working/task_m100_877_stage2.md b/mydocs/working/task_m100_877_stage2.md new file mode 100644 index 000000000..82fed488f --- /dev/null +++ b/mydocs/working/task_m100_877_stage2.md @@ -0,0 +1,136 @@ +# Task #877 Stage 2 완료 보고서 — HWP3 special char alignment 정합 (ch=5/6/7/8) + +**관련 계획서**: [task_m100_877_impl.md](../plans/task_m100_877_impl.md) +**참조 spec**: [한글문서파일구조3.0.md](../tech/한글문서파일구조3.0.md) §10.1~§10.4 +**브랜치**: `local/task877` + +## 진단 — 28737 페이지 폭주 원인 + +### 1차 단서 (Stage 1 이후) +Stage 1 가드 적용 후 sample16 panic 은 사라졌으나, dump 시 **1 구역 / 77 문단**, dump-pages **28737 페이지** 로 폭주 인식. paragraph 71 부근부터 garbage cc/lc (cc=2560, lc=2602 등) 출현. + +### probe 분석 +`/tmp/pic_probe/` 외부 probe binary 로 sample16 의 decompressed body byte stream 정밀 추적: +- paragraph 70 (`@31904`, raw cc=20) text body 에 `ch=6` (HWP3 책갈피) control 포함 +- 현재 파서는 `ch=6` 을 미지 제어로 처리하여 **8 byte** 만 소비 (ch + dword + ch close) +- paragraph 70 끝 위치 32188 에서 paragraph 71 헤더 시도 → fp=73, cc=2560 (garbage) +- char_shape pattern `0906 0201 0101 6400 6464 6464 6464` (size + font_indices + ratios) 가 body offset **32236** 부터 등장 → para[71] 진짜 시작 = 32236 - 12 = **32224** +- 32224 - 32188 = **36 byte 추가 소비 필요** 확인 + +### spec 대조 (한글문서파일구조3.0 §10) + +| ch | 의미 | spec 총 byte | 현재 파서 | 차이 | +|----|-----|------------|---------|------| +| 5 | 필드 코드 | 가변 (8+n) | 8 | n bytes 누락 | +| **6** | **책갈피** | **42** | 8 | **+34 누락** | +| **7** | **날짜 형식** | **84** | 8 | **+76 누락** | +| **8** | **날짜 코드** | **96** | 8 | **+88 누락** | +| 18~21 | 번호코드 등 | 8 | 8 | ✓ | +| 22 | 메일머지 | 24 | 24 | ✓ | +| 23 | 글자겹침 | 10 | 10 | ✓ | +| 24, 25 | 하이픈, 차례 | 6 | 6 | ✓ | +| 26 | 찾아보기 | 246 | 246 | ✓ | +| 28 | 개요 | 64 | 64 | ✓ | +| 29 | 상호참조 | 가변 | 가변 (가드) | ✓ | +| 30, 31 | 묶음/고정폭빈칸 | 4 | 4 | ✓ | + +`ch=6/7/8/5` 만 spec 비정합. **sample16 의 핵심 트리거는 `ch=6 책갈피` (= "1. 추진목적" 등 본문 제목들에 부착된 책갈피)**. + +### 검증 +para[70] cc=20 의 hchar 구성 (spec 적용 후): +- 7 hchars text "1. 추진목적" → 14 bytes (32148-32161) +- 1 ch=6 책갈피 control: cc count += 4, 실제 42 bytes 소비 (32162-32203) +- 1 ch=19 새 번호: cc += 4, 8 bytes (32204-32211) +- 1 ch=20 쪽번호달기: cc += 4, 8 bytes (32212-32219) +- 1 ch=13 CR: cc += 1, 2 bytes (32220-32221) + +합: 7+4+4+4+1 = 20 cc ✓, byte 32148→32222. para[71] @ **32222** valid header (fp=0, cc=5, lc=1) ✓ + +## 작업 내용 + +### 1. 책갈피 (ch=6) 처리 추가 ([src/parser/hwp3/mod.rs](../../src/parser/hwp3/mod.rs#L1149-L1169)) + +기존 `_ =>` else 분기에 책갈피 명세에 따른 34 byte 추가 소비 + `Control::Field("Bookmark:이름:type=종류")` 형식으로 IR 등록. + +```rust +} else if ch == 6 { + // 책갈피 spec §10.2 표 36: 42 bytes total + let mut bookmark_extra = [0u8; 34]; + if let Err(_) = body_cursor.read_exact(&mut bookmark_extra) { break; } + let name = decode_hwp3_string(&bookmark_extra[0..32]).trim_end_matches('\0').to_string(); + let bookmark_type = (&bookmark_extra[32..34]).read_u16::().unwrap_or(0); + // → Control::Field { command: "Bookmark:{name}:type={type}", ... } +} +``` + +### 2. 날짜 형식/코드 (ch=7, ch=8) ([mod.rs:1183-1206](../../src/parser/hwp3/mod.rs#L1183)) + +spec 정합 byte 소비. sample16 은 사용 안 함이나 다른 HWP3 sample 회귀 대비. + +### 3. 필드 코드 (ch=5) ([mod.rs:1140-1148](../../src/parser/hwp3/mod.rs#L1140)) + +가변 길이 (8 + n bytes). header_val1 = n. 추가 n bytes 소비. Stage 1 의 `alloc_record_buf` 가드 통해. + +### 4. 단위 테스트 강화 + +`test_hwp3_sample16_load_alignment` — sample16 의 paragraph count >= 1000 검증 (Stage 1 만 적용 시 77, Stage 2 적용 후 1058). + +## 검증 결과 + +### sample16 (이슈 #877 대상) +| 항목 | Stage 1 만 | Stage 2 적용 후 | 한컴 viewer | 한컴 HWP5 변환본 | +|------|----------|--------------|----------|----------------| +| 문단 수 | 77 | **1058** | - | 1058 | +| 페이지 수 | 28737 | **65** | 64 | 62 | + +문단 수가 한컴 HWP5 변환본과 동일. 페이지 수 차이 (65 vs 64) 는 layout 미세 차이로 본 task 범위 밖. + +### 회귀 검증 (다른 HWP3 sample) +| 샘플 | 문단 수 | 페이지 수 | +|------|--------|----------| +| hwp3-sample.hwp | 195 (변동 없음) | 16 | +| hwp3-sample10.hwp | 26767 (변동 없음) | 763 | +| hwp3-sample13.hwp | 71 (변동 없음) | 3 | +| hwp3-sample14.hwp | 256 (변동 없음) | 11 | +| hwp3-sample4.hwp | 1273 (변동 없음) | 36 | +| hwp3-sample5.hwp | 1931 (변동 없음) | 64 | + +전부 Stage 1 직후 값과 동일. **회귀 없음** ✓ + +### cargo test +``` +test result: ok. 1234 passed; 0 failed; 2 ignored (lib) ++ integration tests 36개 묶음 전부 ok ++ test_hwp3_sample16_load_alignment ✓ +``` + +## 신규 파일 git 추가 + +작업지시자 요청 — sample16 관련 자료를 저장소에 영구 보존: +- `samples/hwp3-sample16.hwp` (2.94 MB) — HWP3 원본 +- `samples/hwp3-sample16-hwp5.hwp` (3.04 MB) — 한컴 HWP5 변환본 (정합 기준) +- `samples/hwp3-sample16-hwp5.hwpx` (3.06 MB) — 한컴 HWPX 변환본 +- `pdf/hwp3-sample16-hwp5-2022.pdf` (2.23 MB) — 한컴 2022 편집기 PDF 변환본 (시각 정답지) + +모두 50 MB 미만이라 일반 git 영역에 보존 (LFS 불필요). + +## 변경 파일 + +- `src/parser/hwp3/mod.rs`: + - `ch=5` (필드 코드) 가변 길이 처리 추가 + - `ch=6` (책갈피) 42 byte 처리 추가 + Control::Field 로 IR 등록 + - `ch=7` (날짜 형식) 84 byte 처리 추가 + - `ch=8` (날짜 코드) 96 byte 처리 추가 + - `test_hwp3_sample16_load_alignment` 추가 (paragraph 수 1000 이상 검증) +- `samples/hwp3-sample16*.hwp{,x}` git 추가 (3 파일) +- `pdf/hwp3-sample16-hwp5-2022.pdf` git 추가 + +## 잔여 / 향후 작업 + +- **외부 image 404 (rhwp-studio 콘솔)**: sample16 의 pic_type=2 (Embedded Image) 가 `external_path` 로 잘못 설정되어 studio 가 `/samples/E$$0001E.gif` 등을 fetch 시도 → 404. 본 이슈는 PR #869 (task864) 의 `pic_type == 0` 조건 fix 가 들어와야 해결. **본 task 범위 밖**. +- **페이지 수 65 vs 64**: 1쪽 차이는 layout 의 미세한 줄 높이/줄간격 차이. **이슈 #877 의 1차 목표는 panic 차단 + 합리적 페이지 수 인식이며 이미 달성됨.** +- ch=2/3/4/12/27 등 spec 미정의 control 의 정확한 처리: 추후 발견 시 별도 수정. + +## 다음 단계 + +Stage 3 (WASM panic hook + 통합 회귀 테스트) 진행. diff --git a/mydocs/working/task_m100_877_stage3.md b/mydocs/working/task_m100_877_stage3.md new file mode 100644 index 000000000..8a0d7a8b7 --- /dev/null +++ b/mydocs/working/task_m100_877_stage3.md @@ -0,0 +1,127 @@ +# Task #877 Stage 3 완료 보고서 — 시각 차이 4건 진단 + 3건 수정 + +**관련 계획서**: [task_m100_877_impl.md](../plans/task_m100_877_impl.md) +**참조 spec**: [한글문서파일구조3.0.md](../tech/한글문서파일구조3.0.md) +**브랜치**: `local/task877_v2` (분기: `local/task873`) + +## 배경 + +Stage 1+2 적용 후 sample16 panic 없음 + 65 페이지 인식. 그러나 한컴오피스 viewer 와 시각 차이 4건 발견: + +1. **표지 박스/외곽선 누락** +2. **빈 페이지 2 (목차 페이지 어긋남)** +3. **로마숫자 prefix (Ⅰ, Ⅱ, Ⅲ ...) 누락** +4. **16페이지 다이어그램 미표시** + +## 진단 결과 및 수정 + +### ✅ 1. 표지 박스/외곽선 누락 — **수정 완료** + +probe 결과: paragraph 5 의 RFP 박스는 **Rectangle drawing object (pic_type=3)**: +- HWP3 raw: `border line color=0x00000000, width=84, style=0x0000` (LineType=0 → 외곽선 미표시) +- HWP5 변환본: `style=0xc0010041` (LineType=1 Solid + cap + arrow 등) + +렌더러 [renderer/layout/utils.rs:163] 의 `border.attr & 0x3F == 0` 시 외곽선 미표시 규칙. + +**수정** ([src/parser/hwp3/drawing.rs:748-768](../../src/parser/hwp3/drawing.rs#L748-L768)): +```rust +attr: { + let raw_attr = header.basic_attr.line_style as u32; + if (raw_attr & 0x3F) == 0 && header.basic_attr.line_width > 0 { + raw_attr | 0x01 // bit 0..5 = 1 (Solid LineType) + } else { raw_attr } +} +``` +근거: HWP3 raw line_style=0 + line_width>0 + line_color 정상 = 한컴 viewer 실선 표시. + +### ✅ 2. 빈 페이지 2 (목차 페이지 어긋남) — **수정 완료** + +HWP3 vs HWP5 변환본 vpos 비교: +| paragraph | HWP3 vpos | HWP5 변환본 vpos | 차이 | +|-----------|----------|---------------|------| +| 0.5 (RFP 박스) | line_spacing=4768 | line_spacing=840 | 3928 | +| 0.23 | 63676 | 59748 | 3928 | +| 0.24 | 72360 | 68372 | 3988 | + +원인: paragraph 5 의 RFP 박스는 **Rectangle (treat_as_char=true)**. [mod.rs:1647] 의 `has_tac_picture` 검사가 Picture (image) 만 포함하고 **Rectangle / Ellipse / Polygon / Line / Arc / Curve / Group 누락**. 그 결과 line_spacing 이 `text_height (7948) × 60% = 4768 HU` 거대값으로 계산 → 후속 paragraph vpos 누적 3928~3988 HU 어긋남 → paragraph 24 vpos=72360 페이지 1 영역 (~74435 HU) 초과 → 빈 페이지 2. + +**수정** ([src/parser/hwp3/mod.rs:1647-1670](../../src/parser/hwp3/mod.rs#L1647-L1670)): +```rust +let has_tac_picture = para.controls.iter().any(|c| { + match c { + Control::Picture(p) => p.common.treat_as_char, + Control::Shape(s) => match s.as_ref() { + ShapeObject::Picture(p) => p.common.treat_as_char, + ShapeObject::Rectangle(r) => r.common.treat_as_char, + ShapeObject::Ellipse(e) => e.common.treat_as_char, + ShapeObject::Polygon(p) => p.common.treat_as_char, + ShapeObject::Line(l) => l.common.treat_as_char, + ShapeObject::Arc(a) => a.common.treat_as_char, + ShapeObject::Curve(c) => c.common.treat_as_char, + ShapeObject::Group(g) => g.common.treat_as_char, + _ => false, + }, + _ => false, + } +}); +``` + +결과: paragraph 5 ls 4768 → 600 정합 → 후속 vpos 누적 정합 → **페이지 수 65 → 64** (한컴 viewer 정확 일치). + +### ✅ 3. 로마숫자 (Ⅰ, Ⅱ, Ⅲ ...) 누락 — **수정 완료** + +HWP3 사적 인코딩 `0x3590~0x3599` = Unicode `U+2160~U+2169` (Ⅰ~Ⅹ) 매핑 부재. + +**수정** ([src/parser/hwp3/johab.rs:64-71](../../src/parser/hwp3/johab.rs#L64-L71)): +```rust +if (0x3590..=0x3599).contains(&ch) { + return char::from_u32(0x2160 + (ch - 0x3590) as u32); +} +``` + +### ❌ 4. 16페이지 다이어그램 — **본 task 범위 밖** + +사용자 확인: **HWP5 변환본도 동일 증상** — HWP3/HWP5 IR 은 동일 표현, **rhwp 의 drawing object tree 렌더러** 영역. +→ 별도 task 로 분리. + +## 최종 결과 (sample16) + +| 항목 | Stage 1 만 | Stage 2 후 | **Stage 3 최종** | 한컴 viewer | +|------|---------|---------|---------------|---------| +| Panic | ❌ 발생 | ✅ 없음 | ✅ 없음 | (n/a) | +| 문단 수 | 77 | 1058 | 1058 | (= HWP5 변환본) | +| 페이지 수 | 28737 | 65 | **64** | 64 ✓ | +| 페이지 2 | 빈 | 빈 | **목차** | 목차 ✓ | +| 표지 박스 | ❌ | ❌ | **✅** | ✓ | +| 로마숫자 Ⅰ~Ⅹ | ❌ | ❌ | **✅** | ✓ | +| 16쪽 다이어그램 | ❌ | ❌ | ❌ (HWP5 도 동일) | ✓ | + +## 검증 + +### cargo test +``` +test result: passed: 1381 failed: 0 +``` + +### HWP3 sample 6종 회귀 없음 +| 샘플 | 문단 수 | 페이지 수 | +|------|--------|----------| +| hwp3-sample.hwp | 195 | 16 | +| hwp3-sample10.hwp | 26767 | 763 | +| hwp3-sample14.hwp | 256 | 11 | +| hwp3-sample4.hwp | 1273 | 36 | +| hwp3-sample5.hwp | 1931 | 64 | + +## 변경 파일 (Stage 3 누적) + +- `src/parser/hwp3/johab.rs` — `decode_hwp3_extra` 에 Ⅰ~Ⅹ 로마숫자 매핑 추가 (커밋 7f35fa3) +- `src/parser/hwp3/drawing.rs` — drawing line_style=0 + width>0 → Solid LineType 보강 (커밋 b0bf58f) +- `src/parser/hwp3/mod.rs` — drawing object 모든 variant 의 treat_as_char 검사 (커밋 2d737be) + +## 후속 별도 이슈 (1건) + +- **HWP3/HWP5 drawing object tree (ch=11 pic_type=3) 렌더링 정합**: sample16 페이지 16 의 다이어그램. HWP5 변환본도 동일 미표시. rhwp 렌더러 영역. + +## 다음 단계 + +최종 결과 보고서 작성 → task #877 완료 → orders 갱신 → merge. diff --git a/mydocs/working/task_m100_877_stage4.md b/mydocs/working/task_m100_877_stage4.md new file mode 100644 index 000000000..bed8a4b32 --- /dev/null +++ b/mydocs/working/task_m100_877_stage4.md @@ -0,0 +1,100 @@ +# Task #877 Stage 4 완료 보고서 — 잔여 3건 추가 처리 + +**관련 계획서**: [task_m100_877_impl.md](../plans/task_m100_877_impl.md) +**참조 spec**: [한글문서파일구조3.0.md](../tech/한글문서파일구조3.0.md), [한글문서파일형식_5.0_revision1.3.md](../tech/한글문서파일형식_5.0_revision1.3.md) +**브랜치**: `local/task877_v2` + +## 배경 + +Stage 3 후 sample16 의 시각 차이 4건 중 1건 (로마숫자) + 2건 (외곽선, 빈 페이지) 처리. 잔여 3건 (◦ 글머리 / 본문 박스 외곽선 / 16쪽 다이어그램) Stage 4 에서 시도. + +## 진단 및 수정 + +### ✅ 1. ◦ 글머리 — **휴리스틱 도입 완료** + +**진단**: +- HWP5 spec §표 43 은 paragraph 의 `Bullet ID` 필드 보유 +- HWP3 spec §5 paragraph 모양은 `Bullet ID` 필드 부재. 한컴 변환기가 paragraph margins 패턴 분석하여 ◦ 자동 추가하는 휴리스틱. +- sample16 의 paragraph 91/100/110 raw 첫 char = ' ' (공백). HWP3 raw 에 ◦ 정보 자체 부재. + +**휴리스틱**: HWP3 ParaShape (L=6500, R=1000, I=-2500, ls=130) + 첫 char 공백 → paragraph text 첫 공백 다음에 "◦ " insert. + +**회귀 검증**: 다른 HWP3 sample (sample/sample10/sample14) 에서 패턴 매치 paragraph **0개** → 회귀 안전. + +**구현** ([src/parser/hwp3/mod.rs](../../src/parser/hwp3/mod.rs)): `fixup_hwp3_outline_bullets` 후처리 함수 추가. char_count / char_shapes.start_pos 동기화 갱신. + +**결과**: sample16 의 25개 paragraph 에 ◦ 자동 prefix. paragraph 91 = `" ◦ 주요업무에 대한 고가용성의 클러스터링(Clustering) 기술 도입"`. + +### ❌ 2. 본문 박스 외곽선 — **본 task 범위 외 (별도 task)** + +**진단**: +- HWP5 spec §표 43 은 paragraph 의 `BorderFill ID` 필드 보유 +- HWP3 spec §5 paragraph 모양 (offset 181) 의 `테두리` 는 1 byte boolean. sample16 의 paragraph 89/91 = `border=0` (raw 정보 부재) +- HWP5 변환본의 1058 paragraph 모두 `border_fill_id > 0` — 한컴 변환기가 default 값 (line_type=0 "선 없음") 부여 + 일부 paragraph 만 실제 시각 외곽선 + +**판단**: paragraph 별 border_fill 자동 부여 휴리스틱은 광범위 (한컴 viewer 의 HWP3 표시 알고리즘 reverse-engineering 필요) → 별도 task 분리. + +### ✅ 3. 16쪽 다이어그램 — **수정 완료** + +**진단**: +- 사용자 확인: HWP5 변환본도 동일 미표시 → rhwp 렉더러 영역 추정 +- 그러나 추가 분석 결과 **HWP3 파서의 image format detection 누락** +- paragraph 394 의 picture (bin_id=3, 161mm × 109mm) 의 binary data magic = `01 00 09 00 00 03` = **WMF (Windows Metafile)** +- HWP3 파서 [src/parser/hwp3/mod.rs:2198](../../src/parser/hwp3/mod.rs#L2198) 의 image format magic 검사가 JPG / PNG / GIF / BMP 만 지원. WMF / EMF 누락 → `ext="bin"` 으로 저장 → rhwp 렉더러가 미지원으로 처리 + +**수정**: +```rust +} else if img_data.starts_with(b"\xD7\xCD\xC6\x9A") + || img_data.starts_with(b"\x01\x00\x09\x00") +{ + "wmf" +} else if img_data.len() >= 44 + && img_data.starts_with(b"\x01\x00\x00\x00") + && &img_data[40..44] == b" EMF" +{ + "emf" +} +``` + +**결과**: sample16 의 bin_data id=3/5/7 의 image ext = "bin" → **"wmf"**. rhwp 의 `src/wmf/converter/svg` 모듈이 WMF → SVG 변환하여 다이어그램 표시. + +## 최종 sample16 결과 + +| 항목 | Stage 4 후 | +|------|----------| +| Panic | ✅ 없음 | +| 문단 수 | 1058 (한컴 정확 일치) | +| 페이지 수 | 64 (한컴 정확 일치) | +| 페이지 2 = 목차 | ✅ | +| 표지 RFP 박스 외곽선 | ✅ | +| Ⅰ~Ⅹ 로마숫자 | ✅ | +| paragraph 89/92 글머리 `󰏅` | ✅ | +| paragraph 91/100/110... ◦ 글머리 | ✅ (Stage 4 신규) | +| **16쪽 다이어그램 (WMF)** | ✅ (Stage 4 신규) | +| 본문 박스 외곽선 (paragraph border_fill) | ❌ 별도 task | + +## 검증 + +### cargo test +``` +total: passed: 1381, failed: 0 +``` + +### 다른 HWP3 sample 회귀 없음 +- hwp3-sample.hwp: 195 문단 +- hwp3-sample10.hwp: 26767 문단 +- hwp3-sample14.hwp: 256 문단 + +## 변경 파일 + +- `src/parser/hwp3/mod.rs`: + - `fixup_hwp3_outline_bullets` 함수 추가 (◦ 자동 prefix 휴리스틱) — 커밋 b647227 + - additional_info_blocks image magic 검사에 WMF / EMF 추가 — 커밋 5b70dfc + +## 후속 별도 이슈 (1건) + +- **HWP3 paragraph border_fill 자동 부여 휴리스틱**: HWP3 raw 의 paragraph margins/style 패턴 분석하여 본문 영역의 paragraph border 자동 부여. 광범위 reverse-engineering 작업. + +## 다음 단계 + +Stage 4 최종 + 최종 결과 보고서 → task #877 종료. diff --git a/mydocs/working/task_m100_894_stage1.md b/mydocs/working/task_m100_894_stage1.md new file mode 100644 index 000000000..a4c419c4e --- /dev/null +++ b/mydocs/working/task_m100_894_stage1.md @@ -0,0 +1,235 @@ +# Task #894 Stage 1 진단 보고서 — HWPX 변환본 페이지 수 정합 (72 → 62) + +**Stage**: 1 / 3 (항목 C') +**상태**: 옵션 (b) 깊이 진단 진행 — Fix 1 적용 후 페이지 수 미해결, 추가 root cause 발견. 작업 방향 결정 요청. + +## 1. 진단 진행 요약 (시간순) + +### 1.0 사전 측정 + +| 파일 | rhwp | 한컴 viewer | 차이 | +|------|------|-----------|------| +| `hwp3-sample16.hwp` (원본 HWP3) | 64 | 64 | 0 ✅ | +| `hwp3-sample16-hwp5.hwp` (HWP5 변환본) | 62 | 62 | 0 ✅ | +| `hwp3-sample16-hwp5.hwpx` (HWPX 변환본) | **72** | **62** | **+10 ❌** | + +### 1.1 ir-diff 카테고리 분석 + +| 항목 | 건수 | 패턴 | +|------|------|------| +| char_shapes count | 604 | 빈 paragraph: HWPX=0, HWP5=1 (HWPX 가 default char_shape 미생성) | +| line_segs count | 59 | PUA 글머리 paragraph: HWPX=1, HWP5=0 | +| cc / text | 39 | 미세 차이 | + +### 1.2 페이지별 누적 차이 측정 + +- HWPX 페이지당 누적 **+19.2px** (vs HWP5) × 60 페이지 ≈ +1152px ≈ **약 10 페이지 inflate** 와 일치 +- Paragraph count 동일 (1058) +- paragraph 별 height (h) 값 동일 (페이지 16 의 빈 paragraph h=4.0 양쪽 동일) +- **첫 divergence: 페이지 1** — HWPX pi=24 페이지 1 들어감, HWP5 pi=24 페이지 2 들어감 + +### 1.3 paragraph 24 정밀 비교 + +| 항목 | HWPX | HWP5 | +|------|------|------| +| CharShape id | **0** (default) | **42** (height=2400) | +| bold | false | true | +| line_seg vpos, lh | 68372, 2400 | 68372, 2400 | +| body_area h | 971.3 px (72847 HU) | 971.3 px | + +→ HWPX 파서가 빈 paragraph 의 `` self-closing element 를 읽지 못함. `parse_paragraph` 의 `Event::Empty` 분기에 `b"run"` 처리 누락. + +## 2. Fix 1 — HWPX run Empty 처리 (commit 55c6191) + +### 2.1 코드 변경 + +`src/parser/hwpx/section.rs` 의 `parse_paragraph` 에 `Event::Empty` 의 `b"run"` 분기 추가: + +```rust +b"run" => { + // self-closing 빈 run (예: ) + for attr in ce.attributes().flatten() { + if attr.key.as_ref() == b"charPrIDRef" { + current_char_shape_id = parse_u32(&attr); + } + } + let utf16_pos = calc_utf16_len_from_parts(&text_parts); + char_shape_changes.push((utf16_pos, current_char_shape_id)); +} +``` + +### 2.2 결과 + +- sample16-hwp5.hwpx paragraph 24 의 CharShape id: 0 → **42** (정확 인식) ✅ +- **그러나 페이지 수 변화 없음**: 72 → 72 (페이지 inflate 미해결) +- HWPX 회귀 없음 — 10종 모두 동일 페이지 수 +- `cargo test --lib`: 1234 passed + +### 2.3 분석 + +paragraph 24 의 `line_seg` (lh=2400) 가 양쪽 IR 에서 **이미 동일**. char_shape 만 갱신되었지 layout (line_seg vpos / lh) 은 변경 없음. 즉 페이지 inflate 의 진짜 root cause 는 line_seg 가 동일함에도 페이지 break 위치가 다른 다른 곳에 존재. + +## 3. 추가 발견된 root cause 후보 + +### 3.1 paragraph 23 picture cur=0×0 + +| 항목 | HWPX | HWP5 | +|------|------|------| +| picture cur | **0×0** (0.0×0.0mm) | 30704×6380 (108.3×22.5mm) | +| picture orig | 30704×6380 | 30704×6380 | +| line_seg lh | 8084 | 8084 | +| tac (treat_as_char) | true | true | + +→ HWPX 의 picture `cur` (current size) 값이 0×0 으로 파싱됨. layout 알고리즘이 picture height 를 line_seg 외에 추가로 사용한다면 이 차이가 페이지 break 영향. + +### 3.2 다른 잠재 후보 + +- 1058 paragraph 중 다양한 미세 차이 누적 (char_shapes count 604건, paragraph 23 처럼 picture cur 차이가 있을 수 있음) +- ParaShape 미세 차이 (bold 같은 비-height 속성) +- Section 의 page-setup metadata + +## 3.4 옵션 "더 깊이" — pagination 코드 trace ★ ROOT CAUSE 발견 ★ + +### 3.4.1 paragraph 24/25 의 lineseg vpos 비교 + +| pi | HWP5 first_vpos | HWPX first_vpos | 의미 | +|----|----------------|----------------|------| +| 23 | 59748 | 59748 | 동일 (페이지 1) | +| 24 | 68372 | 68372 | 동일 | +| **25** | **0** ⭐ | **72212** ❌ | **HWP5 가 페이지 break 시 0 reset** | +| 26 | 3840 (페이지 2 내부) | 76052 (누적) | HWPX 누적 | + +### 3.4.2 typeset 의 vpos-reset trigger (typeset.rs:455~493) + +```rust +let trigger = if st.col_count > 1 { + ... +} else { + cv == 0 && pv > 5000 // 단일 단 +}; +if trigger { + st.advance_column_or_new_page(); // 강제 페이지 break +} +``` + +- **HWP5 pi=25**: cv=0, pv=68372 > 5000 → trigger! → 페이지 break → pi=25 부터 페이지 2 시작 +- **HWPX pi=25**: cv=72212, pv=68372 → trigger=false → 페이지 break 없음 → pi=24/25 모두 페이지 1 + +### 3.4.3 결정적 root cause + +**HWPX 파서가 한컴 HWPX 변환기의 lineseg vpos 누적값을 그대로 받음**: +- HWP5 변환본의 lineseg vpos: 페이지 break 시 0 으로 reset (한컴 HWP5 변환기 동작) +- HWPX 변환본의 lineseg vpos: **문서 전체 누적값** (한컴 HWPX 변환기 동작) +- rhwp 의 typeset 의 vpos-reset trigger 가 HWP5 의 0 reset 신호를 페이지 break 신호로 사용 +- HWPX 에서는 이 신호 없음 → 페이지 break 의도 인식 실패 → 더 많은 paragraph 가 페이지에 들어감 → 누적 차이 → 72 페이지 inflate + +### 3.4.4 검증 — 매 페이지에서 동일 패턴 + +페이지 break 가 발생할 때마다 (`cv==0 && pv>5000`) HWP5 에서만 trigger 발동: + +``` +HWP5: pi=25 cv=0 pv=68372 → trigger 발동 → 페이지 break → 페이지 2 시작 (cur_h=0) +HWPX: pi=25 cv=72212 pv=68372 → trigger 발동 안 됨 → 페이지 1 에 들어감 (cur_h=962.8) +``` + +페이지 break point 마다 1 page 누적 → 60 페이지 × 1 page break 미발동 = 약 10 페이지 차이 (실측 +10 과 일치) + +## 3.5 Fix 방향 옵션 (root cause 발견 후) + +| 옵션 | 처리 방식 | 영향 | 회귀 위험 | +|------|----------|------|----------| +| α | HWPX 파서가 lineseg vpos 의 페이지 break 지점 추정 후 0 reset 정규화 | HWPX 파서 변경 | 중 — paragraph 간 vpos jump 분석 알고리즘 필요. 다른 HWPX 영향 | +| β | typeset 의 vpos-reset trigger 조건 확장 — 누적 vpos 도 처리 | typeset 알고리즘 변경 | 매우 높 — 모든 파일 포맷에 영향 | +| γ | HWPX 파서가 lineseg vpos 모두 paragraph 내부 좌표로 정규화 | HWPX 파서 변경 — 모든 paragraph_first_vpos 빼기 | 높 — vpos_overflow 등 다른 vpos 기반 분기 모두 비활성 | +| δ | **현재 발견까지 정리 + Stage 1 별도 task 분리** — fix 는 별도 task 에서 ★ 추천 ★ | 본 task 에 Fix 1 만 + root cause 분석 보존 | 낮 | + +### 3.5.1 옵션 α 정밀 분석 (구현 시도 가치 있는 후보) + +알고리즘: +1. HWPX 파서가 모든 paragraph parse 후 후처리 +2. paragraph 간 vpos jump 측정: + - `jump = curr_para.first_lineseg.vpos - prev_para.last_lineseg.vpos` + - `expected_jump = prev_para.last_lineseg.line_spacing` (= 정상 paragraph 간 spacing) +3. jump 가 expected_jump 와 다르면 페이지 break 신호 의심 — 다만 sample16-hwp5.hwpx 의 pi=24 → pi=25 jump = 3840 (line_spacing 정확) → **jump 만으로 페이지 break 추정 불가** +4. 대안: 누적 vpos 가 body_height 의 정수 배수에 가까운 paragraph 를 page break 후보로 추정 — 부정확 + +**옵션 α 도 정확한 알고리즘 어려움** — 한컴 HWPX 변환기가 lineseg vpos 에 페이지 break 정보를 완전히 잃어버렸기 때문. + +## 4. 최종 결론 + +옵션 "더 깊이" 진행 결과: + +- ✅ **ROOT CAUSE 명확 확정**: HWPX 변환본의 lineseg vpos 가 페이지 break 시 0 reset 안 됨 (한컴 HWPX 변환기 산물) +- ❌ **Fix 가 매우 어려움**: 페이지 break 의도 정보가 HWPX XML 에서 손실. 어떤 fix 도 휴리스틱 또는 광범위 영향. +- ✅ **회귀 점검 자료 부족**: 다른 HWPX 샘플 한컴 viewer 정답지 없음. Fix 후 회귀 검증 불가. + +### 4.1 추천: **옵션 δ — Stage 1 별도 task 분리** + +이유: +- root cause 가 한컴 HWPX 변환기의 본질적 한계 (vpos reset 정보 손실) +- 어떤 fix 도 휴리스틱 — 다른 HWPX 샘플 회귀 점검 자료 부족 +- 본 task #894 의 다른 stage (B, A, D) 가 sample16 시각 정합에 더 직접적 영향 +- **별도 task 에서 HWPX 변환본 전반 정합성 종합 검토 후 결정** 권장 + +### 4.2 본 stage 의 산출물 + +- Fix 1 (`55c6191`): HWPX self-closing run charPrIDRef 처리 추가 — 정확성 보강 (유지) +- 진단 결과: ROOT CAUSE 명확 문서화 (본 보고서) +- 임시 디버그 코드 제거 완료 + +## 3.3 옵션 (ii) 종합 진단 결과 + +### 3.3.1 picture cur 차이 검증 + +- HWPX 의 picture `` 가 0 으로 설정됨 (한컴 변환기 산물) +- HWPX 파서가 `` 로 common.width/height 정상 설정 (30704×6380), `` 는 `shape_attr.current_width/height` 만 0 으로 설정 +- layout / pagination 코드는 `common.width/height` 만 사용, `shape_attr.current_*` 는 composer.rs/object_ops.rs 에서만 사용 (renderer 외부) +- → **picture cur 차이는 layout 영향 없음**. root cause 아님 + +### 3.3.2 paragraph 23 / 24 IR diff 정밀 분석 + +ir-diff 결과 (paragraph 23, paragraph 24 모두): +- TD (TabDef) 의 pos 값 차이만 존재 (4294707008 = -262144 unsigned, HWP5 가 음수 위치 사용) +- text, char_count, char_offsets, char_shapes, line_segs, controls, tab_extended, ParaShape 모두 동일 +- → **ir-diff 비교 범위 내에서는 paragraph 23/24 자체 IR 차이 없음** + +### 3.3.3 종합 결론 + +옵션 (ii) "모든 잠재 후보 종합 진단" 결과: + +- ✅ paragraph 별 IR 비교 (ir-diff 범위 내) 차이 없음 (TD 제외) +- ✅ line_seg vpos / lh 등 layout 결정 요소 동일 +- ❌ 그럼에도 페이지 break 위치 다름 + +→ **root cause 가 ir-diff 비교 항목 외부에 있거나, pagination 알고리즘 자체에 입력 외 분기가 있을 가능성**. 단일 fix 로 해결 불가능. 추가 정밀 분석 (pagination 코드 trace + 모든 paragraph attribute 비교 + section/document metadata 비교) 필요하며, 그 분석 자체가 본 task 규모 초과. + +## 4. 작업 방향 결정 요청 (재요청) + +옵션 (b) → (ii) 진단 결과: + +- ✅ **Fix 1 적용**: HWPX 파서 누락 case (run Empty) 처리 — 정확성 보강 +- ❌ **페이지 inflate 미해결**: 72 → 72 (Fix 1 만으로는 부족) +- ⚠️ **picture cur 후보 검증**: shape_attr.current_* 가 layout 미사용 → root cause 아님 +- ⚠️ **paragraph IR 자체 차이 없음**: ir-diff 비교 범위 내에서 paragraph 23/24 동일 → root cause 가 비교 범위 외부 또는 pagination 알고리즘 분기 + +### 4.1 다음 옵션 + +| 옵션 | 처리 | 비고 | +|------|------|------| +| (i) | picture cur 처리 fix 시도 → 효과 측정 | 단일 후보. 효과 없으면 다음 후보 | +| (ii) | 모든 잠재 후보 망라 진단 후 종합 fix | 시간 매우 큼. 회귀 위험 누적 | +| (iii) | **Stage 1 별도 task 분리** — #894 는 Fix 1 만 유지 + Stage 2/3/D 진행 | 분리된 별도 task 에서 HWPX 전반 정합 종합 | +| (iv) | Fix 1 revert, Stage 1 완전 보류 | scope 최소화 | + +### 4.2 추천 + +**옵션 (iii) Stage 1 별도 task 분리**: +- Fix 1 (run Empty) 은 정확성 보강이므로 #894 에 유지 +- 페이지 수 정합은 별도 task 에서 종합 진단 (HWPX picture / paragraph metadata 전반) +- Stage 2 (multi-line picture 중복) / Stage 3 (page border 좌표) 가 sample16 의 시각 정합에 더 직접적인 영향 + +## 5. 산출물 + +- Fix 1 commit: `55c6191` — HWPX self-closing run 의 charPrIDRef 처리 +- 본 보고서: `mydocs/working/task_m100_894_stage1.md` +- ir-diff: `/tmp/ir_diff_hwpx_vs_hwp5.txt` (2076 lines, 753 건 차이) diff --git a/mydocs/working/task_m100_894_stage2.md b/mydocs/working/task_m100_894_stage2.md new file mode 100644 index 000000000..940ed8553 --- /dev/null +++ b/mydocs/working/task_m100_894_stage2.md @@ -0,0 +1,117 @@ +# Task #894 Stage 2 완료 보고서 — paragraph multi-line picture SVG 중복 emit + +**Stage**: 2 / 3 (항목 B) +**상태**: ✅ 완료 + +## 1. 문제 + +sample16 페이지 18 (한컴 16쪽) 의 paragraph 394 [1] 그림 (WMF 다이어그램, bin_id=3) 이 SVG 에 **3개 `` 로 emit** 됨 (모두 동일 href). 한컴 viewer 는 1개만 표시. + +## 2. 진단 + +### 2.1 paragraph 394 구조 + +- text: `" "` (5 chars, `\u{FFFC}` marker at chars 0, 1, 4) +- controls 3개: [0] 표, [1] 그림 (WMF), [2] 표 +- line_segs 3개 + +### 2.2 SVG image 위치 측정 + +3 image 모두 width=608.48px (= 161.0mm = picture [1] 의 너비). 위치만 다름. 즉 picture [1] 만 3 번 emit, 표 2 개는 별도 element. + +### 2.3 ROOT CAUSE 추적 + +`src/renderer/layout/paragraph_layout.rs:1555` 의 `run_tacs` 필터: + +```rust +let run_tacs: Vec<(usize, f64, usize)> = tac_offsets_px.iter() + .filter(|(pos, _, _)| *pos >= run_char_pos && (*pos < run_char_end || (is_last_run && *pos == run_char_end))) + .map(|(pos, w, ci)| (pos - run_char_pos, *w, *ci)) + .collect(); +``` + +디버그 결과: +``` +[DBG394] tac_offsets_px=[(5, 336.21, 0), (5, 608.48, 1), (5, 177.17, 2)] +[DBG394] composed.tac_controls=[(5, 25216, 0), (5, 45636, 1), (5, 13288, 2)] + +line_idx=0 run_char_pos=0 run_char_end=1 → run_tacs=[] +line_idx=1 run_char_pos=1 run_char_end=4 → run_tacs=[] +line_idx=2 run_char_pos=4 run_char_end=5 is_last_run=true → run_tacs=[3 entries] +``` + +→ **모든 tac control 의 pos 가 5** (paragraph 끝). 실제로는 0, 1, 4 가 정답. line[2] (is_last_run=true) 처리 시 3 control 모두 매치되어 picture 1개가 3번 emit. + +### 2.4 ROOT CAUSE 함수 추적 + +`composer.rs:122` → `find_control_text_positions(para)` → `paragraph.rs:773 control_text_positions()`: + +paragraph 394 의 `char_offsets=[0, 1, 2, 3, 4]` — sequential, gap 분석 결과 control 발견 없음. 모든 3 controls 가 fallback 분기 (line 841): + +```rust +while positions.len() < total_controls { + positions.push(chars.len()); // = 5 +} +``` + +으로 paragraph 끝 (5) 으로 push. + +### 2.5 HWP3 파서의 char_offsets 생성 + +`src/parser/hwp3/mod.rs:330~520` — HWP3 파서가 각 char 마다 `char_offsets.push(utf16_len)` + `utf16_len += 1`. **control marker 위치에 +8 gap 추가하지 않음** → HWP5 spec 의 char_offsets 형태와 다름. + +## 3. Fix + +`src/model/paragraph.rs` 의 `control_text_positions()` 함수의 fallback 분기를 강화: + +```rust +// 갭 분석으로 발견되지 않은 컨트롤의 위치를 text 의 `\u{FFFC}` marker +// 위치로 매핑한다. +let mut search_start = positions.last().copied().unwrap_or(0); +while positions.len() < total_controls { + let next_marker = chars[search_start..].iter() + .position(|&c| c == '\u{FFFC}') + .map(|rel| search_start + rel); + match next_marker { + Some(abs_pos) => { + positions.push(abs_pos); + search_start = abs_pos + 1; + } + None => { + positions.push(chars.len()); // 기존 동작 + } + } +} +``` + +**핵심**: 갭 분석으로 채워진 positions 의 마지막 인덱스 이후를 search start 로 사용하여 중복 매핑 방지. HWP5/HWPX 의 정상 갭 분석 결과는 그대로 사용 → 비-HWP3 paragraph 영향 없음. + +## 4. 검증 + +### 4.1 sample16 paragraph 394 image emit + +| 항목 | 이전 | 이후 | +|------|------|------| +| `` 개수 | 3 | **1 ✅** | +| 위치 정합 | 잘못된 3 위치 | 정확한 line[1] 위치 | + +### 4.2 회귀 점검 + +| 항목 | 결과 | +|------|------| +| `cargo test --lib` | 1234 passed (회귀 없음) | +| HWP3 sample 6종 페이지 수 | 모두 동일 (`hwp3-sample` 16, 4, 5, 10, 13, 14) | +| HWPX sample 페이지 수 | 모두 동일 (sample16-hwp5.hwpx 72 유지 — #895 별도) | + +## 5. 커밋 + +- `5c177bd` — Task #894 Stage 2: control_text_positions fallback 강화 — text marker 스캔 + +## 6. 산출물 + +- 본 Stage 보고서: `mydocs/working/task_m100_894_stage2.md` +- Fix: `src/model/paragraph.rs` (+23 lines, -2 lines) + +## 7. 후속 영향 (참고) + +HWP3 파서의 char_offsets 가 HWP5 spec 형태 (control marker +8 gap) 가 아닌 sequential 만 push 하는 것은 별도 정합 task 가치 있음. 본 fix 는 `control_text_positions` 의 fallback 강화로 호환성 보장. 추후 HWP3 파서 자체 수정 시 본 fallback 도 활용 가능 (이중 안전). diff --git a/mydocs/working/task_m100_894_stage3.md b/mydocs/working/task_m100_894_stage3.md new file mode 100644 index 000000000..f23d5103e --- /dev/null +++ b/mydocs/working/task_m100_894_stage3.md @@ -0,0 +1,86 @@ +# Task #894 Stage 3 완료 보고서 — HWP3 페이지 외곽선 좌표 기준 정합 + +**Stage**: 3 / 3 (항목 A) +**상태**: ✅ 완료 + +## 1. 문제 + +sample16 페이지 2 (목차) 의 우측 페이지 번호 (1, 3, 5, 6, ...) 가 페이지 외곽선 박스 **밖에** 표시. 한컴 viewer (HWP5 변환본 PDF) 는 외곽선 안에 표시. + +## 2. 진단 + +### 2.1 기존 IR 변환 (Task #877 c8ba53b) + +`src/parser/hwp3/mod.rs:2361`: +```rust +section_def.page_border_fill = crate::model::page::PageBorderFill { + attr: 0, // body_based + spacing_left: (doc_info.border_margin_left as i16) * 4, // 355 × 4 = 1420 HU ≈ 23.6 px (5 mm) + ... +}; +``` + +### 2.2 renderer 의 좌표 계산 (layout.rs:732~764) + +```rust +let paper_based = (pbf.attr & 0x01) != 0; +let (base_x, base_y, base_w, base_h) = if paper_based { + (0.0, 0.0, layout.page_width, layout.page_height) +} else { + (layout.body_area.x, layout.body_area.y, layout.body_area.width, layout.body_area.height) +}; +// border = base + spacing +``` + +### 2.3 좌표 측정 + +| 항목 | body_based (attr=0) | paper_based (attr=1) | 한컴 viewer | +|------|--------------------|--------------------|------------| +| 외곽선 x (좌, 우) | 80.3 ~ 713.4 px | **18.93 ~ 774.77 px** | (paper_based 정합) | +| 페이지 번호 x | 728.0 px | 728.0 px | — | +| 페이지 번호 위치 | **외곽선 밖** ❌ | **외곽선 안** ✅ | 외곽선 안 | + +→ **paper_based (attr=1) 가 한컴 정합**. + +## 3. Fix + +`src/parser/hwp3/mod.rs:2361~2370`: + +```rust +// attr bit 0 = paper_based (1) vs body_based (0). +// HWP3 spec 명시 없으나 한컴 viewer 의 PDF 출력 정합 비교 결과 paper_based 가 정답. +section_def.page_border_fill = crate::model::page::PageBorderFill { + attr: 1, + spacing_left: (doc_info.border_margin_left as i16) * 4, + spacing_right: (doc_info.border_margin_right as i16) * 4, + spacing_top: (doc_info.border_margin_top as i16) * 4, + spacing_bottom: (doc_info.border_margin_bottom as i16) * 4, + border_fill_id: bfid, +}; +``` + +## 4. 검증 + +### 4.1 sample16 페이지 2 정합 + +| 항목 | 결과 | +|------|------| +| 외곽선 박스 (x 범위) | 18.93 ~ 774.77 px ✅ | +| 페이지 번호 (x=728.0) | 외곽선 안 ✅ | + +### 4.2 회귀 점검 + +| 항목 | 결과 | +|------|------| +| `cargo test --release --all-targets` | **1355 passed**, 0 failed | +| HWP3 sample 6종 페이지 수 | 모두 동일 (회귀 없음) | +| HWPX/HWP5 페이지 수 | 모두 동일 (회귀 없음) | + +## 5. 커밋 + +- `ddb7fa4` — Task #894 Stage 3: HWP3 page border 좌표 기준 paper_based 로 정합 + +## 6. 산출물 + +- 본 Stage 보고서: `mydocs/working/task_m100_894_stage3.md` +- Fix: `src/parser/hwp3/mod.rs` (+4 lines, -1 line) diff --git a/mydocs/working/task_m100_896_stage1.md b/mydocs/working/task_m100_896_stage1.md new file mode 100644 index 000000000..d3ce5c14b --- /dev/null +++ b/mydocs/working/task_m100_896_stage1.md @@ -0,0 +1,103 @@ +# Task #896 Stage 1+2 통합 보고서 — 차이 1 진단 정정 + Fix + +**Stage**: 1+2 / 5 (차이 1: paragraph 글머리 fixup 휴리스틱) +**상태**: ✅ 완료 + +## 1. 진단 정정 (이전 Task #894 Stage 4 의 진단 오류) + +### 1.1 SVG x 좌표 직접 측정 + +paragraph 396~399 의 ◦ char SVG x 좌표: + +| y | x | char | paragraph | +|---|---|------|-----------| +| 1220 | 96.69 | ○ | 395 (1단계) | +| **1249** | **107.30** | **◦** | **396** | +| **1276** | **107.30** | **◦** | **397** | +| **1303** | **107.30** | **◦** | **398 (이전)** | +| **1330** | **107.30** | **◦** | **399 (이전)** | + +→ **paragraph 396/397/398/399 의 ◦ 모두 동일 x=107.30**. 이전 진단 ("paragraph 396 ○ x=117.81 vs paragraph 397~399 ◦ x=107.30") 의 paragraph 396 x 가 잘못된 비교 (paragraph 396 의 ◦ 가 아닌 다른 paragraph 의 ○). + +### 1.2 진짜 root cause — paragraph 398/399 의 ◦ 자체가 잘못 추가됨 + +PDF (한컴 viewer) 페이지 18 의 paragraph 398 영역 분석 (pdftohtml -xml): + +```xml +- +하드웨어 및 소프트웨어 장애에 대비한 클러스터링 구성 +``` + +paragraph 398 의 첫 char 가 **`-` (left=159)** — **한컴 viewer 에서 ◦ 표시되지 않음**. sub-item dash marker 로 처리. + +다른 paragraph 의 ◦ (paragraph 396/397/400) PDF 추출: +```xml + + + +``` + +paragraph 398 (top≈1014), paragraph 399 (top≈1041) 영역에 ◦ char element **없음**. + +### 1.3 ROOT CAUSE 함수 추적 + +`src/parser/hwp3/mod.rs:2502 apply_bullet_fixup_single` (Task #877 Stage 4): + +```rust +if !para.text.starts_with(' ') { return; } +let second = para.text.chars().nth(1).unwrap_or(' '); +if second == '◦' || second == '○' { return; } +``` + +skip 조건이 두번째 char 가 ◦/○ 인 경우만. **`-` (sub-item dash) 시작 paragraph 의 skip 누락**. + +paragraph 398 raw text: `" - 하드웨어..."` (공백+공백+공백+dash). fixup 진행 → ` ◦ ` prefix 추가 → `" ◦ - 하드웨어..."` (◦ 잘못 추가). + +대조: `apply_textbox_bullet_fixup` (line 2467, nested text_box) 의 skip: +```rust +if second == '-' { return; } +``` + +main fixup 이 textbox fixup 의 dash skip 정책 누락. + +## 2. Fix + +`src/parser/hwp3/mod.rs:2526` 의 `apply_bullet_fixup_single` 에 dash skip 조건 추가: + +```rust +// 첫 non-space char 가 '-' (sub-item dash) 면 skip. +let first_non_space = para.text.chars().find(|c| *c != ' ').unwrap_or(' '); +if first_non_space == '-' { return; } +``` + +`apply_textbox_bullet_fixup` 의 동일 정책 적용. sub-item marker 가 이미 dash 로 표시되므로 ◦ 추가 안 함 — 한컴 변환기 정합. + +## 3. 검증 + +### 3.1 paragraph text dump (fix 후) + +``` +paragraph 397: " ◦ 공사 주요업무에 대한 클러스터링(Active - Active) 체계 구축" (유지) +paragraph 398: " - 하드웨어 및 소프트웨어 장애에 대비한 클러스터링 구성" (◦ 제거 ✅) +paragraph 399: " - ORACLE RDBMS의 DB 클러스터링 구성" (◦ 제거 ✅) +``` + +### 3.2 SVG (fix 후) + +paragraph 398/399 의 첫 char `-` 가 x=129.15 시작 (이전: ◦ x=107.30 + - x=152.37 → ◦ 제거로 dash 만). + +### 3.3 회귀 점검 + +- `cargo test --release --lib`: **1250 passed**, 0 failed (이전 1234 + Task #894 의 신규 4 + 본 task 영향 측정 차이) +- HWP3 sample 6종 페이지 수 회귀 없음 +- HWPX sample 회귀 없음 +- HWP3 sample10 (해당 ◦ 휴리스틱 적용 sample) 회귀 없음 (페이지 수 763 유지) + +## 4. 커밋 + +- `(다음 commit)` — Task #896 Stage 1+2: apply_bullet_fixup_single 의 first_non_space == '-' skip 추가 + +## 5. Stage 산출물 + +- 본 보고서: `mydocs/working/task_m100_896_stage1.md` +- Fix: `src/parser/hwp3/mod.rs` (+8 lines) diff --git a/mydocs/working/task_m100_896_stage3.md b/mydocs/working/task_m100_896_stage3.md new file mode 100644 index 000000000..730170316 --- /dev/null +++ b/mydocs/working/task_m100_896_stage3.md @@ -0,0 +1,126 @@ +# Task #896 Stage 3+4 통합 보고서 — 차이 2 진단 + Fix (WMF font encoding) + +**Stage**: 3+4 / 5 (차이 2: WMF 그림 안 텍스트 겹침) +**상태**: ✅ 완료 + +## 1. 진단 + +### 1.1 WMF SVG 변환 결과 분석 + +sample16 paragraph 394 의 picture (WMF `bin_id=3`, "주전산센터 목표시스템 구성(안)") 의 SVG 변환 결과 (base64 decode): + +```xml + + ... + +``` + +→ **font-family primary 가 `±¼¸²Ã¼`** (깨진 글자). secondary 가 `굴림체` (정확). + +### 1.2 ROOT CAUSE 추적 + +`src/wmf/parser/objects/graphics/font.rs:165~188` 의 facename 처리: + +```rust +let as_latin1 = bytes_into_utf8(&bytes[..len], ANSI_CHARSET)?; // Latin-1 변환 +let as_charset = bytes_into_utf8(&bytes[..len], charset)?; // charset 변환 +(as_latin1, as_charset) // primary=as_latin1, secondary=as_charset +``` + +WMF spec: facename 은 Latin-1 ANSI character 만 허용. 그러나 **한컴 WMF (HANGUL_CHARSET=0x81) 는 CP949 byte 그대로 facename 에 넣음** — spec 위반이나 한컴의 일반적 패턴. + +`as_latin1`: "굴림체" 의 CP949 bytes 를 Latin-1 으로 해석 → `±¼¸²Ã¼` (깨진 글자) +`as_charset` (CP949): "굴림체" (정확) + +primary 로 `as_latin1` (깨진 글자) 사용 → SVG renderer 가 못 찾음 → fallback `굴림체` 시도 → 시스템 미설치 시 다시 fallback 없음 → 깨진 글리프 표시. + +### 1.3 시각 검증 (rsvg-convert PNG) + +PDF 비교용 PNG 추출: + +- 한컴 PDF: 그림 안 텍스트 정상 한글 표시 +- rhwp (이전): **모자이크 깨진 글리프** (font 못 찾음) + +## 2. Fix + +### 2.1 Fix 1 — font.rs: charset 기반 primary 선택 + +`src/wmf/parser/objects/graphics/font.rs`: + +```rust +// charset 이 ANSI 가 아니면 as_charset (CP949 등) 을 primary, as_latin1 fallback +if charset != crate::wmf::parser::CharacterSet::ANSI_CHARSET { + (as_charset, as_latin1) +} else { + (as_latin1, as_charset) +} +``` + +### 2.2 Fix 2 — util.rs: 한국어 시스템 fallback chain 추가 + +`src/wmf/converter/svg/util.rs:414`: + +```rust +let has_korean = font_family.iter().any(|f| { + f.chars().any(|c| ('\u{AC00}'..='\u{D7A3}').contains(&c)) +}); +if has_korean { + for fallback in [ + "Apple SD Gothic Neo", + "Malgun Gothic", + "Nanum Gothic", + "Noto Sans CJK KR", + "sans-serif", + ] { + font_family.push(fallback.to_string()); + } +} +``` + +facename 또는 fallback_facename 에 한글 char (U+AC00~U+D7A3) 있으면 한국어 시스템 폰트 chain 추가. + +## 3. 결과 + +### 3.1 SVG font-family (fix 후) + +이전: +``` +font-family="'±¼¸²Ã¼','굴림체'" +``` + +이후: +``` +font-family="'굴림체','±¼¸²Ã¼','Apple SD Gothic Neo','Malgun Gothic','Nanum Gothic','Noto Sans CJK KR','sans-serif'" +``` + +### 3.2 시각 정합 + +| 항목 | 이전 | 이후 | +|------|------|------| +| 그림 안 텍스트 표시 | 모자이크 (깨진 글리프) | **정상 한글 텍스트** ✅ | +| 시스템 환경 호환 | 굴림체 설치 필요 | **macOS/Windows/Linux 정합** | + +### 3.3 회귀 점검 + +- `cargo test --release --lib`: **1250 passed**, 0 failed +- HWP3 sample 6종 + HWPX sample 페이지 수 회귀 없음 +- ANSI WMF (영문 only) 의 경우 영향 없음 — Fix 1 의 `charset != ANSI_CHARSET` 조건으로 분기 + +## 4. 영향 범위 + +- 모든 한국어 WMF (HANGUL_CHARSET) 의 font-family 정합 +- 다른 multi-byte charset (SHIFTJIS_CHARSET=0x80, GB2312_CHARSET=0x86 등) 도 자동 정합 +- ANSI WMF (서구권) 영향 없음 + +## 5. 커밋 + +- `(다음 commit)` — Task #896 Stage 3+4: WMF font name 인코딩 + fallback chain + +## 6. Stage 산출물 + +- 본 보고서: `mydocs/working/task_m100_896_stage3.md` +- Fix 1: `src/wmf/parser/objects/graphics/font.rs` (+11 lines) +- Fix 2: `src/wmf/converter/svg/util.rs` (+18 lines, -2 lines) diff --git a/src/parser/hwp3/mod.rs b/src/parser/hwp3/mod.rs index 17f3f4746..a98bfb1b6 100644 --- a/src/parser/hwp3/mod.rs +++ b/src/parser/hwp3/mod.rs @@ -2524,6 +2524,13 @@ fn apply_bullet_fixup_single( if !para.text.starts_with(' ') { return; } let second = para.text.chars().nth(1).unwrap_or(' '); if second == '◦' || second == '○' { return; } + // 첫 non-space char 가 '-' (sub-item dash) 면 skip. + // sample16 paragraph 398/399 ("◦ - 하드웨어..." 등) 의 raw text 가 + // 공백 + dash 시작 — 한컴 viewer 는 본 paragraph 에 ◦ 추가 안 함 + // (sub-item marker 이미 dash 로 표시됨). apply_textbox_bullet_fixup 의 + // 동일 정책 적용. + let first_non_space = para.text.chars().find(|c| *c != ' ').unwrap_or(' '); + if first_non_space == '-' { return; } let inserted_chars: u32 = 2; let inserted_utf16: u32 = bullet_str.chars().map(|c| c.len_utf16() as u32).sum(); diff --git a/src/wmf/converter/svg/util.rs b/src/wmf/converter/svg/util.rs index 088831ec1..5f655a94f 100644 --- a/src/wmf/converter/svg/util.rs +++ b/src/wmf/converter/svg/util.rs @@ -411,13 +411,31 @@ impl Font { ); } - let mut font_family: Vec<&str> = vec![]; + let mut font_family: Vec = vec![]; - font_family.push(self.facename.as_str()); + font_family.push(self.facename.clone()); self.fallback_facename.iter().for_each(|f| { - font_family.push(f.as_str()); + font_family.push(f.clone()); }); + // 한컴 WMF 의 한국어 폰트 (예: "굴림체") 가 시스템에 미설치된 환경에서 + // SVG renderer 가 fallback 못 찾으면 글자가 깨져 보임. facename/fallback + // 에 한글 char (U+AC00~U+D7A3) 있으면 시스템 한국어 폰트 chain 추가. + let has_korean = font_family.iter().any(|f| { + f.chars().any(|c| ('\u{AC00}'..='\u{D7A3}').contains(&c)) + }); + if has_korean { + for fallback in [ + "Apple SD Gothic Neo", + "Malgun Gothic", + "Nanum Gothic", + "Noto Sans CJK KR", + "sans-serif", + ] { + font_family.push(fallback.to_string()); + } + } + elem = elem .set("font-family", format!("'{}'", font_family.join("','"))) .set("font-size", self.height.abs()) diff --git a/src/wmf/parser/objects/graphics/font.rs b/src/wmf/parser/objects/graphics/font.rs index 2698049d0..a61b9e617 100644 --- a/src/wmf/parser/objects/graphics/font.rs +++ b/src/wmf/parser/objects/graphics/font.rs @@ -184,7 +184,18 @@ impl Font { let as_charset = crate::wmf::parser::bytes_into_utf8(&bytes[..len], charset)?; - (as_latin1, as_charset) + // WMF spec: facename 은 Latin-1 ANSI character 만 허용. 그러나 한컴 + // 등 multi-byte charset (HANGUL_CHARSET, SHIFTJIS_CHARSET 등) WMF 는 + // CP949/SJIS 등 binary 그대로 facename 에 넣음 — spec 위반이나 실제 + // 한컴 WMF 의 일반적 패턴. charset 이 ANSI 가 아니면 charset 으로 + // 해석한 결과를 primary 로 사용. + // (sample16 paragraph 394 WMF 의 facename "굴림체" 가 Latin-1 으로 + // 해석되어 "±¼¸²Ã¼" 깨진 글자로 표시되던 회귀.) + if charset != crate::wmf::parser::CharacterSet::ANSI_CHARSET { + (as_charset, as_latin1) + } else { + (as_latin1, as_charset) + } }; let mut fallback_facename = Vec::new();