diff --git a/mydocs/manual/hwp5_roundtrip_baseline.md b/mydocs/manual/hwp5_roundtrip_baseline.md new file mode 100644 index 000000000..b89863a88 --- /dev/null +++ b/mydocs/manual/hwp5_roundtrip_baseline.md @@ -0,0 +1,93 @@ +# HWP5 Roundtrip Baseline 가이드 (Task #1552) + +`samples/*.hwp` 전수에 대한 HWP5→IR→HWP5 roundtrip **무손실** 검증 체계의 사용·유지보수 매뉴얼. +`hwpx-roundtrip`(Task #1315)의 HWP5 대응 게이트. + +## 1. 개요 + +`serialize_document`(= `export_hwp_native`, HWP5 "저장하기")의 **무손실성**을 회귀 게이트로 고정한다. +검사 항목(C1~C5): + +| # | 검사 | 방법 | 잡는 결함 | +|---|------|------|-----------| +| C1 | IR 뼈대 diff | `parse → serialize → 재parse` 후 `diff_documents` == 0 | 구조 손실 | +| C2 | **BinData 보존** | 원본·저장본 CFB의 BinData **decompressed 내용** 멀티셋 동일 | **그림 스트림 드롭(F1)** | +| C3 | 페이지수 복원 | `DocumentCore::from_bytes` 페이지 수 원본==저장본 | 페이지 변형 | +| C4 | CFB 구조 | 필수 스트림(FileHeader/DocInfo/BodyText/Section0) + 섹션 수 = IR | 구조 회귀 | +| C5 | 2-round 안정성 | 저장본 재직렬화→재parse 후 IR diff == 0 | 비결정성 | + +> **중요**: 통과 = 구조+BinData+페이지(rhwp 자기 일관) 보존이며 **시각 충실도 보장이 아니다**. +> C3 는 rhwp 자기 재로드 기준이라 **외부 한글에서만** 나타나는 페이지 붕괴(예: `convert`/ +> `convert_to_editable` 경로)는 자동 검출하지 못한다. 시각·외부 divergence 판정은 +> 작업지시자(한컴에디터)와 `output/poc/fidelity/` 한글 harness(T3 재열림·T4 PDF)가 보조한다. + +## 2. 등급 체계 + +| 등급 | 의미 | 코드 위치 | +|------|------|----------| +| **A (baseline)** | C1~C5 전부 통과. 신규 HWP5 샘플 자동 포함 | `tests/hwp5_roundtrip_baseline.rs` 기본 대상 | +| **B (xfail)** | 식별된 결함으로 baseline 제외. 사유 필수 | `XFAIL` 상수 | +| **자동 제외** | HWP5 아님(HWP3/HWPML) 또는 배포용 문서 — serializer 결함 아님 | `out_of_scope()` (포맷·distribution 감지) | + +현황 (2026-06-26, `samples/*.hwp` 319건): +- **A=297, B(xfail)=9**, 자동 제외=13 (HWP3 10 + 배포용 3). +- B(xfail) 9건은 전부 `serialize_document`의 **BinData 그림 스트림 드롭(F1)**: + `img-start-001`(20/20), `BookReview`(7/10), `Worldcup_FIFA2010_32`(13/47), + `exam_social`(2/8), `NewYear_s_Day`(2/4), `곡선이있는분산형`(2/3), `pic-crop-01`(2/3), + `interview`(1/3), `BlogForm_Recipe`(1/3). 후속 이슈에서 serializer 수정 시 승격. + +> **자동 제외 근거**: HWP3(`HWP Document File V3.00`)는 별도 포맷이라 HWP5 직렬화 시 +> 교차변환(페이지 폭증)된다. 배포용 문서는 `serialize_document` 직접 적용 시 +> `DISTRIBUTE_DOC_DATA` 누락으로 재파싱 실패하나, 정상 경로는 `convert_to_editable` +> 선행이므로 게이트 범위 밖이다(별도 이슈로 분리). + +## 3. 통합 테스트 (`tests/hwp5_roundtrip_baseline.rs`) + +```bash +cargo test --release --test hwp5_roundtrip_baseline +``` + +| 테스트 | 역할 | +|--------|------| +| `baseline_all_samples_roundtrip` | 소형(≤3MB) 전수 — **신규 샘플 자동 포함** | +| `baseline_large_samples_roundtrip` | 대형(>3MB) 분리 — 하네스 병렬로 wall time 단축 | +| `xfail_entries_still_fail` | xfail 이 통과하면 실패 → baseline 승격 강제 | + +### 신규 샘플 추가 시 +`samples/` 에 `.hwp` 추가 시 자동으로 baseline 게이트에 포함된다. +- 통과 → 끝 (A등급) +- 실패 → 결함 수정하거나 **사유와 함께** `XFAIL` 등록(사유 없는 등록 금지) +- HWP3/배포용 → `out_of_scope()` 가 자동 제외(목록 불필요) + +### xfail 승격 절차 +serializer 결함(F1 등) 해소 시 `xfail_entries_still_fail` 가 실패한다. +해당 항목을 `XFAIL` 에서 제거하면 baseline 으로 자동 승격된다. + +## 4. 배치 CLI (`rhwp hwp5-roundtrip`) + +```bash +rhwp hwp5-roundtrip sample.hwp # 단일 파일 검사 +rhwp hwp5-roundtrip --batch samples # 폴더 전수 (재귀) +rhwp hwp5-roundtrip --batch samples -o output/poc/task1552 # 산출물 지정 +``` + +- 산출물: `{out}/inventory.tsv`(17컬럼) + `{out}/{stem}.rt.hwp`(재조립 파일) +- 상태 우선순위: `PARSE_FAIL → SERIALIZE_FAIL → REPARSE_FAIL → IR_DIFF → BINDATA_LOSS → CFB_STRUCT_FAIL → PAGE_DIFF → ROUND2_FAIL → ROUND2_DIFF → PASS` +- 하드 실패(파싱/직렬화/재파싱/BinData/구조/페이지/2-round) 존재 시 종료 코드 1 (CI 사용 가능) +- `inventory.tsv` 컬럼: sample, status, parse/serialize/reparse_ok, ir_diff_count, + bindata_total, bindata_lost, page_before, page_after, cfb_struct_ok, round2_diff, + elapsed_ms, error, ir_diff_summary, cfb_problems, round2_error. + +## 5. Known limitations / 후속 + +| 한계 | 증상 | 후속 | +|------|------|------| +| BinData 그림 스트림 드롭 | `serialize_document` 가 일부 그림 누락 (xfail 9건) | F1 수정 이슈 | +| convert 경로 추가 손실 | `convert_to_editable`/`convert` 경유 시 이미지·페이지 손실(KTX 27→1) — 본 게이트(serialize_document 직접) 범위 밖 | F2' 별도 이슈 | +| C3 외부 divergence | rhwp 자기일관 페이지는 보존이나 외부 한글에서만 붕괴하는 경우 미검출 | 한글 harness 보조 | + +## 6. 관련 문서 +- 수행/구현 계획: `mydocs/plans/task_m100_1552{,_impl}.md` +- 단계별 보고서: `mydocs/working/task_m100_1552_stage{1..4}.md` +- 최종 보고서: `mydocs/report/task_m100_1552_report.md` +- 선행 조사(한글 4단계 오라클): `output/poc/fidelity/report.md` diff --git a/mydocs/plans/task_m100_1552.md b/mydocs/plans/task_m100_1552.md new file mode 100644 index 000000000..5eedb24f9 --- /dev/null +++ b/mydocs/plans/task_m100_1552.md @@ -0,0 +1,90 @@ +# Task #1552: HWP5 roundtrip 무손실 게이트(hwp5-roundtrip CLI) 신설 — 수행계획서 + +## 목표 + +`hwpx-roundtrip`에 대응하는 **HWP5 동일포맷 roundtrip 게이트**를 신설한다. +parse → `export_hwp_native` → 재parse 경로의 무손실성을, **IR 뼈대만이 아니라 +BinData 스트림 보존·페이지수 복원·CFB 구조까지** 자동 검사하여 회귀 게이트화한다. + +## 배경 (조사 #1552 선행) + +HWPX/HWP5 동일포맷 roundtrip 무손실 4단계 검증(IR·텍스트·한글 재열림·한글 PDF) +결과, **IR 뼈대 diff=0이 무손실을 보장하지 않음**이 실증됨 +(보고서: `output/poc/fidelity/report.md`, 표본 각 포맷 25건). + +| ID | 심각도 | 증상 | 자동검사 현황 | +|----|--------|------|---------------| +| F1 | 심각 | HWP5 저장 시 BinData 그림 스트림 통째 드롭 (25건 중 4건/19스트림) | IR diff=0·텍스트 동일 → **전건 false PASS** | +| F2 | 심각 | KTX(437문단·다중 표) 저장본 한글에서 27→1쪽 붕괴 | rhwp 2-round r2=0 → **자가검출 불가** | +| F3 | 경미 | HWPX 컨트롤 슬롯 8유닛 시프트 | 기존 `hwpx-roundtrip`가 검출(IR_DIFF) | + +> 본 타스크는 **게이트(검출 도구)** 신설에 한정한다. F1·F2의 실제 serializer 수정, +> F3 HWPX 잔여는 **별도 후속 이슈**로 분리한다(혼합 금지). + +## 현황 분석 (기존 자산) + +- `hwpx-roundtrip`(`rhwp::diagnostics::hwpx_roundtrip_batch::run`): HWPX 전용 게이트. + 단일/`--batch`/`-o`, `inventory.tsv`, 상태 우선순위, 종료 코드. **미러 대상**. +- `export_hwp_native`(`document_core::commands::document.rs:582`) = `serialize_document`. HWP5 저장 진입점. + (HWP 출처는 `export_hwp_with_adapter`도 동일 — 어댑터 no-op.) +- `serialize_hwp_with_verify`(같은 파일): serialize→재로드 후 **page_count_before/after 비교**. + → F2 게이트 재료(현재 CLI 미노출). +- `CfbReader::list_bin_data()` / `read_bin_data()`(`parser/cfb_reader.rs`): BinData 스트림 이름·바이트 접근. + → F1 게이트 재료. +- IR 뼈대 비교 함수(HWPX baseline의 `diff_documents` 계열): HWP5에도 적용 가능(공통 `Document` IR). + +## 범위 + +**포함(In)** +- 신규 진단 모듈 `src/diagnostics/hwp5_roundtrip_batch.rs` + `main.rs` 서브커맨드 1행. +- 단일/배치 모드, `inventory.tsv` 산출, 종료 코드(CI). +- 검사 항목 5종(아래). +- `samples/` 전수 배치 1회 실행 → 현 손실 현황 등급화(`XFAIL`/제외 분류). +- 회귀 통합 테스트 + 매뉴얼. + +**제외(Out)** +- F1·F2 serializer 실제 수정(후속 이슈). 본 게이트는 **검출만**. +- F3 HWPX 잔여(별도). +- 한글 OCX 오라클(T3/T4) 자동 연동 — 외부 의존이라 게이트 본체에 미포함. + 대신 조사 harness(`output/poc/fidelity/`)를 보조 자산으로 병기. + +## 검사 항목 설계 + +| # | 검사 | 방법 | 잡는 결함 | +|---|------|------|-----------| +| C1 | IR 뼈대 diff | parse→serialize→재parse 후 IR 비교(diff=0) | (기존 수준) | +| C2 | **BinData 보존** | orig/rt CFB의 BinData 스트림 **decompressed 내용 멀티셋** 동일 | **F1** | +| C3 | **페이지수 복원** | `serialize_hwp_with_verify`의 before==after | **F2(부분)** | +| C4 | CFB 구조 | 필수 스트림(FileHeader/DocInfo/BodyText/Section{N}) + 섹션 수 = IR | 구조 회귀 | +| C5 | 2-round 안정성 | rt→재serialize→재parse 후 IR diff=0 | 비결정성 | + +> C2 주의: BinData는 raw deflate 압축. **decompressed 바이트**로 비교해야 재압축 +> (무손실, 크기만 변동)을 오탐하지 않는다(조사에서 검증 완료). 압축 실패 스트림은 +> 저장 바이트로 폴백 비교. +> C3 한계: rhwp 자기 재로드 기준이라 KTX형(외부 한글에서만 붕괴) 일부는 미검출 가능 → +> 보고서에 한계 명시 + harness 보조. + +## 산출물 + +- `src/diagnostics/hwp5_roundtrip_batch.rs` (신규) +- `main.rs`: `Some("hwp5-roundtrip") => ...` 1행 + `--help` 항목 +- `inventory.tsv`(상태/diff/bindata/page/구조 컬럼) + `{stem}.rt.hwp` +- `tests/hwp5_roundtrip_baseline.rs`(신규) — `samples/*.hwp` 전수 게이트(XFAIL/제외 등급) +- `mydocs/manual/hwp5_roundtrip_baseline.md`(신규) + +## 영향 범위 + +- 신규 파일 위주. 기존 동작 변경 없음(`main.rs` 분기 1행 추가). +- HWP3 전용 분기 추가 없음(CLAUDE.md 규약 준수). 게이트는 HWP5 파서/직렬화만 사용. + +## 검증 기준 + +- 신규 CLI가 단일/배치에서 C1~C5 판정 + `inventory.tsv` 산출 + 손실 존재 시 종료 코드 1. +- 조사에서 확인된 **KTX·interview·Worldcup이 게이트에서 FAIL/XFAIL로 분류**됨(C2·C3 검출 실증). +- exam_kor 등 정상 대조군은 PASS. +- `cargo test --test hwp5_roundtrip_baseline` 통과(현 손실은 사유와 함께 XFAIL 등록). +- 기존 `cargo test` 회귀 없음. + +## 다음 단계 + +승인 시 **구현 계획서**(`task_m100_1552_impl.md`, 3~6단계) 작성 → 재승인 후 단계별 진행. diff --git a/mydocs/plans/task_m100_1552_impl.md b/mydocs/plans/task_m100_1552_impl.md new file mode 100644 index 000000000..b784a9d22 --- /dev/null +++ b/mydocs/plans/task_m100_1552_impl.md @@ -0,0 +1,86 @@ +# Task #1552: HWP5 roundtrip 무손실 게이트 — 구현 계획서 + +> 수행계획서: `task_m100_1552.md` (승인 완료). 본 문서는 단계별 구현 계획(4단계). +> 미러 대상: `src/diagnostics/hwpx_roundtrip_batch.rs`(577줄) + `tests/hwpx_roundtrip_baseline.rs`. + +## 재사용 확정 API (조사 완료) + +| API | 위치 | 용도 | +|-----|------|------| +| `parser::parse_document(&[u8]) -> Result` | 파서 진입 | doc1/doc2/doc3 파싱 | +| `serializer::serialize_document(&Document) -> Result>` | `serializer/mod.rs:78` | HWP5 저장(= `export_hwp_native`) | +| `diff_documents(&Document, &Document) -> IrDiff` | `serializer/hwpx/roundtrip.rs:427` | **포맷 무관** IR 뼈대 비교(C1·C5) | +| `CfbReader::{list_bin_data, read_bin_data}` | `parser/cfb_reader.rs` | BinData 스트림 열거·raw 읽기(C2) | +| `decompress_stream(&[u8]) -> Result>` | `parser/cfb_reader.rs:640` | BinData raw deflate 해제(C2) | +| `CfbReader::{section_count, list_streams}` | `parser/cfb_reader.rs` | CFB 구조 검사(C4) | +| `DocumentCore::{from_bytes, page_count, serialize_hwp_with_verify}` | `document_core/...` | 페이지수 복원(C3) | + +--- + +## Stage 1 — 모듈 골격 + C1(IR diff) + C5(2-round) + CLI 연결 + +**목표**: `hwpx-roundtrip`을 미러한 `hwp5-roundtrip` 최소 동작(단일/배치/`-o`/`inventory.tsv`/종료코드). + +- 신규 `src/diagnostics/hwp5_roundtrip_batch.rs`: + - `Options`/`parse_args`(단일 파일 | `--batch ` | `-o `), `collect_hwp5_files`(재귀 `*.hwp`, ViewText/배포용 등은 포함하되 파싱 실패는 상태로 기록) + - `roundtrip_one`: `parse_document`(doc1) → `serialize_document` → `parse_document`(doc2) → `diff_documents(doc1,doc2)` → 2-round(serialize→doc3, `diff_documents(doc2,doc3)`) + - `RoundtripRow` + `status()`/`is_hard_fail()` (상태 우선순위: `PARSE_FAIL→SERIALIZE_FAIL→REPARSE_FAIL→IR_DIFF→ROUND2_FAIL→ROUND2_DIFF→PASS`) + - `write_tsv`/`print_summary`/`rt_output_path`(`{stem}.rt.hwp`) +- `src/diagnostics/mod.rs`: `pub mod hwp5_roundtrip_batch;` +- `main.rs`: `Some("hwp5-roundtrip") => rhwp::diagnostics::hwp5_roundtrip_batch::run(&args[2..])` + `--help` 항목 +- 단위 테스트: `parse_args`(단일/배치/거부), `rt_output_path`, blank 샘플 PASS (hwpx 테스트 미러) + +**검증**: `rhwp hwp5-roundtrip samples/business_overview.hwp` PASS, `--batch`로 `inventory.tsv` 생성. `cargo test` 회귀 없음. +**산출**: `task_m100_1552_stage1.md` + 소스 커밋. + +## Stage 2 — C2 BinData 스트림 보존 검사 + +**목표**: F1(이미지 드롭) 게이트화. + +- `bindata_fingerprint(bytes: &[u8]) -> BTreeMap<해시, count>`: + - `CfbReader`로 BinData 열거 → 각 스트림 raw 읽기 → `decompress_stream` 시도, 실패 시 raw 사용(압축 플래그 무관 일관) + - **decompressed 내용**의 멀티셋(내용 해시→개수). 이름(BIN0001 등)은 재명명 가능성 있어 **내용 기준 비교**(hwpx `check_package` 정신과 동일) +- `roundtrip_one`에 orig bytes vs rt bytes fingerprint 비교 → `bindata_lost`(드롭 수)/`bindata_total` 기록 +- TSV 컬럼 추가(`bindata_total`, `bindata_lost`), 상태에 `BINDATA_LOSS`(IR_DIFF와 동급 hard-fail) 추가 +- 단위 테스트: 이미지 포함 소형 샘플로 보존 PASS, 인위적 드롭 검출 + +**검증**: KTX(3/3)·interview(1/3)·Worldcup(13/47)이 `BINDATA_LOSS`로 검출, exam_kor(재압축만)은 보존 PASS. +**산출**: `task_m100_1552_stage2.md` + 커밋. + +## Stage 3 — C3(페이지수 복원) + C4(CFB 구조) + +**목표**: F2(페이지 붕괴) 부분 게이트화 + 구조 회귀 봉인. + +- C3: `DocumentCore::from_bytes(orig)`로 `page_before`, rt bytes로 `page_after` 비교(또는 `serialize_hwp_with_verify` 활용). `page_before/page_after`/`page_recovered` 기록. 불일치 시 `PAGE_DIFF`. + - **한계 명시**: rhwp 자기 일관 기준이라 KTX형(외부 한글에서만 27→1) 일부는 자동 미검출 가능 — 보고서·매뉴얼에 기재, 한글 harness 보조. +- C4: rt CFB 필수 스트림(`FileHeader`,`DocInfo`,`BodyText/Section{0..}`) 존재 + `section_count(rt) == doc.sections.len()` 검사. 불일치 시 `CFB_STRUCT_FAIL`. +- TSV 컬럼 추가, 상태 우선순위에 PAGE_DIFF·CFB_STRUCT_FAIL 편입 +- 단위 테스트: 정상 샘플 page_recovered=true·구조 OK + +**검증**: 정상 대조군 PASS, page 불일치 샘플 검출(있으면). +**산출**: `task_m100_1552_stage3.md` + 커밋. + +## Stage 4 — 회귀 테스트 + 전수 등급화 + 매뉴얼 + 최종 보고 + +**목표**: 게이트를 `samples/*.hwp` 전수 회귀로 고정 + 문서화. + +- `tests/hwp5_roundtrip_baseline.rs`(hwpx baseline 미러): + - `baseline_all_samples_roundtrip`(전수 재귀, XFAIL/EXCLUDED 제외, 신규 샘플 자동 포함) + - `xfail_entries_still_fail`(XFAIL 승격 강제), `grade_lists_are_consistent` + - `--batch samples` 1회 실행 결과로 **현 손실을 사유와 함께 XFAIL 등록**(F1: KTX/interview/Worldcup 등, F2: 발견분). EXCLUDED: 비-HWP5/손상. + - 대형 샘플 분리(`LARGE`)로 wall time 관리(hwpx 동형) +- `mydocs/manual/hwp5_roundtrip_baseline.md`: 사용법·등급체계·C1~C5·한계(C3 외부 divergence) +- 최종 결과보고서 `mydocs/report/task_m100_1552_report.md` + 후속 이슈 후보(F1/F2/F3 수정) 정리 + +**검증**: `cargo test --test hwp5_roundtrip_baseline` 통과(XFAIL 정합). `cargo test` 전체 회귀 없음. `cargo clippy` 클린(신규 파일 범위). +**산출**: `task_m100_1552_stage4.md` + `_report.md` + 커밋. + +--- + +## 공통 주의 + +- HWP3 전용 분기 추가 금지(CLAUDE.md). 게이트는 HWP5 파서/직렬화·CFB만 사용. +- `inventory.tsv` 컬럼은 단계별로 **추가만**(기존 컬럼 의미 불변). +- 한글 OCX 오라클은 게이트 본체 미포함 — `output/poc/fidelity/` harness가 보조. +- 단계마다 완료보고서(`_stage{N}.md`) + 소스 동반 커밋, 승인 후 다음 단계. +- 기능/포맷 변경 미혼합. 신규 파일 위주라 무관 rustfmt diff 미발생. diff --git a/mydocs/report/task_m100_1552_report.md b/mydocs/report/task_m100_1552_report.md new file mode 100644 index 000000000..7ae157cfa --- /dev/null +++ b/mydocs/report/task_m100_1552_report.md @@ -0,0 +1,80 @@ +# Task #1552 최종 결과보고서 — HWP5 roundtrip 무손실 게이트(hwp5-roundtrip CLI) 신설 + +- 이슈: #1552 (M100) +- 브랜치: `local/task1552` (from `devel`) +- 일자: 2026-06-26 + +## 1. 목표 및 결과 + +`hwpx-roundtrip`에 대응하는 **HWP5 동일포맷 roundtrip 무손실 게이트**를 신설했다. +`serialize_document`(= `export_hwp_native`, HWP5 "저장하기")가 IR 뼈대뿐 아니라 +**BinData 그림 스트림·페이지수·CFB 구조**까지 보존하는지 자동 검사하고 회귀로 고정한다. + +- CLI `rhwp hwp5-roundtrip`(단일/`--batch`/`-o`/`inventory.tsv`/종료코드) 신설 +- 회귀 테스트 `tests/hwp5_roundtrip_baseline.rs` (신규 샘플 자동 포함, XFAIL 등급화) +- 매뉴얼 `mydocs/manual/hwp5_roundtrip_baseline.md` + +## 2. 검사 항목 (C1~C5) + +| # | 검사 | 잡는 결함 | +|---|------|-----------| +| C1 | IR 뼈대 diff(`diff_documents`, 포맷 무관) | 구조 손실 | +| C2 | **BinData 보존**(decompressed 내용 멀티셋) | **그림 스트림 드롭(F1)** | +| C3 | 페이지수 복원(`DocumentCore::from_bytes`, rhwp 자기 일관) | 페이지 변형 | +| C4 | CFB 구조(필수 스트림 + 섹션 수 = IR) | 구조 회귀 | +| C5 | 2-round 안정성 | 비결정성 | + +## 3. 전수 측정 결과 (`samples/*.hwp` 319건, 27.5s) + +| 분류 | 건수 | 비고 | +|------|----:|------| +| **A (PASS)** | 297 | C1~C5 전부 통과 | +| **B (xfail) BinData 드롭(F1)** | 9 | serialize_document 그림 스트림 드롭 — 후속 수정 | +| 자동 제외 — HWP3 | 10 | `HWP Document File V3.00` (별도 포맷, 교차변환 페이지 폭증) | +| 자동 제외 — 배포용 | 3 | `serialize_document` 직접 적용 시 DISTRIBUTE_DOC_DATA 누락 | + +xfail 9건(전부 편집가능·배포용 아님): `img-start-001`(20/20), `BookReview`(7/10), +`Worldcup_FIFA2010_32`(13/47), `exam_social`(2/8), `NewYear_s_Day`(2/4), +`곡선이있는분산형`(2/3), `pic-crop-01`(2/3), `interview`(1/3), `BlogForm_Recipe`(1/3). + +## 4. 핵심 발견 — 손실 원인 분리 (선행 조사 정정) + +선행 조사(`output/poc/fidelity/report.md`)는 `convert` CLI로 저장본을 만들었는데, +`convert`는 `serialize_document` 전에 **`convert_to_editable_native()`를 호출**한다. +본 게이트는 `serialize_document`를 직접 호출하여 두 손실 원인을 분리했다: + +| 손실 | 원인 | 본 게이트 | +|------|------|-----------| +| **F1 그림 드롭** | `serialize_document` 자체(편집가능 문서 9건, interview 1/3·Worldcup 13/47 등) | **C2 가 정탐 → xfail** | +| **F2' 그림+페이지 붕괴** | `convert_to_editable`/`convert` 경로(KTX 3/3 이미지·27→1쪽) | 범위 밖(별도 이슈) | + +KTX 교차 검증: `serialize_document` 저장본은 BinData 3/3 보존 + 한글 페이지 27=27 +보존(붕괴 없음). 즉 **F2(KTX 페이지 붕괴)는 serialize_document 결함이 아니라 convert +경로 결함**임이 게이트 신설로 규명됨. (선행 보고서의 KTX·convert 기반 수치는 본 보고서로 정정) + +## 5. 검증 + +- `cargo build --release` 성공. +- `cargo test --lib hwp5_roundtrip`: 14 passed (단위). +- `cargo test --test hwp5_roundtrip_baseline`: 통과 (A=297 baseline, xfail 9 여전히 실패 확인). +- 전수 배치 종료 코드 1(하드 실패 존재 = xfail 결함 노출) — CI 게이트로 사용 가능. + +## 6. 변경 파일 + +- 신규: `src/diagnostics/hwp5_roundtrip_batch.rs`, `tests/hwp5_roundtrip_baseline.rs`, + `mydocs/manual/hwp5_roundtrip_baseline.md` +- 수정: `src/diagnostics/mod.rs`(모듈 등록), `src/main.rs`(서브커맨드+help) +- 계획/보고: `mydocs/plans/task_m100_1552{,_impl}.md`, `mydocs/working/task_m100_1552_stage{1..4}.md` + +## 7. 후속 이슈 후보 + +- **F1**: `serialize_document` BinData 그림 스트림 드롭 수정 (xfail 9건 승격 목표) +- **F2'**: `convert_to_editable`/`convert` 경로 이미지·페이지 손실 (KTX 27→1) +- **F3**: HWPX serializer 컨트롤 슬롯 8유닛 시프트 (실문서 노출, `hwpx-roundtrip` 소관) +- (선택) 배포용 문서 roundtrip 지원(convert_to_editable 경유 게이트 분기) + +## 8. 한계 + +- C3 는 rhwp 자기 일관 기준 — 외부 한글에서만 나타나는 페이지 붕괴는 미검출. + `output/poc/fidelity/` 한글 harness(T3 재열림·T4 PDF)가 보조. +- 게이트 통과 = 구조+BinData+페이지(자기일관) 보존이며 시각 충실도 보장이 아니다. diff --git a/mydocs/report/task_m100_1554_report.md b/mydocs/report/task_m100_1554_report.md new file mode 100644 index 000000000..c9dd025e4 --- /dev/null +++ b/mydocs/report/task_m100_1554_report.md @@ -0,0 +1,85 @@ +# Task #1554 최종 결과보고서 — serialize_document BinData 고아 스트림 드롭 수정 (F1, XFAIL 9건 승격) + +- 이슈: #1554 (M100) +- 브랜치: `local/task1554` (from `devel`) +- 일자: 2026-06-26 +- 선행: #1552(PR #1553) — `hwp5-roundtrip` 게이트가 본 결함을 정탐 + +## 1. 목표 및 결과 + +Task #1552의 `hwp5-roundtrip` 게이트 **C2(BinData 보존)** 가 검출한 F1 결함 +— `serialize_document`(= HWP5 "저장하기")가 일부 BinData 그림 스트림을 **통째 드롭** +— 을 수정했다. 영향 9건의 BinData 가 저장본에 전수 보존되며, `tests/hwp5_roundtrip_baseline.rs` +의 XFAIL 9건이 baseline 으로 승격(목록에서 제거)되었다. + +## 2. 근본 원인 — "storage_id 충돌"이 아니라 "레코드 없는 고아 스트림" + +이슈의 1차 가설(storage_id 충돌)은 **오진**이었다. 진단 결과 실제 원인은 +**대응 `HWPTAG_BIN_DATA` 레코드가 없는 고아(orphan) `/BinData` 스트림**이다. + +| 파일 | 원본 `/BinData` 스트림 | DocInfo BinData 레코드 | 고아 | +|------|----------------------|----------------------|------| +| `interview.hwp` | `BIN0001.jpg`,`BIN0002.jpg`,`BIN0003.gif` (3) | storage_id=2,3 (2) | `BIN0001.jpg` (1) | +| `img-start-001.hwp` | `BIN0001`~`BIN0014` (20) | **0개** | 20 전건 | + +`load_bin_data_content`(파서)와 `collect_extra_streams`(추가 스트림 보존) **둘 다 +`bin_data_list`(레코드) 기준으로만** 동작한다. 따라서 레코드 없는 스트림은 파싱 단계에서 +버려지고, 직렬화 시 재생성되지 못해 저장본에서 사라졌다. IR diff=0·텍스트 동일이라 +기존 자동검사는 전건 false PASS였다(데이터 손실). + +> 유지되는 그림의 크기 변동은 무손실 재압축(decompressed 바이트 동일)으로 확인됨. +> 손실은 **드롭된 고아 스트림**에 한정. + +## 3. 수정 (`src/parser/mod.rs`) + +`collect_extra_streams` 가 **직렬화기가 `bin_data_content` 로부터 재생성할 `/BinData` +경로 집합**을 계산하고, 그 집합에 들지 않는 `/BinData` 스트림(= 고아)을 `extra_streams` +로 **원본 바이트 그대로 보존**하도록 변경했다. + +- 신규 헬퍼 `serialized_bin_name()` — 직렬화기 `cfb_writer::find_bin_data_info_with_compress` + 의 명명 규칙(매칭 레코드 우선, 없으면 content 자체값)을 미러링하여 "재생성될 경로"를 + 정확히 산출 → 중복 기록 없이 고아만 선별. +- `collect_extra_streams` 의 `/BinData/` 일괄 제외를 "재생성 대상만 제외"로 완화. +- 호출부에 `&bin_data_content` 전달(파싱 순서상 이미 산출되어 있음). + +`extra_streams` 는 기존 Scripts/DocOptions 보존에 쓰던 메커니즘으로, 렌더링·IR·레이아웃 +경로를 건드리지 않는 **격리된 저위험** 방식이다. 직렬화기(`cfb_writer`)의 extra_streams +기록 루프가 원본 경로 그대로 출력하며, 재생성 스트림과 경로가 분리(disjoint)되어 충돌 없다. + +## 4. 검증 + +| 항목 | 결과 | +|------|------| +| 영향 9건 `rhwp hwp5-roundtrip` | `BINDATA_LOSS` → **전건 PASS** | +| `interview.hwp` 저장본 스트림 | `BIN0001/0002/0003` 전수 보존(decompressed 동일) | +| `cargo test --test hwp5_roundtrip_baseline` | 3건 통과(`baseline_all_samples` 포함 → 유지 그림 재압축 무손실 회귀 없음, `xfail_entries_still_fail` 통과) | +| `cargo test --lib cfb_writer`(17) / `bin_data`(16) | 통과 | +| `cargo clippy --release` | 무경고 | +| 변경 파일 `cargo fmt` | 적용 | + +XFAIL 9건 전건 승격: `img-start-001`(20/20), `BookReview`(7/10), +`Worldcup_FIFA2010_32`(13/47), `exam_social`(2/8), `NewYear_s_Day`(2/4), +`곡선이있는분산형`(2/3), `pic-crop-01`(2/3), `interview`(1/3), `BlogForm_Recipe`(1/3). + +## 5. 수용 기준 대조 + +- [x] 영향 9건 BinData 저장본 전수 보존(내용 멀티셋 동일) +- [x] `tests/hwp5_roundtrip_baseline.rs` XFAIL 9건 baseline 승격(목록 비움) +- [x] 유지 그림 재압축 무손실 회귀 없음, `cargo test --test hwp5_roundtrip_baseline` 통과 + +## 6. 범위 밖 (별개) + +- **F2'**: `convert`/`convert_to_editable` 경로의 이미지·페이지 손실(KTX 27→1쪽). 본 이슈 + (F1, `serialize_document` 직접)와 무관 — `serialize_document` 는 KTX 보존 확인됨. +- lenient CFB 폴백 경로(`parse_hwp_with_lenient`)는 `extra_streams` 를 비워 두는 기존 + 한계 유지(깨진 CFB 전용 폴백, 9건은 모두 strict 경로로 파싱). + +## 7. 변경 파일 + +| 파일 | 변경 | +|------|------| +| `src/parser/mod.rs` | `collect_extra_streams` 고아 `/BinData` 보존 + `serialized_bin_name` 헬퍼 | +| `tests/hwp5_roundtrip_baseline.rs` | XFAIL 9건 제거(baseline 승격) | + +관련: #1552, PR #1553. 게이트·근거: `mydocs/manual/hwp5_roundtrip_baseline.md`, +`mydocs/report/task_m100_1552_report.md`. diff --git a/mydocs/working/task_m100_1552_stage1.md b/mydocs/working/task_m100_1552_stage1.md new file mode 100644 index 000000000..0bd67de23 --- /dev/null +++ b/mydocs/working/task_m100_1552_stage1.md @@ -0,0 +1,27 @@ +# Task #1552 Stage 1 완료보고서 — 모듈 골격 + C1(IR diff) + C5(2-round) + CLI 연결 + +## 목표 +`hwpx-roundtrip`을 미러한 `hwp5-roundtrip` 최소 동작(단일/배치/`-o`/`inventory.tsv`/종료코드) 구현. + +## 변경 사항 +- 신규 `src/diagnostics/hwp5_roundtrip_batch.rs` + - `parse_document`(doc1) → `serialize_document` → `parse_document`(doc2) → `diff_documents`(C1) + - 2-round: serialize(doc2)→doc3, `diff_documents(doc2,doc3)` (C5) + - `RoundtripRow.status()`: `PARSE_FAIL→SERIALIZE_FAIL→REPARSE_FAIL→IR_DIFF→ROUND2_FAIL→ROUND2_DIFF→PASS` + - 단일/`--batch `/`-o`, `inventory.tsv`(11컬럼), `print_summary`, `{stem}.rt.hwp` 산출, 하드 실패 시 종료 코드 1 + - `collect_hwp5_files`: `.hwp`만 수집(`.hwpx` 제외) +- `src/diagnostics/mod.rs`: `pub mod hwp5_roundtrip_batch;` 등록 +- `src/main.rs`: `Some("hwp5-roundtrip") => ...` 분기 + `--help` 3행 추가 + +## 재사용 API +`parse_document`, `serialize_document`(=`export_hwp_native`), `diff_documents`(포맷 무관, `serializer/hwpx/roundtrip.rs:427`). + +## 검증 +- `cargo build --release`: 성공 (기존 bin/lib 동명 note 외 경고 없음) +- `cargo test --release --lib hwp5_roundtrip`: **10 passed; 0 failed** +- 스모크: + - `business_overview.hwp` → `PASS diff=0 r2=0` + - `KTX.hwp` → `PASS diff=0 r2=0` (이미지 드롭은 C2/Stage 2에서 검출 예정 — Stage 1 범위 밖, 정상) + +## 다음 단계 +Stage 2 — C2 BinData 스트림 보존 검사(decompressed 내용 멀티셋)로 F1(이미지 드롭) 게이트화. diff --git a/mydocs/working/task_m100_1552_stage2.md b/mydocs/working/task_m100_1552_stage2.md new file mode 100644 index 000000000..e0a24c660 --- /dev/null +++ b/mydocs/working/task_m100_1552_stage2.md @@ -0,0 +1,48 @@ +# Task #1552 Stage 2 완료보고서 — C2 BinData 스트림 보존 검사 + +## 목표 +F1(이미지 드롭) 게이트화 — 저장 시 BinData 그림 스트림 소실을 검출. + +## 변경 사항 (`hwp5_roundtrip_batch.rs`) +- `bindata_fingerprint(bytes) -> Option>`: + `CfbReader::open` → `list_bin_data` → 각 스트림 `read_bin_data`(raw) → `decompress_stream` 시도, 실패 시 raw. + **decompressed 내용**의 해시 멀티셋. 이름이 아닌 내용 기준(재명명·재압축 무관). CFB 아니면 `None`. +- `bindata_lost(orig, rt)`: orig 멀티셋에서 rt 가 못 덮은 항목 수(gained 무시). +- `RoundtripRow`에 `bindata_total`/`bindata_lost` 추가, 상태 `BINDATA_LOSS`(IR_DIFF와 ROUND2 사이, 하드 실패). +- TSV 2컬럼 추가(`bindata_total`,`bindata_lost`), 콘솔 `bin_lost=n/total`, summary 1행. + +## 검증 (스모크) +| 파일 | 결과 | 판정 | +|------|------|------| +| interview | `BINDATA_LOSS bin_lost=1/3` | ✅ 정탐 (olefile 일치) | +| Worldcup_FIFA2010_32 | `BINDATA_LOSS bin_lost=13/47` | ✅ 정탐 (olefile 정확 일치) | +| exam_kor | `PASS` | ✅ 무손실 재압축 오탐 없음 | +| business_overview | `PASS` | ✅ | +| **KTX** | `PASS` | ✅ (아래 정밀화 참조) | + +- 단위 테스트: `cargo test --lib hwp5_roundtrip` **12 passed** (신규 `bindata_lost_counts_only_missing`, `bindata_fingerprint_preserved_on_roundtrip`). +- `cargo build --release` 성공. + +## ⚠️ 정밀화 발견 — F1/F2 손실 원인 분리 + +선행 조사(`output/poc/fidelity/`)는 `convert` CLI로 저장본을 만들었는데, `convert`는 +`serialize_document` 전에 **`convert_to_editable_native()`를 호출**한다. 본 게이트는 +`serialize_document`(= `export_hwp_native`, 진짜 "저장하기")를 **직접** 호출한다. + +KTX 교차 비교(olefile + 한글 PageCount): + +| 경로 | KTX BinData | KTX 페이지(한글) | +|------|-------------|------------------| +| 원본 | 3 | 27 | +| `serialize_document`(본 게이트) | **3 보존** (재압축만) | **27 = 27 보존** | +| `convert`(convert_to_editable 경유) | **0 (전 소실)** | **27→1 붕괴** | + +**결론**: +- **F1(이미지 드롭)은 두 원인** — ① `serialize_document` 자체(interview 1/3·Worldcup 13/47, **본 게이트가 정탐**) ② `convert_to_editable` 경로 추가 손실(KTX 3/3). +- **F2(KTX 페이지 붕괴)는 `serialize_document`가 아니라 `convert_to_editable` 경로 버그**. 네이티브 저장은 KTX를 완전 보존. +- 따라서 KTX `PASS`는 **정확한 판정**. 게이트가 손실 원인을 분리해냈다(게이트 신설의 핵심 가치). +- 선행 조사 보고서(`output/poc/fidelity/report.md`)의 KTX·convert 기반 수치는 Stage 4 최종보고에서 정정한다. +- **후속 이슈 후보 갱신**: F1(serialize_document BinData 드롭)과 **F2'(convert_to_editable 이미지·페이지 손실)**를 분리 등록. + +## 다음 단계 +Stage 3 — C3 페이지수 복원 + C4 CFB 구조 검사. diff --git a/mydocs/working/task_m100_1552_stage3.md b/mydocs/working/task_m100_1552_stage3.md new file mode 100644 index 000000000..d0091dc0e --- /dev/null +++ b/mydocs/working/task_m100_1552_stage3.md @@ -0,0 +1,32 @@ +# Task #1552 Stage 3 완료보고서 — C3(페이지수 복원) + C4(CFB 구조) + +## 목표 +F2(페이지 붕괴) 부분 게이트화 + CFB 구조 회귀 봉인. + +## 변경 사항 (`hwp5_roundtrip_batch.rs`) +- `page_count_of(bytes) -> Option`: `DocumentCore::from_bytes` → `page_count()`. + 배치 중 단일 파일 패닉 격리 위해 `catch_unwind`(AssertUnwindSafe). 실패/패닉 시 `None`. +- `cfb_structure_ok(out, expected_sections)`: `CfbReader::open` 후 필수 스트림 + (`/FileHeader`,`/DocInfo`,`/BodyText/Section0`) 존재 + `section_count == IR 섹션 수`. +- `RoundtripRow`: `page_before`/`page_after`/`cfb_struct_ok`/`cfb_problems` 추가. + - `page_mismatch()`: 양쪽 Some 이고 다를 때만 true(한쪽 None 은 미검출). + - 상태 `CFB_STRUCT_FAIL`(C4) / `PAGE_DIFF`(C3) 추가, `is_hard_fail` 편입. +- TSV 컬럼 추가(`page_before`,`page_after`,`cfb_struct_ok`,`cfb_problems`), summary 2행, 콘솔 `pg=a→b`. + +## 검증 +- `cargo build --release` 성공. +- `cargo test --lib hwp5_roundtrip`: **14 passed** (신규 `page_mismatch_only_when_both_present_and_differ`, `cfb_structure_rejects_non_cfb`). +- 스모크: + - KTX `PASS` (page_before==page_after==27, PAGE_DIFF 없음 — serialize_document 페이지 보존 재확인) + - interview `BINDATA_LOSS bin_lost=1/3` (유지) + - exam_kor / business_overview / pic-in-table-01 `PASS` + - **오탐 없음**: CFB_STRUCT_FAIL·PAGE_DIFF 미발생. + +## C3 한계 (명시) +C3 는 rhwp 자기 재로드(`DocumentCore::from_bytes`) 기준이라 **rhwp 가 자기일관**인 +경우(예: KTX 가 외부 한글에서만 27→1 붕괴하는 convert 경로 결함)는 자동 미검출된다. +serialize_document 경로의 페이지 변화는 검출하나, 외부 한글-only divergence 는 +`output/poc/fidelity/` 한글 harness(T3/T4)가 보조한다. + +## 다음 단계 +Stage 4 — 회귀 테스트(`tests/hwp5_roundtrip_baseline.rs`) + `samples/*.hwp` 전수 등급화(XFAIL) + 매뉴얼 + 최종 보고서. diff --git a/mydocs/working/task_m100_1552_stage4.md b/mydocs/working/task_m100_1552_stage4.md new file mode 100644 index 000000000..eb5032a48 --- /dev/null +++ b/mydocs/working/task_m100_1552_stage4.md @@ -0,0 +1,33 @@ +# Task #1552 Stage 4 완료보고서 — 회귀 테스트 + 전수 등급화 + 매뉴얼 + 최종 보고 + +## 목표 +게이트를 `samples/*.hwp` 전수 회귀로 고정하고 문서화 + 최종 보고. + +## 변경 사항 +- `src/diagnostics/hwp5_roundtrip_batch.rs`: `pub fn baseline_check(bytes) -> Result<(),String>` + 추가 — 게이트 C1~C5 로직의 단일 출처(인메모리). 회귀 테스트가 재사용. +- 신규 `tests/hwp5_roundtrip_baseline.rs`: + - `baseline_all_samples_roundtrip`(≤3MB) / `baseline_large_samples_roundtrip`(>3MB) — 신규 샘플 자동 포함 + - `xfail_entries_still_fail` — xfail 통과 시 승격 강제 + - `out_of_scope()`: `detect_format != Hwp` (HWP3) + `header.distribution`(배포용) **자동 제외** + - `XFAIL` 9건(F1 BinData 드롭, 사유 동반) +- 신규 `mydocs/manual/hwp5_roundtrip_baseline.md` +- 신규 `mydocs/report/task_m100_1552_report.md` + +## 전수 측정 (`samples/*.hwp` 319건, 27.5s) +| 분류 | 건수 | +|------|----:| +| A (PASS) | 297 | +| B (xfail) F1 BinData 드롭 | 9 | +| 자동 제외 HWP3 | 10 | +| 자동 제외 배포용 | 3 | + +## 검증 +- `cargo test --test hwp5_roundtrip_baseline`: **3 passed** (18s) — baseline 통과 + xfail 여전히 실패. +- `cargo test --lib hwp5_roundtrip`: 14 passed (단위). +- `cargo build --release` 성공. + +## 결론 +HWP5 저장 무손실 게이트 완비. F1(serialize_document BinData 드롭) 9건을 xfail 로 봉인, +HWP3·배포용은 자동 제외. 선행 조사의 F2(KTX 페이지 붕괴)는 convert 경로 결함으로 분리 규명. +후속 이슈 후보(F1·F2'·F3)는 최종 보고서에 정리. diff --git a/src/diagnostics/hwp5_roundtrip_batch.rs b/src/diagnostics/hwp5_roundtrip_batch.rs new file mode 100644 index 000000000..8cc9aa679 --- /dev/null +++ b/src/diagnostics/hwp5_roundtrip_batch.rs @@ -0,0 +1,720 @@ +//! HWP5 roundtrip 배치 검증 (Task #1552). +//! +//! `samples/*.hwp` 등의 HWP5(OLE) 파일을 `parse → serialize → 재parse` 경로로 돌려 +//! 파일별 무손실성을 측정하고, 재조립 `.rt.hwp`를 출력 폴더에 남긴다. +//! `hwpx-roundtrip`(Task #1315)의 HWP5 대응 게이트. +//! +//! ```text +//! rhwp hwp5-roundtrip sample.hwp -o output/poc/task1552/ +//! rhwp hwp5-roundtrip --batch samples -o output/poc/task1552/ +//! ``` +//! +//! 출력: +//! - `{out}/{상대경로 stem}.rt.hwp` — 재조립 HWP5 +//! - `{out}/inventory.tsv` — 배치 측정 결과 (배치 모드) +//! +//! 검사 항목(단계별 확장): +//! - C1 IR 뼈대 diff (`diff_documents`, 포맷 무관) +//! - C5 2-round 안정성 +//! - (Stage 2) C2 BinData 스트림 보존 +//! - (Stage 3) C3 페이지수 복원 + C4 CFB 구조 + +use std::collections::hash_map::DefaultHasher; +use std::collections::BTreeMap; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use crate::document_core::DocumentCore; +use crate::parser::cfb_reader::{decompress_stream, CfbReader}; +use crate::parser::parse_document; +use crate::serializer::hwpx::roundtrip::diff_documents; +use crate::serializer::serialize_document; + +/// C3 — 바이트에서 페이지 수 산출(파싱+페이지네이션). 실패/패닉 시 `None`. +/// 배치 중 단일 파일 패닉이 전체를 중단시키지 않도록 `catch_unwind` 로 격리. +fn page_count_of(bytes: &[u8]) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + DocumentCore::from_bytes(bytes) + .ok() + .map(|dc| dc.page_count()) + })) + .ok() + .flatten() +} + +/// C4 — 저장본 CFB 구조 검사: 필수 스트림 존재 + 섹션 수 = IR. +fn cfb_structure_ok(out: &[u8], expected_sections: usize) -> (bool, String) { + let reader = match CfbReader::open(out) { + Ok(r) => r, + Err(e) => return (false, format!("CFB 열기 실패: {e}")), + }; + let mut problems = Vec::new(); + for req in ["/FileHeader", "/DocInfo", "/BodyText/Section0"] { + if !reader.has_stream(req) { + problems.push(format!("필수 스트림 없음: {req}")); + } + } + let sc = reader.section_count() as usize; + if sc != expected_sections { + problems.push(format!("섹션 수 불일치: cfb={sc} ir={expected_sections}")); + } + (problems.is_empty(), problems.join("; ")) +} + +/// BinData 스트림의 **decompressed 내용** 지문 멀티셋. +/// +/// 이름(BIN0001 등)이 아니라 내용 기준 — serializer 가 재명명·재압축할 수 있으므로. +/// 각 스트림은 raw deflate 해제 시도 후 실패 시 raw 바이트를 사용한다(압축 플래그 무관 정규화: +/// 한쪽이 압축·다른쪽이 비압축으로 같은 이미지를 저장해도 내용이 일치하면 동일 지문). +/// CFB 가 아니거나 열 수 없으면 `None`(검사 생략). +fn bindata_fingerprint(bytes: &[u8]) -> Option> { + let mut reader = CfbReader::open(bytes).ok()?; + let names = reader.list_bin_data(); + let mut multiset: BTreeMap = BTreeMap::new(); + for name in names { + let raw = match reader.read_bin_data(&name) { + Ok(r) => r, + Err(_) => continue, + }; + let content = decompress_stream(&raw).unwrap_or(raw); + let mut h = DefaultHasher::new(); + content.hash(&mut h); + *multiset.entry(h.finish()).or_insert(0) += 1; + } + Some(multiset) +} + +/// orig 멀티셋에서 rt 가 덮지 못한 항목 수(= 소실된 BinData 스트림 수). +/// rt 가 더 많이 가진 항목(gained)은 손실이 아니므로 무시한다. +fn bindata_lost(orig: &BTreeMap, rt: &BTreeMap) -> usize { + let mut lost = 0; + for (hash, &cnt) in orig { + let have = rt.get(hash).copied().unwrap_or(0); + if cnt > have { + lost += cnt - have; + } + } + lost +} + +#[derive(Debug)] +struct Options { + input: PathBuf, + batch: bool, + out_dir: PathBuf, +} + +fn parse_args(args: &[String]) -> Result { + let mut input: Option = None; + let mut batch = false; + let mut out_dir = PathBuf::from("output/poc/task1552"); + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--batch" => batch = true, + "-o" | "--out" => { + i += 1; + let v = args + .get(i) + .ok_or_else(|| "-o 다음에 출력 폴더가 필요합니다".to_string())?; + out_dir = PathBuf::from(v); + } + other if other.starts_with('-') => { + return Err(format!("알 수 없는 옵션: {other}")); + } + other => { + if input.is_some() { + return Err(format!("입력 경로가 중복 지정됨: {other}")); + } + input = Some(PathBuf::from(other)); + } + } + i += 1; + } + + let input = input.ok_or_else(|| { + "사용법: rhwp hwp5-roundtrip <입력.hwp | --batch 폴더> [-o 출력폴더]".to_string() + })?; + Ok(Options { + input, + batch, + out_dir, + }) +} + +/// 파일 1건의 roundtrip 측정 결과. +#[derive(Debug)] +struct RoundtripRow { + /// 배치 루트 기준 상대 경로 (단일 모드는 파일명). + rel_path: String, + parse_ok: bool, + serialize_ok: bool, + reparse_ok: bool, + ir_diff_count: Option, + ir_diff_summary: String, + /// C2 — 원본 BinData 스트림 수. `None` = 검사 생략(CFB 아님/열기 실패). + bindata_total: Option, + /// C2 — 저장본에서 소실된 BinData 스트림 수(내용 멀티셋 기준). + bindata_lost: usize, + /// C3 — 원본/저장본의 rhwp 페이지 수. `None` = 재로드 실패/패닉. + page_before: Option, + page_after: Option, + /// C4 — 저장본 CFB 구조 검사. `None` = 미실행. + cfb_struct_ok: Option, + cfb_problems: String, + /// 2-round 안정성: round1 IR vs round2 IR 의 diff 건수. `None` = 미실행/실패. + round2_diff_count: Option, + round2_error: String, + elapsed_ms: u128, + error: String, +} + +impl RoundtripRow { + fn status(&self) -> &'static str { + if !self.parse_ok { + "PARSE_FAIL" + } else if !self.serialize_ok { + "SERIALIZE_FAIL" + } else if !self.reparse_ok { + "REPARSE_FAIL" + } else if self.ir_diff_count.is_some_and(|c| c > 0) { + "IR_DIFF" + } else if self.bindata_lost > 0 { + "BINDATA_LOSS" + } else if self.cfb_struct_ok == Some(false) { + "CFB_STRUCT_FAIL" + } else if self.page_mismatch() { + "PAGE_DIFF" + } else if !self.round2_error.is_empty() || self.round2_diff_count.is_none() { + "ROUND2_FAIL" + } else if self.round2_diff_count.is_some_and(|c| c > 0) { + "ROUND2_DIFF" + } else { + "PASS" + } + } + + /// C3 — 원본/저장본 페이지 수가 둘 다 존재하고 다른 경우. + fn page_mismatch(&self) -> bool { + matches!((self.page_before, self.page_after), (Some(a), Some(b)) if a != b) + } + + /// 회귀 검출용 하드 실패 (등급화 대상 분류와 별개). + fn is_hard_fail(&self) -> bool { + !(self.parse_ok && self.serialize_ok && self.reparse_ok) + || self.bindata_lost > 0 + || self.cfb_struct_ok == Some(false) + || self.page_mismatch() + || !self.round2_error.is_empty() + } +} + +/// 단일 HWP5 파일 roundtrip 실행. 재조립 파일을 `rt_path`에 기록. +fn roundtrip_one(path: &Path, rel_path: &str, rt_path: &Path) -> RoundtripRow { + let started = Instant::now(); + let mut row = RoundtripRow { + rel_path: rel_path.to_string(), + parse_ok: false, + serialize_ok: false, + reparse_ok: false, + ir_diff_count: None, + ir_diff_summary: String::new(), + bindata_total: None, + bindata_lost: 0, + page_before: None, + page_after: None, + cfb_struct_ok: None, + cfb_problems: String::new(), + round2_diff_count: None, + round2_error: String::new(), + elapsed_ms: 0, + error: String::new(), + }; + + let finish = |mut row: RoundtripRow, started: Instant| -> RoundtripRow { + row.elapsed_ms = started.elapsed().as_millis(); + row + }; + + let bytes = match fs::read(path) { + Ok(b) => b, + Err(e) => { + row.error = format!("읽기 실패: {e}"); + return finish(row, started); + } + }; + + let doc1 = match parse_document(&bytes) { + Ok(d) => d, + Err(e) => { + row.error = format!("파싱 실패: {e}"); + return finish(row, started); + } + }; + row.parse_ok = true; + + let out = match serialize_document(&doc1) { + Ok(o) => o, + Err(e) => { + row.error = format!("직렬화 실패: {e}"); + return finish(row, started); + } + }; + row.serialize_ok = true; + + if let Some(parent) = rt_path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + row.error = format!("출력 폴더 생성 실패: {e}"); + return finish(row, started); + } + } + if let Err(e) = fs::write(rt_path, &out) { + row.error = format!("재조립 파일 쓰기 실패: {e}"); + return finish(row, started); + } + + let doc2 = match parse_document(&out) { + Ok(d) => d, + Err(e) => { + row.error = format!("재파싱 실패: {e}"); + return finish(row, started); + } + }; + row.reparse_ok = true; + + let diff = diff_documents(&doc1, &doc2); + row.ir_diff_count = Some(diff.differences.len()); + row.ir_diff_summary = diff + .differences + .iter() + .map(|d| d.to_string()) + .collect::>() + .join("; "); + + // C2 — BinData 스트림 보존 (원본 bytes vs 저장본 out 의 내용 멀티셋 비교) + if let (Some(orig_fp), Some(rt_fp)) = (bindata_fingerprint(&bytes), bindata_fingerprint(&out)) { + row.bindata_total = Some(orig_fp.values().sum()); + row.bindata_lost = bindata_lost(&orig_fp, &rt_fp); + } + + // C3 — 페이지수 복원 (rhwp 자기 일관 기준; 외부 한글-only 붕괴는 미검출 한계) + row.page_before = page_count_of(&bytes); + row.page_after = page_count_of(&out); + + // C4 — 저장본 CFB 구조 + let (cfb_ok, cfb_problems) = cfb_structure_ok(&out, doc1.sections.len()); + row.cfb_struct_ok = Some(cfb_ok); + row.cfb_problems = cfb_problems; + + // 2-round 안정성: round1 IR(doc2) 을 다시 직렬화→파싱한 IR(doc3) 과 비교해 0 이어야 안정. + match serialize_document(&doc2) { + Ok(out2) => match parse_document(&out2) { + Ok(doc3) => { + let diff2 = diff_documents(&doc2, &doc3); + row.round2_diff_count = Some(diff2.differences.len()); + } + Err(e) => row.round2_error = format!("2-round 재파싱 실패: {e}"), + }, + Err(e) => row.round2_error = format!("2-round 직렬화 실패: {e}"), + } + + finish(row, started) +} + +/// 회귀 테스트용 — 인메모리 baseline 검사(C1~C5). 하드 실패 시 사유 반환. +/// +/// 게이트(`roundtrip_one`)와 동일한 검사 순서. 호출자는 HWP3(`detect_format`)·배포용 +/// (`header.distribution`) 등 범위 밖 샘플을 사전에 걸러야 한다(이 함수는 무조건 C1~C5 수행). +pub fn baseline_check(bytes: &[u8]) -> Result<(), String> { + let doc1 = parse_document(bytes).map_err(|e| format!("파싱 실패: {e}"))?; + let out = serialize_document(&doc1).map_err(|e| format!("직렬화 실패: {e}"))?; + let doc2 = parse_document(&out).map_err(|e| format!("재파싱 실패: {e}"))?; + + // C1 IR 뼈대 + let diff = diff_documents(&doc1, &doc2); + if !diff.differences.is_empty() { + return Err(format!( + "IR diff {}건: {}", + diff.differences.len(), + diff.differences + .iter() + .map(|d| d.to_string()) + .collect::>() + .join("; ") + )); + } + + // C2 BinData 보존 + if let (Some(orig_fp), Some(rt_fp)) = (bindata_fingerprint(bytes), bindata_fingerprint(&out)) { + let lost = bindata_lost(&orig_fp, &rt_fp); + if lost > 0 { + return Err(format!( + "BinData 소실 {}/{}", + lost, + orig_fp.values().sum::() + )); + } + } + + // C4 CFB 구조 + let (cfb_ok, cfb_problems) = cfb_structure_ok(&out, doc1.sections.len()); + if !cfb_ok { + return Err(format!("CFB 구조: {cfb_problems}")); + } + + // C3 페이지수 복원 + if let (Some(a), Some(b)) = (page_count_of(bytes), page_count_of(&out)) { + if a != b { + return Err(format!("페이지 변화 {a}→{b}")); + } + } + + // C5 2-round 안정성 + let out2 = serialize_document(&doc2).map_err(|e| format!("2-round 직렬화 실패: {e}"))?; + let doc3 = parse_document(&out2).map_err(|e| format!("2-round 재파싱 실패: {e}"))?; + let diff2 = diff_documents(&doc2, &doc3); + if !diff2.differences.is_empty() { + return Err(format!( + "2-round 불안정: IR diff {}건", + diff2.differences.len() + )); + } + + Ok(()) +} + +/// 폴더에서 `.hwp` 파일을 재귀 수집 (정렬된 순서). `.hwpx`는 제외. +fn collect_hwp5_files(root: &Path) -> Result, String> { + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = + fs::read_dir(&dir).map_err(|e| format!("폴더 읽기 실패 {}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("폴더 항목 읽기 실패: {e}"))?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("hwp")) + { + files.push(path); + } + } + } + files.sort(); + Ok(files) +} + +fn tsv_escape(s: &str) -> String { + s.replace(['\t', '\n', '\r'], " ") +} + +fn write_tsv(out_dir: &Path, rows: &[RoundtripRow]) -> Result { + let tsv_path = out_dir.join("inventory.tsv"); + let opt_to_str = |o: Option| o.map(|c| c.to_string()).unwrap_or_else(|| "-".to_string()); + let opt_u32 = |o: Option| o.map(|c| c.to_string()).unwrap_or_else(|| "-".to_string()); + let opt_bool = |o: Option| match o { + Some(true) => "true", + Some(false) => "false", + None => "-", + }; + let mut tsv = String::from( + "sample\tstatus\tparse_ok\tserialize_ok\treparse_ok\tir_diff_count\tbindata_total\tbindata_lost\tpage_before\tpage_after\tcfb_struct_ok\tround2_diff\telapsed_ms\terror\tir_diff_summary\tcfb_problems\tround2_error\n", + ); + for row in rows { + tsv.push_str(&format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n", + tsv_escape(&row.rel_path), + row.status(), + row.parse_ok, + row.serialize_ok, + row.reparse_ok, + opt_to_str(row.ir_diff_count), + opt_to_str(row.bindata_total), + row.bindata_lost, + opt_u32(row.page_before), + opt_u32(row.page_after), + opt_bool(row.cfb_struct_ok), + opt_to_str(row.round2_diff_count), + row.elapsed_ms, + tsv_escape(&row.error), + tsv_escape(&row.ir_diff_summary), + tsv_escape(&row.cfb_problems), + tsv_escape(&row.round2_error), + )); + } + fs::write(&tsv_path, tsv).map_err(|e| format!("TSV 쓰기 실패: {e}"))?; + Ok(tsv_path) +} + +fn print_summary(rows: &[RoundtripRow]) { + let count = |s: &str| rows.iter().filter(|r| r.status() == s).count(); + println!(); + println!("=== hwp5-roundtrip 요약 ==="); + println!(" 총 파일 : {}", rows.len()); + println!(" PASS : {}", count("PASS")); + println!(" IR_DIFF : {}", count("IR_DIFF")); + println!(" BINDATA_LOSS : {}", count("BINDATA_LOSS")); + println!(" CFB_STRUCT_FAIL: {}", count("CFB_STRUCT_FAIL")); + println!(" PAGE_DIFF : {}", count("PAGE_DIFF")); + println!(" ROUND2_DIFF : {}", count("ROUND2_DIFF")); + println!(" ROUND2_FAIL : {}", count("ROUND2_FAIL")); + println!(" PARSE_FAIL : {}", count("PARSE_FAIL")); + println!(" SERIALIZE_FAIL : {}", count("SERIALIZE_FAIL")); + println!(" REPARSE_FAIL : {}", count("REPARSE_FAIL")); +} + +/// `rt.hwp` 출력 경로 — 배치 루트 기준 상대 구조를 출력 폴더 아래에 유지. +fn rt_output_path(out_dir: &Path, rel_path: &str) -> PathBuf { + let rel = Path::new(rel_path); + let stem = rel + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "output".to_string()); + let mut out = out_dir.to_path_buf(); + if let Some(parent) = rel.parent() { + out.push(parent); + } + out.push(format!("{stem}.rt.hwp")); + out +} + +pub fn run(args: &[String]) { + let opts = match parse_args(args) { + Ok(o) => o, + Err(e) => { + eprintln!("오류: {e}"); + std::process::exit(2); + } + }; + + let inputs: Vec<(PathBuf, String)> = if opts.batch { + match collect_hwp5_files(&opts.input) { + Ok(files) => files + .into_iter() + .map(|p| { + let rel = p + .strip_prefix(&opts.input) + .map(|r| r.to_string_lossy().to_string()) + .unwrap_or_else(|_| p.to_string_lossy().to_string()); + (p, rel) + }) + .collect(), + Err(e) => { + eprintln!("오류: {e}"); + std::process::exit(2); + } + } + } else { + let rel = opts + .input + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| opts.input.to_string_lossy().to_string()); + vec![(opts.input.clone(), rel)] + }; + + if inputs.is_empty() { + eprintln!( + "오류: 처리할 .hwp 파일이 없습니다: {}", + opts.input.display() + ); + std::process::exit(2); + } + + let mut rows = Vec::with_capacity(inputs.len()); + for (path, rel) in &inputs { + let rt_path = rt_output_path(&opts.out_dir, rel); + let row = roundtrip_one(path, rel, &rt_path); + let fmt_opt = + |o: Option| o.map(|c| c.to_string()).unwrap_or_else(|| "-".to_string()); + let mut extra = String::new(); + if row.bindata_lost > 0 { + extra.push_str(&format!( + " bin_lost={}/{}", + row.bindata_lost, + fmt_opt(row.bindata_total) + )); + } + if row.page_mismatch() { + extra.push_str(&format!( + " pg={}→{}", + row.page_before.map(|p| p.to_string()).unwrap_or_default(), + row.page_after.map(|p| p.to_string()).unwrap_or_default() + )); + } + println!( + "[{:>15}] diff={:>3} r2={:>3}{} {:>6}ms {}", + row.status(), + fmt_opt(row.ir_diff_count), + fmt_opt(row.round2_diff_count), + extra, + row.elapsed_ms, + row.rel_path + ); + for detail in [&row.error, &row.cfb_problems, &row.round2_error] { + if !detail.is_empty() { + println!(" └ {}", detail); + } + } + rows.push(row); + } + + if opts.batch { + match write_tsv(&opts.out_dir, &rows) { + Ok(p) => println!("\nTSV 저장: {}", p.display()), + Err(e) => { + eprintln!("오류: {e}"); + std::process::exit(1); + } + } + print_summary(&rows); + } + + // 하드 실패(파싱/직렬화/재파싱/2-round 오류)가 있으면 비정상 종료 코드 (회귀 검출용) + if rows.iter().any(|r| r.is_hard_fail()) { + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blank_row() -> RoundtripRow { + RoundtripRow { + rel_path: String::new(), + parse_ok: true, + serialize_ok: true, + reparse_ok: true, + ir_diff_count: Some(0), + ir_diff_summary: String::new(), + bindata_total: None, + bindata_lost: 0, + page_before: None, + page_after: None, + cfb_struct_ok: Some(true), + cfb_problems: String::new(), + round2_diff_count: Some(0), + round2_error: String::new(), + elapsed_ms: 0, + error: String::new(), + } + } + + #[test] + fn parse_args_single_file() { + let args = vec!["sample.hwp".to_string()]; + let o = parse_args(&args).unwrap(); + assert_eq!(o.input, PathBuf::from("sample.hwp")); + assert!(!o.batch); + assert_eq!(o.out_dir, PathBuf::from("output/poc/task1552")); + } + + #[test] + fn parse_args_batch_with_out() { + let args = vec![ + "--batch".to_string(), + "samples".to_string(), + "-o".to_string(), + "output/poc/x".to_string(), + ]; + let o = parse_args(&args).unwrap(); + assert!(o.batch); + assert_eq!(o.input, PathBuf::from("samples")); + assert_eq!(o.out_dir, PathBuf::from("output/poc/x")); + } + + #[test] + fn parse_args_rejects_unknown_option() { + let args = vec!["--nope".to_string()]; + assert!(parse_args(&args).is_err()); + } + + #[test] + fn parse_args_requires_input() { + let args: Vec = vec![]; + assert!(parse_args(&args).is_err()); + } + + #[test] + fn rt_output_path_keeps_subdir() { + let p = rt_output_path(Path::new("out"), "basic/interview.hwp"); + assert_eq!(p, PathBuf::from("out/basic/interview.rt.hwp")); + } + + #[test] + fn rt_output_path_flat_file() { + let p = rt_output_path(Path::new("out"), "business_overview.hwp"); + assert_eq!(p, PathBuf::from("out/business_overview.rt.hwp")); + } + + #[test] + fn tsv_escape_strips_tabs_newlines() { + assert_eq!(tsv_escape("a\tb\nc"), "a b c"); + } + + #[test] + fn bindata_lost_counts_only_missing() { + let orig: BTreeMap = [(1, 2), (2, 1), (3, 1)].into_iter().collect(); + // rt 가 hash=1 하나만 가지고 hash=3 소실, hash=2 유지, gained hash=9 무시 + let rt: BTreeMap = [(1, 1), (2, 1), (9, 5)].into_iter().collect(); + // 소실: hash=1 (2→1, 1개) + hash=3 (1→0, 1개) = 2 + assert_eq!(bindata_lost(&orig, &rt), 2); + // 동일 멀티셋이면 0 + assert_eq!(bindata_lost(&orig, &orig), 0); + } + + #[test] + fn page_mismatch_only_when_both_present_and_differ() { + let mut row = blank_row(); + row.page_before = Some(5); + row.page_after = Some(5); + assert!(!row.page_mismatch()); + row.page_after = Some(1); + assert!(row.page_mismatch()); + row.page_after = None; + assert!(!row.page_mismatch(), "한쪽이 None 이면 미스매치 아님"); + } + + #[test] + fn cfb_structure_rejects_non_cfb() { + let (ok, problems) = cfb_structure_ok(b"not a cfb file", 1); + assert!(!ok); + assert!(problems.contains("CFB")); + } + + #[test] + fn bindata_fingerprint_preserved_on_roundtrip() { + // 이미지 포함 소형 샘플이 있으면 C2 보존을 확인한다. + let sample = Path::new("samples/basic/interview.hwp"); + if !sample.exists() { + return; + } + let bytes = fs::read(sample).unwrap(); + let fp = bindata_fingerprint(&bytes); + assert!(fp.is_some(), "CFB BinData 지문 추출 실패"); + assert!(fp.unwrap().values().sum::() > 0, "BinData 없음"); + } + + #[test] + fn collect_hwp5_files_excludes_hwpx() { + // samples 폴더가 있으면 .hwpx 가 섞이지 않는지 확인. + let root = Path::new("samples"); + if !root.exists() { + return; + } + let files = collect_hwp5_files(root).unwrap(); + assert!( + files + .iter() + .all(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("hwp"))), + "hwpx 파일이 수집됨" + ); + } +} diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 35a44be7b..153397350 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -10,6 +10,7 @@ pub mod hwp5_first_para_control_probe; pub mod hwp5_inventory; pub mod hwp5_inventory_diff; pub mod hwp5_mel_personnel_probe; +pub mod hwp5_roundtrip_batch; pub mod hwp5_table_probe; pub mod hwpx_roundtrip_batch; pub mod render_geom_diff; diff --git a/src/main.rs b/src/main.rs index dc100d346..a6eb3b476 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ fn main() { Some("test-field") => test_field_roundtrip(&args[2..]), Some("ir-diff") => ir_diff(&args[2..]), Some("hwpx-roundtrip") => rhwp::diagnostics::hwpx_roundtrip_batch::run(&args[2..]), + Some("hwp5-roundtrip") => rhwp::diagnostics::hwp5_roundtrip_batch::run(&args[2..]), Some("render-diff") => rhwp::diagnostics::render_geom_diff::run(&args[2..]), Some("thumbnail") => extract_thumbnail(&args[2..]), _ => { @@ -207,6 +208,9 @@ fn print_help() { println!(" HWPX → IR → HWPX roundtrip 검증 (Task #1315 baseline)"); println!(" 재조립 .hwpx와 inventory.tsv를 출력 폴더(기본 output/poc/task1315)에 생성"); println!(" --lineseg-report: 문단별 lineseg diff를 lineseg_diff.tsv로 산출 (#1380 측정)"); + println!(" hwp5-roundtrip <파일.hwp | --batch 폴더> [-o <출력폴더>]"); + println!(" HWP5 → IR → HWP5 roundtrip 무손실 검증 (Task #1552)"); + println!(" 재조립 .rt.hwp와 inventory.tsv를 출력 폴더(기본 output/poc/task1552)에 생성"); println!(" render-diff <파일> [--via hwpx|hwp] [-p <페이지>] [--max-disp ]"); println!(" render-diff <파일A> <파일B> [-p <페이지>] [--max-disp ]"); println!(" render-diff --batch <폴더> [--via hwpx] [-o <출력폴더>] [--max-disp ]"); diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9fb866f3c..bc69ae66b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -261,7 +261,7 @@ fn parse_hwp_with_cfb( // 5-7. 미리보기, BinData, 추가 스트림 let preview = extract_preview(&mut cfb); let bin_data_content = load_bin_data_content(&mut cfb, &doc_info.bin_data_list, compressed); - let extra_streams = collect_extra_streams(&mut cfb, &doc_info.bin_data_list); + let extra_streams = collect_extra_streams(&mut cfb, &doc_info.bin_data_list, &bin_data_content); // Document 조립 let model_header = ModelFileHeader { @@ -1067,24 +1067,42 @@ fn detect_image_format(data: &[u8]) -> PreviewImageFormat { /// 이미 별도로 파싱되므로 제외한다. fn collect_extra_streams( cfb: &mut cfb_reader::CfbReader, - _bin_data_list: &[crate::model::bin_data::BinData], + bin_data_list: &[crate::model::bin_data::BinData], + bin_data_content: &[BinDataContent], ) -> Vec<(String, Vec)> { let all_streams = cfb.list_streams(); let mut extra = Vec::new(); + // [Task #1554] 직렬화기(`cfb_writer`)가 `bin_data_content` 로부터 재생성할 + // /BinData 스트림 경로 집합. 직렬화기와 동일한 명명 규칙(`find_bin_data_info_with_compress`) + // 을 미러링하여 계산한다. 이 집합에 들어가지 않는 /BinData 스트림은 대응 BinData + // 레코드가 없는 "고아 스트림"(예: img-start-001 의 20개 BIN, interview.hwp 의 BIN0001) + // 이며, 그대로 두면 저장 시 통째 드롭된다. extra_streams 로 원본 바이트를 보존한다. + let emitted_bin_paths: std::collections::HashSet = bin_data_content + .iter() + .map(|c| { + let (storage_id, ext) = serialized_bin_name(bin_data_list, c); + format!("/BinData/BIN{:04X}.{}", storage_id, ext) + }) + .collect(); + for path in &all_streams { // 이미 파싱된 스트림은 제외 if path == "/FileHeader" || path == "/DocInfo" || path.starts_with("/BodyText/") || path.starts_with("/ViewText/") - || path.starts_with("/BinData/") || path == "/PrvImage" || path == "/PrvText" { continue; } + // /BinData 는 직렬화기가 재생성하는 스트림만 제외하고, 고아 스트림은 보존 + if path.starts_with("/BinData/") && emitted_bin_paths.contains(path) { + continue; + } + // 나머지 스트림 보존 if let Ok(data) = cfb.read_stream_raw(path) { extra.push((path.clone(), data)); @@ -1094,6 +1112,25 @@ fn collect_extra_streams( extra } +/// 직렬화기가 `BinDataContent` 에 대해 생성할 스트림 이름의 (storage_id, ext) 계산. +/// +/// `cfb_writer::find_bin_data_info_with_compress` 의 명명 규칙(매칭 레코드 우선, +/// 없으면 content 자체값)을 미러링한다. extra_streams 의 고아 /BinData 판별 전용. +fn serialized_bin_name<'a>( + bin_data_list: &'a [crate::model::bin_data::BinData], + content: &'a BinDataContent, +) -> (u16, &'a str) { + use crate::model::bin_data::BinDataType; + for bd in bin_data_list { + if matches!(bd.data_type, BinDataType::Embedding | BinDataType::Storage) + && bd.storage_id == content.id + { + return (bd.storage_id, bd.extension.as_deref().unwrap_or("dat")); + } + } + (content.id, &content.extension) +} + /// BinData 스토리지에서 이미지 데이터 로드 /// /// bin_data_list의 각 항목에 대해 CFB 스토리지에서 바이너리 데이터를 읽어온다. diff --git a/tests/hwp5_roundtrip_baseline.rs b/tests/hwp5_roundtrip_baseline.rs new file mode 100644 index 000000000..2a28394f4 --- /dev/null +++ b/tests/hwp5_roundtrip_baseline.rs @@ -0,0 +1,142 @@ +//! Task #1552 — `samples/*.hwp` 전수 HWP5 roundtrip 무손실 baseline 게이트. +//! +//! `mydocs/manual/hwp5_roundtrip_baseline.md` 의 등급 체계를 코드로 고정한다. +//! `hwpx_roundtrip_baseline.rs`(Task #1315)의 HWP5 대응. +//! +//! 검사(C1~C5): IR 뼈대 diff 0 + BinData 스트림 보존(decompressed 내용) + +//! CFB 구조 + 페이지수 복원(rhwp 자기 일관) + 2-round 안정성. +//! +//! - **A (baseline)**: 위 전부 통과. 목록에 없는 신규 HWP5 샘플도 자동 포함. +//! - **B (xfail)**: 식별된 결함으로 제외(사유 필수). 통과하게 되면 `xfail_entries_still_fail` +//! 가 실패 → baseline 승격. +//! - **자동 제외**: HWP5(`FileFormat::Hwp`)가 아닌 `.hwp`(HWP3 등)와 배포용 문서 +//! (`header.distribution`) — serializer 결함이 아니라 범위 밖. +//! +//! 주의: 구조(뼈대)+BinData+페이지(자기일관) 보존 검증이며 시각 충실도 보장이 아니다. +//! 외부 한글-only 페이지 붕괴(convert 경로 등)는 `output/poc/fidelity/` 한글 harness 보조. + +use std::path::{Path, PathBuf}; + +use rhwp::diagnostics::hwp5_roundtrip_batch::baseline_check; +use rhwp::parser::{detect_format, parse_document, FileFormat}; + +const SAMPLES_ROOT: &str = "samples"; + +/// 대형 분리 기준(바이트). 이상은 `baseline_large_samples_roundtrip` 로 분리해 +/// 하네스 병렬 실행을 활용한다. +const LARGE_THRESHOLD: u64 = 3 * 1024 * 1024; + +/// B등급 (xfail) — (상대 경로, 사유). 사유 없는 등록 금지. +/// +/// 과거 `serialize_document`(HWP5 직렬화)의 **BinData 그림 스트림 드롭**(F1, Task #1552 +/// 조사) 9건이 등록되어 있었으나, Task #1554 에서 대응 BinData 레코드 없는 고아 /BinData +/// 스트림을 `extra_streams` 로 보존하도록 수정하여 전건 baseline 승격(목록에서 제거). +const XFAIL: &[(&str, &str)] = &[]; + +fn rel_of(path: &Path, root: &Path) -> String { + path.strip_prefix(root) + .expect("strip_prefix") + .to_string_lossy() + .replace('\\', "/") +} + +/// `samples/` 에서 `.hwp` 를 재귀 수집해 루트 기준 상대 경로(슬래시 구분)로 반환. +fn collect_samples() -> Vec<(PathBuf, String)> { + fn walk(dir: &Path, root: &Path, acc: &mut Vec<(PathBuf, String)>) { + let entries = std::fs::read_dir(dir).expect("samples 읽기 실패"); + for entry in entries { + let path = entry.expect("디렉토리 항목 읽기 실패").path(); + if path.is_dir() { + walk(&path, root, acc); + } else if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("hwp")) + { + let rel = rel_of(&path, root); + acc.push((path, rel)); + } + } + } + let root = Path::new(SAMPLES_ROOT); + let mut acc = Vec::new(); + walk(root, root, &mut acc); + acc.sort_by(|a, b| a.1.cmp(&b.1)); + assert!(!acc.is_empty(), "samples 에 .hwp 샘플이 없음"); + acc +} + +fn in_list(list: &[(&str, &str)], rel: &str) -> bool { + list.iter().any(|(name, _)| *name == rel) +} + +/// 범위 밖(자동 제외) 여부: HWP5 가 아니거나(HWP3 등) 배포용 문서. +/// `Some(사유)` 면 제외 대상. +fn out_of_scope(bytes: &[u8]) -> Option<&'static str> { + if detect_format(bytes) != FileFormat::Hwp { + return Some("HWP5 아님(HWP3/HWPML 등)"); + } + match parse_document(bytes) { + Ok(doc) if doc.header.distribution => Some("배포용 문서"), + _ => None, + } +} + +/// baseline 대상(범위 밖/XFAIL 제외)을 검사하고 실패 목록을 단언한다. +fn run_baseline(size_filter: impl Fn(u64) -> bool) { + let mut failures = Vec::new(); + let mut eligible = 0usize; + + for (path, rel) in collect_samples() { + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + if !size_filter(size) || in_list(XFAIL, &rel) { + continue; + } + let bytes = std::fs::read(&path).expect("읽기 실패"); + if out_of_scope(&bytes).is_some() { + continue; + } + eligible += 1; + if let Err(reason) = baseline_check(&bytes) { + failures.push(format!(" {rel}: {reason}")); + } + } + + assert!(eligible > 0, "baseline 검사 대상이 없음"); + assert!( + failures.is_empty(), + "baseline 샘플 {}건 중 {}건 실패 — 결함 수정 또는 사유와 함께 XFAIL 등록 필요:\n{}", + eligible, + failures.len(), + failures.join("\n") + ); +} + +/// A등급 전수 게이트 (소형) — 신규 샘플은 자동 포함. +#[test] +fn baseline_all_samples_roundtrip() { + run_baseline(|sz| sz <= LARGE_THRESHOLD); +} + +/// A등급 전수 게이트 (대형) — 하네스 병렬 실행으로 wall time 단축. +#[test] +fn baseline_large_samples_roundtrip() { + run_baseline(|sz| sz > LARGE_THRESHOLD); +} + +/// B등급(xfail) 샘플은 여전히 실패해야 한다 — 통과하게 되면 baseline 승격 필요. +#[test] +fn xfail_entries_still_fail() { + for (name, reason) in XFAIL { + let path = Path::new(SAMPLES_ROOT).join(name); + assert!(path.exists(), "XFAIL 샘플 실종: {name} (목록 정비 필요)"); + let bytes = std::fs::read(&path).expect("읽기 실패"); + assert!( + out_of_scope(&bytes).is_none(), + "XFAIL 은 범위 내(HWP5·편집가능) 샘플이어야 함: {name}" + ); + assert!( + baseline_check(&bytes).is_err(), + "XFAIL 샘플이 통과함: {name} — baseline 으로 승격하고 XFAIL 에서 제거하라 (사유였던 결함: {reason})" + ); + } +}